feat(admin): 新增全套运营管理功能,修复核心路由bug及后端seed问题
修复Vue Admin布局中RouterView未绑定唯一key的问题,解决RBAC系统资源页面切换后内容不刷新的ISSUE-001故障,新增routeViewKey工具函数区分路由实例 完善路由配置,新增admin空路径路由,修复路由命名空路径告警 新增媒体管理、运维发布/重置页面,补全全站路由注册与动态路由过滤逻辑 新增玩法、线索、管家、系统资源管理的全套工具函数、业务组件及单元测试用例 修复后端app/seed.py中媒体资源重复检测未考虑会话内pending资源的bug,新增对应测试验证修复逻辑 修正alembic迁移版本0015的依赖关系,删除废弃的旧迁移文件 优化后端系统接口的字典序列化逻辑,简化重复代码 添加QA验收报告与相关测试截图,完善项目测试覆盖
This commit is contained in:
1 parent
fa6fd2a8eb
commit
efab6d1398
73 files changed
+3963
-788
No files matched your search
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { compactConciergeAdvisor, createEmptyConciergeAdvisor, validateConciergeAdvisor } from "@/lib/concierge";
|
||||
import type { ConciergeAdvisor, ConciergeAdvisorCreate, ConciergeDetail } from "@/types";
|
||||
import ConciergeDetailEditor from "@/components/concierge/ConciergeDetailEditor.vue";
|
||||
import SingleMediaEditor from "@/components/media/SingleMediaEditor.vue";
|
||||
|
||||
const props = defineProps<{ advisor?: ConciergeAdvisor; editing: boolean; saving: boolean }>();
|
||||
const emit = defineEmits<{ (event: "save", value: ConciergeAdvisorCreate): void; (event: "cancel"): void }>();
|
||||
const draft = ref<ConciergeAdvisorCreate>(toDraft(props.advisor));
|
||||
const error = ref("");
|
||||
|
||||
watch(() => props.advisor, (advisor) => { draft.value = toDraft(advisor); error.value = ""; }, { deep: true });
|
||||
|
||||
function update<K extends keyof ConciergeAdvisorCreate>(field: K, value: ConciergeAdvisorCreate[K]) {
|
||||
draft.value = { ...draft.value, [field]: value };
|
||||
}
|
||||
|
||||
function save() {
|
||||
const payload = compactConciergeAdvisor(draft.value);
|
||||
error.value = validateConciergeAdvisor(payload) ?? "";
|
||||
if (error.value) return;
|
||||
emit("save", payload);
|
||||
}
|
||||
|
||||
function toDraft(advisor?: ConciergeAdvisor): ConciergeAdvisorCreate {
|
||||
return advisor ? {
|
||||
avatar: advisor.avatar ?? "",
|
||||
name: advisor.name,
|
||||
role: advisor.role,
|
||||
details: (advisor.details ?? []).map((item) => ({ ...item })),
|
||||
qrImage: advisor.qrImage ?? "",
|
||||
isActive: advisor.isActive,
|
||||
sortOrder: advisor.sortOrder,
|
||||
} : createEmptyConciergeAdvisor();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form class="concierge-editor" label-position="top" @submit.prevent="save">
|
||||
<div class="editor-heading"><div><p class="eyebrow">{{ editing ? "EDIT" : "CREATE" }} / CONCIERGE</p><h3>{{ editing ? "编辑管家顾问" : "新增管家顾问" }}</h3><p>维护顾问卡片、服务详情和二维码,保存后由发布快照控制前台生效。</p></div><el-button text :disabled="saving" @click="emit('cancel')">取消</el-button></div>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<el-form-item label="顾问姓名" required><el-input :model-value="draft.name" :disabled="saving" maxlength="80" show-word-limit @update:model-value="update('name', $event)" /></el-form-item>
|
||||
<el-form-item label="顾问职位" required><el-input :model-value="draft.role" :disabled="saving" maxlength="120" show-word-limit @update:model-value="update('role', $event)" /></el-form-item>
|
||||
<div class="editor-options"><SingleMediaEditor :model-value="draft.avatar" group="concierge" label="顾问头像" :disabled="saving" @update:model-value="update('avatar', $event)" /><SingleMediaEditor :model-value="draft.qrImage" group="concierge" label="添加管家二维码" :disabled="saving" @update:model-value="update('qrImage', $event)" /></div>
|
||||
<ConciergeDetailEditor :model-value="draft.details as ConciergeDetail[]" :disabled="saving" @update:model-value="update('details', $event)" />
|
||||
<el-form-item label="启用状态"><el-switch :model-value="draft.isActive ?? true" :disabled="saving" active-text="启用" inactive-text="停用" @update:model-value="update('isActive', $event)" /></el-form-item>
|
||||
<div class="editor-actions"><el-button :disabled="saving" @click="emit('cancel')">取消</el-button><el-button type="primary" native-type="submit" :loading="saving">{{ editing ? "保存修改" : "创建顾问" }}</el-button></div>
|
||||
</el-form>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { ConciergeDetail } from "@/types";
|
||||
|
||||
const props = defineProps<{ modelValue: ConciergeDetail[]; disabled: boolean }>();
|
||||
const emit = defineEmits<{ (event: "update:modelValue", value: ConciergeDetail[]): void }>();
|
||||
|
||||
function update(index: number, field: keyof ConciergeDetail, value: string) {
|
||||
const details = props.modelValue.map((item) => ({ ...item }));
|
||||
details[index][field] = value;
|
||||
emit("update:modelValue", details);
|
||||
}
|
||||
|
||||
function add() { emit("update:modelValue", [...props.modelValue, { icon: "", label: "" }]); }
|
||||
function remove(index: number) { emit("update:modelValue", props.modelValue.filter((_, itemIndex) => itemIndex !== index)); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="concierge-detail-editor">
|
||||
<div class="subsection-heading"><div><strong>服务详情</strong><span>每条填写图标名称和服务说明,提交时保留顺序。</span></div><el-button link type="primary" :disabled="disabled" @click="add">新增一条</el-button></div>
|
||||
<el-empty v-if="!modelValue.length" :image-size="48" description="暂未配置服务详情" />
|
||||
<div v-for="(item, index) in modelValue" :key="index" class="concierge-detail-row">
|
||||
<el-input :model-value="item.icon" :disabled="disabled" placeholder="图标,如 calendar" aria-label="服务详情图标" @update:model-value="update(index, 'icon', $event)" />
|
||||
<el-input :model-value="item.label" :disabled="disabled" placeholder="服务详情说明" aria-label="服务详情说明" @update:model-value="update(index, 'label', $event)" />
|
||||
<el-button link type="danger" :disabled="disabled" @click="remove(index)">移除</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { uploadMediaAsset } from "@/api/client";
|
||||
import { validateMediaFile } from "@/lib/media";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
group: string;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ (event: "update:modelValue", value: string): void }>();
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
function openPicker() { input.value?.click(); }
|
||||
|
||||
async function upload(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
target.value = "";
|
||||
if (!file) return;
|
||||
const validationError = validateMediaFile(file);
|
||||
if (validationError) {
|
||||
error.value = validationError;
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const asset = await uploadMediaAsset(file, props.group);
|
||||
emit("update:modelValue", asset.url);
|
||||
} catch (uploadError) {
|
||||
error.value = uploadError instanceof Error ? uploadError.message : "图片上传失败,请重试";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clear() { emit("update:modelValue", ""); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="single-media-editor">
|
||||
<div class="single-media-heading"><strong>{{ label }}</strong><el-button type="primary" plain :disabled="disabled || uploading" :loading="uploading" @click="openPicker">{{ modelValue ? "更换图片" : "上传图片" }}</el-button></div>
|
||||
<input ref="input" class="media-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" :disabled="disabled || uploading" @change="upload" />
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<el-image v-if="modelValue" class="single-media-preview" :src="modelValue" fit="cover" :preview-src-list="[modelValue]" />
|
||||
<el-empty v-else :image-size="48" description="暂无图片" />
|
||||
<el-button v-if="modelValue" link type="danger" :disabled="disabled || uploading" @click="clear">移除图片</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { getSiteModuleDefinition } from "@/lib/site-config";
|
||||
import type { SiteItemPatch, SiteModule, SiteServiceLine } from "@/types";
|
||||
|
||||
const props = defineProps<{
|
||||
module: SiteModule;
|
||||
draft: SiteItemPatch;
|
||||
creating: boolean;
|
||||
saving: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:draft", value: SiteItemPatch): void;
|
||||
(event: "save"): void;
|
||||
(event: "cancel"): void;
|
||||
}>();
|
||||
|
||||
const definition = computed(() => getSiteModuleDefinition(props.module));
|
||||
const editorTitle = computed(() => `${props.creating ? "新增" : "编辑"}${definition.value.label}`);
|
||||
const imageModule = computed(() => props.module === "heroSlides" || props.module === "destinationHero" || props.module === "vehicleOptions");
|
||||
const primaryValue = computed(() => props.draft[definition.value.primaryField] ?? "");
|
||||
|
||||
const serviceSectionsText = computed({
|
||||
get: () => formatLines(props.draft.serviceSections),
|
||||
set: (value: string) => updateField("serviceSections", parseLines(value)),
|
||||
});
|
||||
|
||||
const advantagesText = computed({
|
||||
get: () => (props.draft.advantages ?? []).join("\n"),
|
||||
set: (value: string) => updateField("advantages", parseList(value)),
|
||||
});
|
||||
|
||||
const processStepsText = computed({
|
||||
get: () => formatLines(props.draft.processSteps),
|
||||
set: (value: string) => updateField("processSteps", parseLines(value)),
|
||||
});
|
||||
|
||||
function updateField<K extends keyof SiteItemPatch>(field: K, value: SiteItemPatch[K]) {
|
||||
emit("update:draft", { ...props.draft, [field]: value });
|
||||
}
|
||||
|
||||
function parseList(value: string) {
|
||||
return value.split(/\r?\n|,|,/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseLines(value: string): SiteServiceLine[] {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const [title = "", ...description] = line.split("|");
|
||||
return { title: title.trim(), description: description.join("|").trim() };
|
||||
})
|
||||
.filter((item) => item.title || item.description);
|
||||
}
|
||||
|
||||
function formatLines(lines: SiteServiceLine[] | undefined) {
|
||||
return (lines ?? []).map((item) => `${item.title} | ${item.description}`).join("\n");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form class="site-config-editor" :model="draft" label-position="top" @submit.prevent="emit('save')">
|
||||
<div class="editor-heading">
|
||||
<div>
|
||||
<p class="eyebrow">{{ creating ? "CREATE" : "EDIT" }} / SITE CONFIG</p>
|
||||
<h3>{{ editorTitle }}</h3>
|
||||
<p>{{ definition.description }}</p>
|
||||
</div>
|
||||
<el-button text @click="emit('cancel')">取消</el-button>
|
||||
</div>
|
||||
|
||||
<el-form-item :label="definition.primaryField === 'introTitle' ? '服务标题' : definition.primaryField === 'submitLabel' ? '提交按钮文案' : '标题'" required>
|
||||
<el-input
|
||||
:model-value="String(primaryValue)"
|
||||
:disabled="saving"
|
||||
maxlength="80"
|
||||
show-word-limit
|
||||
@update:model-value="updateField(definition.primaryField, $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="module === 'heroSlides' || module === 'destinationHero'">
|
||||
<el-form-item label="副标题">
|
||||
<el-input :model-value="draft.kicker ?? ''" :disabled="saving" maxlength="80" @update:model-value="updateField('kicker', $event)" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="module === 'demandHero'">
|
||||
<el-form-item label="上方小标题"><el-input :model-value="draft.kicker ?? ''" :disabled="saving" maxlength="80" @update:model-value="updateField('kicker', $event)" /></el-form-item>
|
||||
<el-form-item label="主说明"><el-input :model-value="draft.description ?? ''" :disabled="saving" type="textarea" :rows="4" maxlength="4000" @update:model-value="updateField('description', $event)" /></el-form-item>
|
||||
<el-form-item label="定制步骤(每行一项)"><el-input :model-value="(draft.steps ?? []).join('\n')" :disabled="saving" type="textarea" :rows="5" @update:model-value="updateField('steps', $event.split(/\r?\n|,|,/).map((item) => item.trim()).filter(Boolean))" /></el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="module === 'demandFeatureCards'">
|
||||
<el-form-item label="卡片描述"><el-input :model-value="draft.description ?? ''" :disabled="saving" type="textarea" :rows="4" maxlength="4000" @update:model-value="updateField('description', $event)" /></el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="module === 'demandForm'">
|
||||
<div class="editor-options">
|
||||
<el-form-item label="目的地字段名称"><el-input :model-value="draft.destinationLabel ?? ''" :disabled="saving" @update:model-value="updateField('destinationLabel', $event)" /></el-form-item>
|
||||
<el-form-item label="目的地占位文案"><el-input :model-value="draft.destinationPlaceholder ?? ''" :disabled="saving" @update:model-value="updateField('destinationPlaceholder', $event)" /></el-form-item>
|
||||
<el-form-item label="联系方式字段名称"><el-input :model-value="draft.phoneLabel ?? ''" :disabled="saving" @update:model-value="updateField('phoneLabel', $event)" /></el-form-item>
|
||||
<el-form-item label="联系方式占位文案"><el-input :model-value="draft.phonePlaceholder ?? ''" :disabled="saving" @update:model-value="updateField('phonePlaceholder', $event)" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="补充说明字段名称"><el-input :model-value="draft.noteLabel ?? ''" :disabled="saving" @update:model-value="updateField('noteLabel', $event)" /></el-form-item>
|
||||
<el-form-item label="补充说明占位文案"><el-input :model-value="draft.notePlaceholder ?? ''" :disabled="saving" type="textarea" :rows="3" @update:model-value="updateField('notePlaceholder', $event)" /></el-form-item>
|
||||
<el-form-item label="快捷选项(每行一项)"><el-input :model-value="(draft.chips ?? []).join('\n')" :disabled="saving" type="textarea" :rows="5" @update:model-value="updateField('chips', $event.split(/\r?\n|,|,/).map((item) => item.trim()).filter(Boolean))" /></el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="module === 'vehicleOptions'">
|
||||
<el-form-item label="卡片描述">
|
||||
<el-input :model-value="draft.description ?? ''" :disabled="saving" type="textarea" :rows="4" maxlength="240" show-word-limit @update:model-value="updateField('description', $event)" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="module === 'vehicleService'">
|
||||
<el-form-item label="板块简介">
|
||||
<el-input :model-value="draft.intro ?? ''" :disabled="saving" type="textarea" :rows="4" maxlength="500" show-word-limit @update:model-value="updateField('intro', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务说明(每行:标题 | 说明)">
|
||||
<el-input v-model="serviceSectionsText" :disabled="saving" type="textarea" :rows="6" placeholder="接送机 | 行程衔接" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务优势(每行一项)">
|
||||
<el-input v-model="advantagesText" :disabled="saving" type="textarea" :rows="5" placeholder="车型灵活" />
|
||||
</el-form-item>
|
||||
<el-form-item label="使用流程(每行:步骤 | 说明)">
|
||||
<el-input v-model="processStepsText" :disabled="saving" type="textarea" :rows="6" placeholder="沟通 | 确认需求" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="imageModule">
|
||||
<el-form-item label="资源图片 URL">
|
||||
<el-input :model-value="draft.image ?? ''" :disabled="saving" placeholder="https://..." @update:model-value="updateField('image', $event)" />
|
||||
<p class="field-hint">填写最终 HTTP(S) 图片地址,不上传或保存 base64 内容。</p>
|
||||
<el-image v-if="draft.image" class="site-image-preview" :src="draft.image" fit="cover" :preview-src-list="[draft.image]" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<div class="editor-options">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch :model-value="draft.isActive ?? true" :disabled="saving" active-text="启用" inactive-text="停用" @update:model-value="updateField('isActive', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!definition.singleton" label="排序值">
|
||||
<el-input-number :model-value="draft.sortOrder ?? 0" :disabled="saving" :min="0" :step="1" controls-position="right" @update:model-value="updateField('sortOrder', $event ?? 0)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<el-button :disabled="saving" @click="emit('cancel')">取消</el-button>
|
||||
<el-button type="primary" native-type="submit" :loading="saving">保存配置</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</template>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import type {
|
||||
SystemDeptItem,
|
||||
SystemMenuItem,
|
||||
SystemRoleItem,
|
||||
SystemUserItem,
|
||||
} from "@/types";
|
||||
|
||||
export type SystemResourceName = "users" | "roles" | "menus" | "depts";
|
||||
|
||||
type EditorItem = SystemUserItem | SystemRoleItem | SystemMenuItem | SystemDeptItem;
|
||||
|
||||
export type SystemEditorSavePayload = {
|
||||
resource: SystemResourceName;
|
||||
mode: "create" | "edit";
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
resource: SystemResourceName;
|
||||
mode: "create" | "edit";
|
||||
item: EditorItem | null;
|
||||
roles: SystemRoleItem[];
|
||||
menus: SystemMenuItem[];
|
||||
depts: SystemDeptItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: boolean): void;
|
||||
(event: "save", payload: SystemEditorSavePayload): void;
|
||||
}>();
|
||||
|
||||
type FormState = {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
isActive: boolean;
|
||||
roleIds: string[];
|
||||
deptIds: string[];
|
||||
menuIds: string[];
|
||||
code: string;
|
||||
dataScope: SystemRoleItem["dataScope"];
|
||||
description: string;
|
||||
parentId: string | null;
|
||||
type: SystemMenuItem["type"];
|
||||
path: string;
|
||||
componentKey: string;
|
||||
permissionCode: string;
|
||||
icon: string;
|
||||
sortOrder: number;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
function blankForm(): FormState {
|
||||
return {
|
||||
email: "",
|
||||
name: "",
|
||||
password: "",
|
||||
isActive: true,
|
||||
roleIds: [],
|
||||
deptIds: [],
|
||||
menuIds: [],
|
||||
code: "",
|
||||
dataScope: "self",
|
||||
description: "",
|
||||
parentId: null,
|
||||
type: "page",
|
||||
path: "",
|
||||
componentKey: "",
|
||||
permissionCode: "",
|
||||
icon: "",
|
||||
sortOrder: 0,
|
||||
isVisible: true,
|
||||
};
|
||||
}
|
||||
|
||||
const form = reactive<FormState>(blankForm());
|
||||
const formRef = ref<{ validate: () => Promise<boolean> } | null>(null);
|
||||
const title = computed(() => `${props.mode === "create" ? "新增" : "编辑"}${({ users: "用户", roles: "角色", menus: "菜单", depts: "部门" } as Record<SystemResourceName, string>)[props.resource]}`);
|
||||
const menuParents = computed(() => props.menus.filter((item) => item.id !== props.item?.id && item.type !== "button"));
|
||||
|
||||
function syncForm(item: EditorItem | null) {
|
||||
Object.assign(form, blankForm());
|
||||
if (!item) return;
|
||||
if (props.resource === "users") {
|
||||
const user = item as SystemUserItem;
|
||||
Object.assign(form, { email: user.email, name: user.name, isActive: user.isActive !== false, roleIds: [...user.roleIds], deptIds: [...user.deptIds] });
|
||||
} else if (props.resource === "roles") {
|
||||
const role = item as SystemRoleItem;
|
||||
Object.assign(form, { name: role.name, code: role.code, dataScope: role.dataScope, description: role.description || "", isActive: role.isActive, menuIds: [...role.menuIds], deptIds: [...role.deptIds] });
|
||||
} else if (props.resource === "menus") {
|
||||
const menu = item as SystemMenuItem;
|
||||
Object.assign(form, { name: menu.name, parentId: menu.parentId || null, type: menu.type, path: menu.path || "", componentKey: menu.component || "", permissionCode: menu.permission || "", icon: menu.icon || "", sortOrder: menu.sortOrder, isVisible: menu.isVisible, isActive: menu.isActive });
|
||||
} else {
|
||||
const dept = item as SystemDeptItem;
|
||||
Object.assign(form, { name: dept.name, parentId: dept.parentId || null, sortOrder: dept.sortOrder, isActive: dept.isActive });
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.item, props.resource, props.mode], () => syncForm(props.item), { immediate: true });
|
||||
|
||||
async function submit() {
|
||||
if (formRef.value && !(await formRef.value.validate())) return;
|
||||
const input: Record<string, unknown> = { ...form };
|
||||
if (props.mode === "edit") {
|
||||
delete input.email;
|
||||
if (props.resource === "users" && !String(input.password || "").trim()) delete input.password;
|
||||
}
|
||||
if (props.resource === "menus") {
|
||||
input.componentKey = form.componentKey || null;
|
||||
input.permissionCode = form.permissionCode || null;
|
||||
input.path = form.path || null;
|
||||
input.parentId = form.parentId || null;
|
||||
}
|
||||
if (props.resource === "depts") input.parentId = form.parentId || null;
|
||||
if (props.resource === "roles") input.description = form.description || null;
|
||||
emit("save", { resource: props.resource, mode: props.mode, input });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" :title="title" width="720px" destroy-on-close @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-form ref="formRef" :model="form" label-width="110px" class="system-editor-form">
|
||||
<template v-if="resource === 'users'">
|
||||
<el-form-item label="登录邮箱" prop="email" required><el-input v-model="form.email" :disabled="mode === 'edit'" /></el-form-item>
|
||||
<el-form-item label="姓名" prop="name" required><el-input v-model="form.name" maxlength="80" /></el-form-item>
|
||||
<el-form-item label="密码" prop="password" :required="mode === 'create'"><el-input v-model="form.password" type="password" show-password :placeholder="mode === 'edit' ? '留空表示不修改' : '至少 8 位'" /></el-form-item>
|
||||
<el-form-item label="角色"><el-select v-model="form.roleIds" multiple filterable class="full-width"><el-option v-for="role in roles" :key="role.id" :label="`${role.name} (${role.code})`" :value="role.id" /></el-select></el-form-item>
|
||||
<el-form-item label="部门"><el-select v-model="form.deptIds" multiple filterable class="full-width"><el-option v-for="dept in depts" :key="dept.id" :label="dept.name" :value="dept.id" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-switch v-model="form.isActive" active-text="启用" inactive-text="停用" /></el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="resource === 'roles'">
|
||||
<el-form-item label="角色名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item>
|
||||
<el-form-item label="角色编码" required><el-input v-model="form.code" maxlength="80" /></el-form-item>
|
||||
<el-form-item label="数据范围"><el-select v-model="form.dataScope" class="full-width"><el-option label="全部数据" value="all" /><el-option label="当前部门" value="dept" /><el-option label="当前部门及子部门" value="dept_and_children" /><el-option label="自定义部门" value="custom_dept" /><el-option label="本人数据" value="self" /></el-select></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="菜单权限"><el-select v-model="form.menuIds" multiple filterable class="full-width"><el-option v-for="menu in menus" :key="menu.id" :label="`${menu.name} (${menu.permission || menu.type})`" :value="menu.id" /></el-select></el-form-item>
|
||||
<el-form-item label="部门范围"><el-select v-model="form.deptIds" multiple filterable class="full-width"><el-option v-for="dept in depts" :key="dept.id" :label="dept.name" :value="dept.id" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-switch v-model="form.isActive" active-text="启用" inactive-text="停用" /></el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="resource === 'menus'">
|
||||
<el-form-item label="菜单名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item>
|
||||
<el-form-item label="菜单类型" required><el-select v-model="form.type" class="full-width"><el-option label="目录" value="directory" /><el-option label="页面" value="page" /><el-option label="按钮" value="button" /></el-select></el-form-item>
|
||||
<el-form-item label="父级菜单"><el-select v-model="form.parentId" clearable class="full-width"><el-option v-for="menu in menuParents" :key="menu.id" :label="menu.name" :value="menu.id" /></el-select></el-form-item>
|
||||
<el-form-item label="路径"><el-input v-model="form.path" placeholder="/example" /></el-form-item>
|
||||
<el-form-item label="组件白名单"><el-input v-model="form.componentKey" placeholder="HomePage" /></el-form-item>
|
||||
<el-form-item label="权限编码"><el-input v-model="form.permissionCode" placeholder="admin:example:read" /></el-form-item>
|
||||
<el-form-item label="图标"><el-input v-model="form.icon" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
|
||||
<el-form-item label="显示状态"><el-switch v-model="form.isVisible" active-text="显示" inactive-text="隐藏" /></el-form-item>
|
||||
<el-form-item label="启用状态"><el-switch v-model="form.isActive" active-text="启用" inactive-text="停用" /></el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-form-item label="部门名称" required><el-input v-model="form.name" maxlength="120" /></el-form-item>
|
||||
<el-form-item label="父级部门"><el-select v-model="form.parentId" clearable class="full-width"><el-option v-for="dept in depts.filter((item) => item.id !== props.item?.id)" :key="dept.id" :label="dept.name" :value="dept.id" /></el-select></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
|
||||
<el-form-item label="状态"><el-switch v-model="form.isActive" active-text="启用" inactive-text="停用" /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="emit('update:modelValue', false)">取消</el-button><el-button type="primary" @click="submit">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { DetailCreate, ConciergeAdvisor } from "@/types";
|
||||
import ImageGalleryEditor from "@/components/wanfa/ImageGalleryEditor.vue";
|
||||
|
||||
const props = defineProps<{ modelValue: DetailCreate; advisors: ConciergeAdvisor[]; disabled: boolean }>();
|
||||
const emit = defineEmits<{ (event: "update:modelValue", value: DetailCreate): void }>();
|
||||
const listFields = [
|
||||
["highlights", "行程亮点", "每行填写一项亮点"],
|
||||
["included", "费用包含", "每行填写一项包含内容"],
|
||||
["excluded", "费用不含", "每行填写一项不包含内容"],
|
||||
["notes", "注意事项", "每行填写一项注意事项"],
|
||||
] as const;
|
||||
|
||||
function update<K extends keyof DetailCreate>(field: K, value: DetailCreate[K]) {
|
||||
emit("update:modelValue", { ...props.modelValue, [field]: value });
|
||||
}
|
||||
|
||||
function updateLines(field: "highlights" | "included" | "excluded" | "notes" | "gallery", value: string) {
|
||||
update(field, value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="detail-fields">
|
||||
<div class="detail-fields-heading">
|
||||
<strong>路线详情</strong>
|
||||
<span>详情通过独立接口保存,数组字段按每行一项提交。</span>
|
||||
</div>
|
||||
<el-form-item label="详情眉标" required><el-input :model-value="modelValue.eyebrow" maxlength="80" :disabled="disabled" @update:model-value="update('eyebrow', $event)" /></el-form-item>
|
||||
<el-form-item label="出行时长" required><el-input :model-value="modelValue.duration" maxlength="80" :disabled="disabled" placeholder="例如:5天4晚" @update:model-value="update('duration', $event)" /></el-form-item>
|
||||
<el-form-item label="详情标题" required><el-input :model-value="modelValue.title" maxlength="160" :disabled="disabled" @update:model-value="update('title', $event)" /></el-form-item>
|
||||
<el-form-item label="详情副标题" required><el-input :model-value="modelValue.subtitle" type="textarea" :rows="2" maxlength="240" :disabled="disabled" @update:model-value="update('subtitle', $event)" /></el-form-item>
|
||||
<el-form-item label="详情介绍" required><el-input :model-value="modelValue.intro" type="textarea" :rows="5" maxlength="4000" :disabled="disabled" @update:model-value="update('intro', $event)" /></el-form-item>
|
||||
<el-form-item v-for="[field, label, placeholder] in listFields" :key="field" :label="label">
|
||||
<el-input :model-value="modelValue[field].join('\n')" type="textarea" :rows="4" :disabled="disabled" :placeholder="placeholder" @update:model-value="updateLines(field, $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="详情图片 URL(每行一张)">
|
||||
<el-input :model-value="modelValue.gallery.join('\n')" type="textarea" :rows="4" :disabled="disabled" placeholder="https://..." @update:model-value="updateLines('gallery', $event)" />
|
||||
<p class="field-hint">可直接填写 HTTP(S) 地址,也可以使用下方上传入口。</p>
|
||||
</el-form-item>
|
||||
<ImageGalleryEditor :model-value="modelValue.gallery" :disabled="disabled" @update:model-value="update('gallery', $event)" />
|
||||
<el-form-item label="联系管家">
|
||||
<el-select :model-value="modelValue.conciergeAdvisorId ?? '__none__'" :disabled="disabled" placeholder="不配置管家" @update:model-value="update('conciergeAdvisorId', $event === '__none__' ? null : $event)">
|
||||
<el-option value="__none__" label="不配置管家" />
|
||||
<el-option v-for="advisor in advisors" :key="advisor.id" :value="advisor.id" :label="`${advisor.name} · ${advisor.role}${advisor.isActive ? '' : '(已停用)'}`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="详情状态"><el-switch :model-value="modelValue.isActive" :disabled="disabled" active-text="启用" inactive-text="停用" @update:model-value="update('isActive', $event)" /></el-form-item>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { uploadMediaAsset } from "@/api/client";
|
||||
|
||||
const props = defineProps<{ modelValue: string[]; disabled: boolean }>();
|
||||
const emit = defineEmits<{ (event: "update:modelValue", value: string[]): void }>();
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||
|
||||
function openPicker() { input.value?.click(); }
|
||||
|
||||
async function uploadFiles(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []);
|
||||
if (!files.length) return;
|
||||
error.value = "";
|
||||
uploading.value = true;
|
||||
try {
|
||||
let next = [...props.modelValue];
|
||||
for (const file of files) {
|
||||
if (!allowedTypes.has(file.type)) throw new Error("仅支持 JPG、PNG、WEBP 或 GIF 图片");
|
||||
if (file.size > 5 * 1024 * 1024) throw new Error("单张图片不能超过 5MB");
|
||||
const asset = await uploadMediaAsset(file, "wanfa-detail");
|
||||
next = [...next, asset.url];
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
ElMessage.success(`已上传 ${files.length} 张详情图片`);
|
||||
} catch (uploadError) {
|
||||
error.value = uploadError instanceof Error ? uploadError.message : "图片上传失败,请重试";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function remove(index: number) { emit("update:modelValue", props.modelValue.filter((_, itemIndex) => itemIndex !== index)); }
|
||||
|
||||
function move(index: number, direction: -1 | 1) {
|
||||
const nextIndex = index + direction;
|
||||
if (nextIndex < 0 || nextIndex >= props.modelValue.length) return;
|
||||
const next = [...props.modelValue];
|
||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gallery-editor">
|
||||
<div class="gallery-editor-heading"><div><strong>详情图片</strong><span>上传后按当前顺序保存到详情记录。</span></div><el-button type="primary" plain :disabled="disabled || uploading" :loading="uploading" @click="openPicker">上传图片</el-button></div>
|
||||
<input ref="input" class="media-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" multiple :disabled="disabled || uploading" @change="uploadFiles" />
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<el-empty v-if="!modelValue.length" :image-size="56" description="暂无详情图片" />
|
||||
<div v-else class="gallery-grid">
|
||||
<div v-for="(image, index) in modelValue" :key="`${image}-${index}`" class="gallery-item">
|
||||
<el-image :src="image" fit="cover" :preview-src-list="modelValue" />
|
||||
<small>{{ index + 1 }}. {{ image }}</small>
|
||||
<div class="gallery-actions"><el-button link size="small" :disabled="disabled || uploading || index === 0" @click="move(index, -1)">上移</el-button><el-button link size="small" :disabled="disabled || uploading || index === modelValue.length - 1" @click="move(index, 1)">下移</el-button><el-button link size="small" type="danger" :disabled="disabled || uploading" @click="remove(index)">移除</el-button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{ label: string; editing: boolean; saving: boolean }>();
|
||||
const emit = defineEmits<{ (event: "save", label: string): void; (event: "cancel"): void }>();
|
||||
const value = ref(props.label);
|
||||
const error = ref("");
|
||||
|
||||
watch(() => props.label, (next) => { value.value = next; error.value = ""; });
|
||||
|
||||
function submit() {
|
||||
const next = value.value.trim();
|
||||
if (!next) {
|
||||
error.value = "请填写分类名称";
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
emit("save", next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form class="wanfa-editor" label-position="top" @submit.prevent="submit">
|
||||
<div class="editor-heading">
|
||||
<div>
|
||||
<p class="eyebrow">{{ editing ? "EDIT" : "CREATE" }} / PLAY CATEGORY</p>
|
||||
<h3>{{ editing ? "编辑玩法分类" : "新增玩法分类" }}</h3>
|
||||
<p>{{ editing ? "修改后会同步更新前台玩法入口。" : "创建后即可在分类下维护路线。" }}</p>
|
||||
</div>
|
||||
<el-button text :disabled="saving" @click="emit('cancel')">取消</el-button>
|
||||
</div>
|
||||
<el-form-item label="分类名称" required :error="error">
|
||||
<el-input v-model="value" maxlength="40" show-word-limit :disabled="saving" placeholder="例如:亲子路线" autofocus />
|
||||
</el-form-item>
|
||||
<p class="field-hint">保存前会自动去除首尾空格。</p>
|
||||
<div class="editor-actions">
|
||||
<el-button :disabled="saving" @click="emit('cancel')">取消</el-button>
|
||||
<el-button type="primary" native-type="submit" :loading="saving">{{ editing ? "保存修改" : "创建分类" }}</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { compactDetail, compactWanfaRoute, createEmptyDetail, createEmptyWanfaRoute, validateWanfaWrite } from "@/lib/wanfa";
|
||||
import type { ConciergeAdvisor, DetailCreate, DetailRecord, WanfaRoute, WanfaRouteCreate } from "@/types";
|
||||
import DetailFields from "@/components/wanfa/DetailFields.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
categoryLabel: string;
|
||||
route?: WanfaRoute;
|
||||
detail?: DetailRecord;
|
||||
advisors: ConciergeAdvisor[];
|
||||
saving: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(event: "save", value: { route: WanfaRouteCreate; detail: DetailCreate }): void;
|
||||
(event: "cancel"): void;
|
||||
}>();
|
||||
|
||||
const routeDraft = ref<WanfaRouteCreate>(createEmptyWanfaRoute());
|
||||
const detailDraft = ref<DetailCreate>(createEmptyDetail());
|
||||
const error = ref("");
|
||||
const editing = ref(Boolean(props.route));
|
||||
|
||||
watch(() => [props.route?.id, props.detail?.id], () => {
|
||||
editing.value = Boolean(props.route);
|
||||
routeDraft.value = props.route
|
||||
? { title: props.route.title, subtitle: props.route.subtitle, image: props.route.image, routeCount: props.route.routeCount, demandKeyword: props.route.demandKeyword }
|
||||
: createEmptyWanfaRoute();
|
||||
detailDraft.value = props.detail
|
||||
? {
|
||||
key: props.detail.key,
|
||||
eyebrow: props.detail.eyebrow,
|
||||
duration: props.detail.duration,
|
||||
title: props.detail.title,
|
||||
subtitle: props.detail.subtitle,
|
||||
intro: props.detail.intro,
|
||||
highlights: [...props.detail.highlights],
|
||||
included: [...props.detail.included],
|
||||
excluded: [...props.detail.excluded],
|
||||
notes: [...props.detail.notes],
|
||||
gallery: [...props.detail.gallery],
|
||||
conciergeAdvisorId: props.detail.conciergeAdvisorId,
|
||||
isActive: props.detail.isActive,
|
||||
sortOrder: props.detail.sortOrder,
|
||||
}
|
||||
: createEmptyDetail();
|
||||
error.value = "";
|
||||
}, { immediate: true });
|
||||
|
||||
function submit() {
|
||||
const route = compactWanfaRoute(routeDraft.value);
|
||||
const detail = compactDetail(detailDraft.value);
|
||||
const validationError = validateWanfaWrite(route, detail);
|
||||
if (validationError) {
|
||||
error.value = validationError;
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
emit("save", { route, detail });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form class="wanfa-editor route-editor" label-position="top" @submit.prevent="submit">
|
||||
<div class="editor-heading">
|
||||
<div>
|
||||
<p class="eyebrow">{{ editing ? "EDIT" : "CREATE" }} / PLAY ROUTE</p>
|
||||
<h3>{{ editing ? "编辑分类路线" : "新增分类路线" }}</h3>
|
||||
<p>{{ editing ? `修改「${categoryLabel}」下的路线卡片和详情。` : `添加到「${categoryLabel}」,保存后会排在当前路线末尾。` }}</p>
|
||||
</div>
|
||||
<el-button text :disabled="saving" @click="emit('cancel')">取消</el-button>
|
||||
</div>
|
||||
|
||||
<el-form-item label="路线标题" required><el-input v-model="routeDraft.title" maxlength="80" :disabled="saving" placeholder="例如:亲子玩水" autofocus /></el-form-item>
|
||||
<el-form-item label="副标题/目的地说明" required><el-input v-model="routeDraft.subtitle" type="textarea" :rows="3" maxlength="160" :disabled="saving" /></el-form-item>
|
||||
<div class="editor-options">
|
||||
<el-form-item label="路线数量" required><el-input-number v-model="routeDraft.routeCount" :disabled="saving" :min="0" :step="1" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="需求关键词" required><el-input v-model="routeDraft.demandKeyword" maxlength="60" :disabled="saving" placeholder="例如:亲子玩水" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="路线封面 URL" required>
|
||||
<el-input v-model="routeDraft.image" :disabled="saving" placeholder="https://..." />
|
||||
<el-image v-if="routeDraft.image" class="site-image-preview" :src="routeDraft.image" fit="cover" :preview-src-list="[routeDraft.image]" />
|
||||
</el-form-item>
|
||||
|
||||
<DetailFields v-model="detailDraft" :advisors="advisors" :disabled="saving" />
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<div class="editor-actions">
|
||||
<el-button :disabled="saving" @click="emit('cancel')">取消</el-button>
|
||||
<el-button type="primary" native-type="submit" :loading="saving">{{ editing ? "保存修改" : "创建路线" }}</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</template>
|
||||
Reference in new issue
Block a user