落地(依据《事项库与办事大厅详细设计 v1》§7)
- 路由:办事事项 modId=21 / 服务字典 modId=22 + 子站镜像(/sch/{code}/site/{siteCode}/svc/*)
+ 3 条 legacyRedirect 旧链兼容;SchoolLayout 菜单图标兜底
- ServiceItemList:三维筛选/关键字/分页/发布下架删除/跳转编辑
- ServiceItemEdit:三段富文本 + 封面 + 指南附件上传回显(attList)+「我要办理外链」提示
+ 部门选择器不可达时提示"可留空保存"
- ServiceDictManage:object/category 双 Tab,被引用键编辑锁定 + 删除引用守护提示
- api/类型:SvcItemApi/SvcDictApi 与 types/cms.ts 契约补齐;站点设置补 svc_hall_base 键
审计修复(docs/18)
- D3 接线:列表页新增 ServiceItemSortModal 拖拽排序(顺序源 selList,保存按下标重排 sort_no)
与「同步部门名」按钮,此前后端 sort/syncDeptName 已备但前端无入口
- 附件场景拆分:封面与三个富文本编辑器改传 scene=svc_item_asset,指南附件保持 svc_item
(内容资产由后端按内容派生归属,不再依赖前端收集上传 id——删除只写不读的 uploadedIds 记账)
- 部门组树:SvcDeptVo 增 children,前端 flattenDepts 缩进扁平化(后端改嵌套下发,交互不变)
- RichTextEditor 补 articleId/slateMinHeight prop 并去掉死状态 editorLoading;
ArticleEdit 改为复用该组件(正文区最小高度按原口径传入,观感不变),消除双份 wangEditor 实现
测试与 mock
- mock 补 /api/svc/** 全量桩(事项分页/字典/部门树/排序/同步部门名/附件回显 + 站点 1、2 示例数据),
dev:mock 下三页可用(实测 curl 各项响应正确)
- cms.spec 补 save/syncDeptName/selList/dict save+delete 契约;siteRouter.spec 补事项三条路由
modId 与子站镜像 resolve 断言;新增 RichTextEditor.spec 5 例(上传参数三键/成功 emit/失败不 emit/工具栏排除项)
- vitest 127 用例全绿 + vite build 通过
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import { articleApi, categoryApi, uploadUrl } from "@/api/module/cms";
|
||||
import RichTextEditor from "@/components/RichTextEditor.vue";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
@@ -55,46 +53,11 @@ function flattenCategories(tree: CategoryTreeNode[], depth = 0): { id: number; l
|
||||
return list;
|
||||
}
|
||||
|
||||
/* ===== wangEditor(Notice 已趟坑配置:无视频组、自定义图片上传) ===== */
|
||||
const editorRef = shallowRef();
|
||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const editorConfig = {
|
||||
placeholder: "请输入正文……",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("siteId", String(store.siteId));
|
||||
if (editId.value) fd.append("articleId", String(editId.value));
|
||||
try {
|
||||
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.code === 0 && res.data) {
|
||||
uploadedIds.value.push(res.data.id);
|
||||
insertFn(res.data.url, res.data.fileName || "", res.data.url);
|
||||
} else {
|
||||
Message.error(res.msg || "图片上传失败");
|
||||
}
|
||||
} catch (e) {
|
||||
Message.error("图片上传失败");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor;
|
||||
editorLoading.value = false;
|
||||
/* 富文本正文:走公共 RichTextEditor(审计批次 6 起与事项页共用一份实现,
|
||||
消除原内联 wangEditor 的重复 customUpload;编辑器内贴图 id 由组件 emit uploaded 收集) */
|
||||
function onEditorUploaded(attachmentId: number) {
|
||||
uploadedIds.value.push(attachmentId);
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy();
|
||||
});
|
||||
|
||||
/* ===== 封面上传 ===== */
|
||||
function coverUploaded(res: any) {
|
||||
@@ -282,16 +245,15 @@ watch(() => store.siteId, () => load());
|
||||
<Switch :model-value="form.topmost === 1" @on-change="(v: boolean) => (form.topmost = v ? 1 : 0)" />
|
||||
</FormItem>
|
||||
<FormItem label="正文">
|
||||
<div class="editor-wrap">
|
||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||
<Editor
|
||||
class="editor-content"
|
||||
v-model="form.content"
|
||||
:defaultConfig="editorConfig"
|
||||
mode="default"
|
||||
@onCreated="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
<RichTextEditor
|
||||
v-model="form.content"
|
||||
:site-id="store.siteId"
|
||||
:article-id="editId"
|
||||
placeholder="请输入正文……"
|
||||
slate-min-height="max(480px, calc(100vh - 430px))"
|
||||
class="editor-full"
|
||||
@uploaded="onEditorUploaded"
|
||||
/>
|
||||
</FormItem>
|
||||
<!-- 微信对接预留入口(方案 §7.4):本期禁用灰显,不产生任何请求 -->
|
||||
<FormItem label="同步公众号">
|
||||
@@ -323,25 +285,7 @@ watch(() => store.siteId, () => load());
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.editor-wrap {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
z-index: 100;
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #dcdee2;
|
||||
}
|
||||
.editor-content {
|
||||
// 长图文显示不全修复(2026-09-10):旧做法把 .w-e-text-container 钉在
|
||||
// max(480px, 100vh-430px) 固定高,长文被关进内滚窗口且 Windows overlay
|
||||
// 滚动条不常显,正文尾部看似被截断。改为容器高度随内容自动增长,
|
||||
// 整篇平铺进页面由窗口滚动;空态/短文用 slate 区 min-height 铺满视口余量
|
||||
//(430≈壳层(56+56)+表单区高度,下限 480px)。
|
||||
:deep(.w-e-text-container) {
|
||||
height: auto !important;
|
||||
}
|
||||
:deep([data-slate-editor]) {
|
||||
min-height: max(480px, calc(100vh - 430px)) !important;
|
||||
}
|
||||
}
|
||||
.editor-full {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { svcDictApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { SvcDictVo } from "@/types/cms";
|
||||
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<SvcDictVo[]>([]);
|
||||
const loading = ref(false);
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true });
|
||||
|
||||
/** dict_type 分 Tab:object=服务对象 / category=事项类别 */
|
||||
const dictType = ref("object");
|
||||
const typeDict = [
|
||||
{ value: "object", label: "服务对象" },
|
||||
{ value: "category", label: "事项类别" },
|
||||
];
|
||||
const typeLabel = computed(() => typeDict.find(t => t.value === dictType.value)?.label || "");
|
||||
|
||||
const columns = [
|
||||
{ title: "字典键", key: "dictKey", width: 200, tooltip: true },
|
||||
{ title: "字典名称", key: "dictName", minWidth: 180, tooltip: true },
|
||||
{ title: "排序", key: "sortNo", width: 80, align: "center" },
|
||||
{
|
||||
title: "被引用",
|
||||
key: "referenced",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h("span", { style: { color: p.row.referenced > 0 ? "#2d8cf0" : "#999" } }, String(p.row.referenced)),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () => (p.row.status === 1 ? "启用" : "停用")),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 120,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const formRef = ref();
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
dictKey: "",
|
||||
dictName: "",
|
||||
sortNo: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
dictKey: [
|
||||
{ required: true, message: "字典键不得为空", trigger: "blur" },
|
||||
{ pattern: /^[A-Za-z0-9_-]{1,64}$/, message: "仅限字母/数字/下划线/中划线", trigger: "blur" },
|
||||
],
|
||||
dictName: [{ required: true, message: "字典名称不得为空", trigger: "blur" }],
|
||||
};
|
||||
|
||||
/** 编辑且已被引用时键不可改(服务端同口径拦截;前端仅提示性禁用) */
|
||||
const keyLocked = computed(() => form.id != null && editingReferenced.value > 0);
|
||||
const editingReferenced = ref(0);
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await svcDictApi.list(store.siteId, dictType.value).send()) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: undefined, dictKey: "", dictName: "", sortNo: 0, status: 1 });
|
||||
editingReferenced.value = 0;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: SvcDictVo) {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
dictKey: row.dictKey,
|
||||
dictName: row.dictName,
|
||||
sortNo: row.sortNo,
|
||||
status: row.status,
|
||||
});
|
||||
editingReferenced.value = row.referenced;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await svcDictApi
|
||||
.save({
|
||||
id: form.id,
|
||||
siteId: store.siteId!,
|
||||
dictType: dictType.value,
|
||||
dictKey: form.dictKey.trim(),
|
||||
dictName: form.dictName,
|
||||
sortNo: form.sortNo,
|
||||
status: form.status,
|
||||
})
|
||||
.send();
|
||||
Message.success("保存成功!");
|
||||
modalVisible.value = false;
|
||||
run();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function doDelete(row: SvcDictVo) {
|
||||
MyConfirm({
|
||||
title: "删除字典",
|
||||
msg: `确定删除「${row.dictName}(${row.dictKey})」吗?`,
|
||||
info: row.referenced > 0 ? `已被 ${row.referenced} 个事项引用,不能删除;可改为停用(存量引用仍可渲染)` : "未被引用,可安全删除",
|
||||
async onOk() {
|
||||
await svcDictApi.delete({ id: row.id, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
watch(() => store.siteId, () => run());
|
||||
watch(dictType, () => run());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix">
|
||||
<RadioGroup v-model="dictType" class="fl type-switch" type="button">
|
||||
<Radio v-for="t in typeDict" :key="t.value" :label="t.value">{{ t.label }}</Radio>
|
||||
</RadioGroup>
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
新增{{ typeLabel }}
|
||||
</Button>
|
||||
<span class="type-tip fr">
|
||||
停用后不出现在门户筛选与事项选择器(存量引用仍可渲染);键被事项引用后不可修改、不可删除
|
||||
</span>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" :no-data-text="`暂无${typeLabel}`" />
|
||||
|
||||
<Modal v-model="modalVisible" :title="(form.id ? '编辑' : '新增') + typeLabel" :mask-closable="false" width="520px">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="字典键" prop="dictKey">
|
||||
<Input v-model="form.dictKey" :disabled="keyLocked" :maxlength="64" placeholder="稳定键,如 person / jw" />
|
||||
<span v-if="keyLocked" class="key-tip">已被 {{ editingReferenced }} 个事项引用,键不可修改(只能改名称)</span>
|
||||
</FormItem>
|
||||
<FormItem label="字典名称" prop="dictName">
|
||||
<Input v-model="form.dictName" :maxlength="128" placeholder="展示名,如 个人 / 教务服务" />
|
||||
</FormItem>
|
||||
<FormItem label="排序">
|
||||
<InputNumber v-model="form.sortNo" :min="0" />
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<RadioGroup v-model="form.status">
|
||||
<Radio :label="1">启用</Radio>
|
||||
<Radio :label="0">停用</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="modalVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.type-switch {
|
||||
margin-right: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.type-tip {
|
||||
color: #999;
|
||||
line-height: 40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.key-tip {
|
||||
color: #e6a23c;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,421 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { svcDictApi, svcItemApi, uploadUrl } from "@/api/module/cms";
|
||||
import { categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { adminPathOf } from "@/utils/sitePath";
|
||||
import RichTextEditor from "@/components/RichTextEditor.vue";
|
||||
import ImageAssetField from "@/components/ImageAssetField.vue";
|
||||
import { CategoryTreeNode, SvcDeptVo, SvcDictVo, SvcItemAttachmentVo } from "@/types/cms";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const editId = computed(() => {
|
||||
const raw = route.params.id || route.query.id;
|
||||
return raw ? parseInt(String(raw)) : undefined;
|
||||
});
|
||||
|
||||
const formRef = ref();
|
||||
const saving = ref(false);
|
||||
const publishing = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
itemCode: "",
|
||||
title: "",
|
||||
summary: "",
|
||||
objectKey: "" as string,
|
||||
categoryKey: "" as string,
|
||||
deptGroupId: undefined as number | undefined,
|
||||
deptName: "",
|
||||
handleMode: 1,
|
||||
onlineFlag: 1,
|
||||
handlePlace: "",
|
||||
handleTimeLimit: "",
|
||||
promiseTimeLimit: "",
|
||||
contactPhone: "",
|
||||
supervisePhone: "",
|
||||
processHtml: "",
|
||||
materialHtml: "",
|
||||
noticeHtml: "",
|
||||
faqCategoryId: undefined as number | undefined,
|
||||
noticeCategoryId: undefined as number | undefined,
|
||||
ruleCategoryId: undefined as number | undefined,
|
||||
coverImage: "",
|
||||
sortNo: 0,
|
||||
applyUrl: "",
|
||||
});
|
||||
|
||||
const rules = {
|
||||
itemCode: [
|
||||
{ required: true, message: "事项编码不得为空", trigger: "blur" },
|
||||
{ pattern: /^[A-Za-z0-9_-]{2,64}$/, message: "仅限字母/数字/下划线/中划线,2~64 位", trigger: "blur" },
|
||||
],
|
||||
title: [{ required: true, message: "事项名称不得为空", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const objectOptions = ref<SvcDictVo[]>([]);
|
||||
const categoryOptions = ref<SvcDictVo[]>([]);
|
||||
const deptOptions = ref<{ id: number; label: string }[]>([]);
|
||||
const categoryTreeOptions = ref<{ id: number; label: string }[]>([]);
|
||||
const deptUnavailable = ref(false);
|
||||
|
||||
/**
|
||||
* 指南附件列表(编辑回显 + 本次上传合并;保存时以本列表为准重绑 ref_type=svc_item)。
|
||||
* 封面与正文内嵌图不在此列表:它们以 scene=svc_item_asset 上传,保存时由后端按内容
|
||||
* (coverImage + 三段富文本里的本站上传地址)反查归属,故前端无需收集其附件 id。
|
||||
*/
|
||||
const attachments = ref<SvcItemAttachmentVo[]>([]);
|
||||
|
||||
function flattenCategories(tree: CategoryTreeNode[], depth = 0): { id: number; label: string }[] {
|
||||
const list: { id: number; label: string }[] = [];
|
||||
tree.forEach(node => {
|
||||
list.push({ id: node.id!, label: `${" ".repeat(depth)}${node.name}` });
|
||||
if (node.children?.length) list.push(...flattenCategories(node.children, depth + 1));
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 部门组树 → 缩进扁平选项(后端下发嵌套结构,选择器仍用扁平 Select 保持既有交互) */
|
||||
function flattenDepts(tree: SvcDeptVo[], depth = 0): { id: number; label: string }[] {
|
||||
const list: { id: number; label: string }[] = [];
|
||||
tree.forEach(node => {
|
||||
list.push({ id: node.id, label: `${" ".repeat(depth)}${node.name}` });
|
||||
if (node.children?.length) list.push(...flattenDepts(node.children, depth + 1));
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!store.siteId) return;
|
||||
const [objects, categories, deptTree, catTree] = await Promise.all([
|
||||
svcDictApi.list(store.siteId, "object").send(),
|
||||
svcDictApi.list(store.siteId, "category").send(),
|
||||
svcDictApi.deptTree(store.siteId).send().catch(() => []),
|
||||
categoryApi.tree(store.siteId).send(),
|
||||
]);
|
||||
objectOptions.value = (objects || []).filter(d => d.status === 1);
|
||||
categoryOptions.value = (categories || []).filter(d => d.status === 1);
|
||||
deptOptions.value = flattenDepts(deptTree || []);
|
||||
deptUnavailable.value = !deptOptions.value.length;
|
||||
categoryTreeOptions.value = flattenCategories(catTree || []);
|
||||
if (!editId.value) return;
|
||||
const detail = await svcItemApi.get(store.siteId, editId.value).send();
|
||||
if (!detail) return;
|
||||
Object.assign(form, {
|
||||
itemCode: detail.itemCode,
|
||||
title: detail.title,
|
||||
summary: detail.summary || "",
|
||||
objectKey: detail.objectKey || "",
|
||||
categoryKey: detail.categoryKey || "",
|
||||
deptGroupId: detail.deptGroupId,
|
||||
deptName: detail.deptName || "",
|
||||
handleMode: detail.handleMode ?? 1,
|
||||
onlineFlag: detail.onlineFlag ?? 1,
|
||||
handlePlace: detail.handlePlace || "",
|
||||
handleTimeLimit: detail.handleTimeLimit || "",
|
||||
promiseTimeLimit: detail.promiseTimeLimit || "",
|
||||
contactPhone: detail.contactPhone || "",
|
||||
supervisePhone: detail.supervisePhone || "",
|
||||
processHtml: detail.processHtml || "",
|
||||
materialHtml: detail.materialHtml || "",
|
||||
noticeHtml: detail.noticeHtml || "",
|
||||
faqCategoryId: detail.faqCategoryId,
|
||||
noticeCategoryId: detail.noticeCategoryId,
|
||||
ruleCategoryId: detail.ruleCategoryId,
|
||||
coverImage: detail.coverImage || "",
|
||||
sortNo: detail.sortNo ?? 0,
|
||||
applyUrl: detail.applyUrl || "",
|
||||
});
|
||||
attachments.value = (await svcItemApi.attList(store.siteId, editId.value).send()) || [];
|
||||
}
|
||||
|
||||
function attachmentUploaded(res: any) {
|
||||
if (res.code === 0 && res.data) {
|
||||
attachments.value.push({
|
||||
id: res.data.id,
|
||||
fileName: res.data.fileName,
|
||||
webUrl: res.data.url,
|
||||
fileSize: res.data.fileSize,
|
||||
});
|
||||
} else {
|
||||
Message.error(res?.msg || "附件上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentError() {
|
||||
Message.error("附件上传失败");
|
||||
}
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
attachments.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function buildRequest() {
|
||||
return {
|
||||
id: editId.value,
|
||||
siteId: store.siteId!,
|
||||
itemCode: form.itemCode.trim(),
|
||||
title: form.title,
|
||||
summary: form.summary || undefined,
|
||||
objectKey: form.objectKey || undefined,
|
||||
categoryKey: form.categoryKey || undefined,
|
||||
deptGroupId: form.deptGroupId,
|
||||
handleMode: form.handleMode,
|
||||
onlineFlag: form.onlineFlag,
|
||||
handlePlace: form.handlePlace || undefined,
|
||||
handleTimeLimit: form.handleTimeLimit || undefined,
|
||||
promiseTimeLimit: form.promiseTimeLimit || undefined,
|
||||
contactPhone: form.contactPhone || undefined,
|
||||
supervisePhone: form.supervisePhone || undefined,
|
||||
processHtml: form.processHtml,
|
||||
materialHtml: form.materialHtml,
|
||||
noticeHtml: form.noticeHtml,
|
||||
faqCategoryId: form.faqCategoryId,
|
||||
noticeCategoryId: form.noticeCategoryId,
|
||||
ruleCategoryId: form.ruleCategoryId,
|
||||
coverImage: form.coverImage || undefined,
|
||||
sortNo: form.sortNo,
|
||||
applyUrl: form.applyUrl.trim() || undefined,
|
||||
attachmentIds: attachments.value.map(a => a.id),
|
||||
};
|
||||
}
|
||||
|
||||
async function save(): Promise<number | undefined> {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return undefined;
|
||||
saving.value = true;
|
||||
try {
|
||||
return await svcItemApi.save(buildRequest()).send();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
const code = store.schoolCode;
|
||||
router.replace(code ? adminPathOf(code, store.siteSegCode, "svc/item") : "/workbench");
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
const id = await save();
|
||||
if (id) {
|
||||
Message.success("保存成功!");
|
||||
if (!editId.value) {
|
||||
const code = store.schoolCode;
|
||||
router.replace(code ? adminPathOf(code, store.siteSegCode, `svc/item/edit/${id}`) : "/workbench");
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndPublish() {
|
||||
const id = await save();
|
||||
if (!id) return;
|
||||
publishing.value = true;
|
||||
try {
|
||||
await svcItemApi.publish(store.siteId!, id).send();
|
||||
Message.success("已发布!门户服务中心即刻可见");
|
||||
backToList();
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
// 站点上下文切换即时重载(docs/09 §3.3)
|
||||
watch(() => store.siteId, () => load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="110">
|
||||
<FormItem label="事项编码" prop="itemCode">
|
||||
<Input v-model="form.itemCode" placeholder="如 BG-ZX-001(站内唯一,进入办理页 URL)" style="width: 320px" :maxlength="64" />
|
||||
<span class="form-tip">仅限字母/数字/下划线/中划线;创建后与站点编码组成跨服务唯一键</span>
|
||||
</FormItem>
|
||||
<FormItem label="事项名称" prop="title">
|
||||
<Input v-model="form.title" placeholder="如 转学办理" style="width: 640px" :maxlength="200" />
|
||||
</FormItem>
|
||||
<FormItem label="一句话简介">
|
||||
<Input v-model="form.summary" type="textarea" :rows="2" placeholder="列表卡片副标题(可空)" style="width: 640px" :maxlength="500" />
|
||||
</FormItem>
|
||||
<FormItem label="服务对象">
|
||||
<Select v-model="form.objectKey" clearable placeholder="请选择(在「服务字典」维护)" style="width: 320px">
|
||||
<Option v-for="d in objectOptions" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="事项类别">
|
||||
<Select v-model="form.categoryKey" clearable placeholder="请选择(在「服务字典」维护)" style="width: 320px">
|
||||
<Option v-for="d in categoryOptions" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="服务部门">
|
||||
<Select
|
||||
v-model="form.deptGroupId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择部门(来自基础平台组织机构)"
|
||||
style="width: 320px"
|
||||
>
|
||||
<Option v-for="d in deptOptions" :key="d.id" :value="d.id">{{ d.label }}</Option>
|
||||
</Select>
|
||||
<span v-if="deptUnavailable" class="form-tip warn">
|
||||
组织机构暂不可达或为空,可先留空保存;恢复后重新选择可刷新部门名快照
|
||||
</span>
|
||||
</FormItem>
|
||||
<FormItem label="办理方式">
|
||||
<RadioGroup v-model="form.handleMode">
|
||||
<Radio :label="1">线上办理</Radio>
|
||||
<Radio :label="2">线下办理</Radio>
|
||||
<Radio :label="3">线上线下</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem label="可在线办理">
|
||||
<Switch :model-value="form.onlineFlag === 1" @on-change="(v: boolean) => (form.onlineFlag = v ? 1 : 0)" />
|
||||
<span class="form-tip">门户"可在线办理"筛选标识;与办理方式可不一致(如线上线下但暂未开通在线入口时关闭)</span>
|
||||
</FormItem>
|
||||
<FormItem label="办理地点">
|
||||
<Input v-model="form.handlePlace" placeholder="如 行政楼 203 室" style="width: 320px" :maxlength="200" />
|
||||
</FormItem>
|
||||
<FormItem label="办理时限">
|
||||
<Input v-model="form.handleTimeLimit" placeholder="如 3 个工作日" style="width: 320px" :maxlength="64" />
|
||||
</FormItem>
|
||||
<FormItem label="承诺时限">
|
||||
<Input v-model="form.promiseTimeLimit" placeholder="如 1 个工作日(可空)" style="width: 320px" :maxlength="64" />
|
||||
</FormItem>
|
||||
<FormItem label="咨询电话">
|
||||
<Input v-model="form.contactPhone" placeholder="如 0571-88888888" style="width: 320px" :maxlength="64" />
|
||||
</FormItem>
|
||||
<FormItem label="监督投诉电话">
|
||||
<Input v-model="form.supervisePhone" placeholder="可空" style="width: 320px" :maxlength="64" />
|
||||
</FormItem>
|
||||
<FormItem label="我要办理外链">
|
||||
<Input v-model="form.applyUrl" placeholder="https://…(可空,须为完整 http(s) 地址)" style="width: 640px" :maxlength="500" />
|
||||
<span class="form-tip">
|
||||
指南页「我要办理」按钮优先级:本外链 > 站点设置「服务大厅基地址」自动生成 > 不显示按钮。两者都配置时以本外链为准
|
||||
</span>
|
||||
</FormItem>
|
||||
<FormItem label="常见问题栏目">
|
||||
<Select v-model="form.faqCategoryId" clearable filterable placeholder="关联栏目(可空)" style="width: 320px">
|
||||
<Option v-for="opt in categoryTreeOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="公告信息栏目">
|
||||
<Select v-model="form.noticeCategoryId" clearable filterable placeholder="关联栏目(可空)" style="width: 320px">
|
||||
<Option v-for="opt in categoryTreeOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="文件规定栏目">
|
||||
<Select v-model="form.ruleCategoryId" clearable filterable placeholder="关联栏目(可空)" style="width: 320px">
|
||||
<Option v-for="opt in categoryTreeOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="事项封面">
|
||||
<ImageAssetField v-model="form.coverImage" :site-id="store.siteId" scene="svc_item_asset" style="width: 420px" />
|
||||
</FormItem>
|
||||
<FormItem label="排序值">
|
||||
<InputNumber v-model="form.sortNo" :min="0" />
|
||||
<span class="form-tip">升序,门户服务中心列表的第一优先级</span>
|
||||
</FormItem>
|
||||
<FormItem label="办理流程">
|
||||
<RichTextEditor
|
||||
v-model="form.processHtml"
|
||||
:site-id="store.siteId"
|
||||
scene="svc_item_asset"
|
||||
placeholder="请输入办理流程……"
|
||||
class="editor-wide"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="所需材料">
|
||||
<RichTextEditor
|
||||
v-model="form.materialHtml"
|
||||
:site-id="store.siteId"
|
||||
scene="svc_item_asset"
|
||||
placeholder="请输入所需材料……"
|
||||
class="editor-wide"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="注意事项">
|
||||
<RichTextEditor
|
||||
v-model="form.noticeHtml"
|
||||
:site-id="store.siteId"
|
||||
scene="svc_item_asset"
|
||||
placeholder="请输入注意事项……"
|
||||
class="editor-wide"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="指南附件">
|
||||
<Upload
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=svc_item'"
|
||||
with-credentials
|
||||
:show-upload-list="false"
|
||||
:on-success="attachmentUploaded"
|
||||
:on-error="attachmentError"
|
||||
>
|
||||
<Button icon="ios-cloud-upload-outline">上传附件(表格/须知等)</Button>
|
||||
</Upload>
|
||||
<ul v-if="attachments.length" class="att-list">
|
||||
<li v-for="(att, idx) in attachments" :key="att.id">
|
||||
<Icon type="ios-document-outline" />
|
||||
<span class="att-name" :title="att.fileName">{{ att.fileName }}</span>
|
||||
<Button size="small" type="text" class="att-del" @click="removeAttachment(idx)">移除</Button>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="form-tip">保存时按当前列表绑定;移除即解除引用(文件保留在附件库)。正文图片与封面自动归属,无需在此登记</span>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button type="primary" :loading="publishing" @click="saveAndPublish">保存并发布</Button>
|
||||
<Button style="margin-left: 8px" :loading="saving" @click="saveDraft">保存草稿</Button>
|
||||
<Button style="margin-left: 8px" @click="backToList">返回</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-tip {
|
||||
margin-left: 12px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
&.warn {
|
||||
color: #e6a23c;
|
||||
}
|
||||
}
|
||||
.editor-wide {
|
||||
width: 860px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.att-list {
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
max-width: 640px;
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
& + li {
|
||||
border-top: 1px dashed #e5e6eb;
|
||||
}
|
||||
.att-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.att-del {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import ServiceItemSortModal from "./ServiceItemSortModal.vue";
|
||||
import { svcDictApi, svcItemApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { adminPathOf } from "@/utils/sitePath";
|
||||
import { SvcDictVo, SvcItemPageItem } from "@/types/cms";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<SvcItemPageItem[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
|
||||
|
||||
const search = reactive({
|
||||
status: undefined as number | undefined,
|
||||
objectKey: "" as string,
|
||||
categoryKey: "" as string,
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
const objectOptions = ref<SvcDictVo[]>([]);
|
||||
const categoryOptions = ref<SvcDictVo[]>([]);
|
||||
const sortVisible = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const statusDict = [
|
||||
{ value: 0, label: "草稿", color: "default" },
|
||||
{ value: 1, label: "已发布", color: "success" },
|
||||
{ value: 2, label: "已下架", color: "warning" },
|
||||
];
|
||||
const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知";
|
||||
const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default";
|
||||
|
||||
const handleModeDict = [
|
||||
{ value: 1, label: "线上办理" },
|
||||
{ value: 2, label: "线下办理" },
|
||||
{ value: 3, label: "线上线下" },
|
||||
];
|
||||
const handleModeText = (v: number) => handleModeDict.find(i => i.value === v)?.label || "-";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "事项名称",
|
||||
key: "title",
|
||||
minWidth: 220,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", null, [
|
||||
h(
|
||||
"a",
|
||||
{ style: { color: "#2d8cf0", cursor: "pointer" }, onClick: () => goEdit(p.row.id) },
|
||||
p.row.title
|
||||
),
|
||||
p.row.onlineFlag === 1
|
||||
? h(Tag, { color: "primary", style: { marginLeft: "6px" } }, () => "可在线办理")
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
{ title: "事项编码", key: "itemCode", width: 130, tooltip: true },
|
||||
{ title: "事项类别", key: "categoryName", width: 110, render: (h: any, p: any) => h("span", p.row.categoryName || "-") },
|
||||
{ title: "服务对象", key: "objectName", width: 100, render: (h: any, p: any) => h("span", p.row.objectName || "-") },
|
||||
{ title: "服务部门", key: "deptName", width: 120, tooltip: true, render: (h: any, p: any) => h("span", p.row.deptName || "-") },
|
||||
{
|
||||
title: "办理方式",
|
||||
key: "handleMode",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", handleModeText(p.row.handleMode)),
|
||||
},
|
||||
{ title: "排序", key: "sortNo", width: 70, align: "center" },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h(Tag, { color: statusColor(p.row.status) }, () => statusText(p.row.status)),
|
||||
},
|
||||
{
|
||||
title: "发布时间",
|
||||
key: "publishTime",
|
||||
width: 160,
|
||||
render: (h: any, p: any) => h("span", p.row.publishTime || "-"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 190,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const ops: any[] = [
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => goEdit(p.row.id)),
|
||||
];
|
||||
if (p.row.status !== 1) {
|
||||
ops.push(Utils.operatingHandle(h, "发布", "icon-fasong", () => doPublish(p.row)));
|
||||
}
|
||||
if (p.row.status === 1) {
|
||||
ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row)));
|
||||
}
|
||||
ops.push(Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)));
|
||||
return ops;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function loadDicts() {
|
||||
if (!store.siteId) return;
|
||||
const [objects, categories] = await Promise.all([
|
||||
svcDictApi.list(store.siteId, "object").send(),
|
||||
svcDictApi.list(store.siteId, "category").send(),
|
||||
]);
|
||||
objectOptions.value = (objects || []).filter(d => d.status === 1);
|
||||
categoryOptions.value = (categories || []).filter(d => d.status === 1);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await svcItemApi
|
||||
.page({
|
||||
siteId: store.siteId,
|
||||
status: search.status,
|
||||
objectKey: search.objectKey || undefined,
|
||||
categoryKey: search.categoryKey || undefined,
|
||||
keyword: search.keyword || undefined,
|
||||
page: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
.send();
|
||||
rows.value = page?.list || [];
|
||||
total.value = page?.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pageSizeUpdate(size: number) {
|
||||
pageSize.value = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
|
||||
function goEdit(id?: number) {
|
||||
const code = store.schoolCode;
|
||||
if (!code) {
|
||||
router.push("/workbench");
|
||||
return;
|
||||
}
|
||||
// 站点段跟随当前上下文(docs/13):子站编辑页 URL 带 /site/{code}
|
||||
router.push(adminPathOf(code, store.siteSegCode, id ? `svc/item/edit/${id}` : "svc/item/edit"));
|
||||
}
|
||||
|
||||
async function doPublish(row: SvcItemPageItem) {
|
||||
await svcItemApi.publish(store.siteId!, row.id).send();
|
||||
Message.success("发布成功!门户服务中心即刻可见");
|
||||
run();
|
||||
}
|
||||
|
||||
async function doOffline(row: SvcItemPageItem) {
|
||||
await svcItemApi.offline(store.siteId!, row.id).send();
|
||||
Message.success("已下架");
|
||||
run();
|
||||
}
|
||||
|
||||
function doDelete(row: SvcItemPageItem) {
|
||||
MyConfirm({
|
||||
title: "删除事项",
|
||||
msg: `确定删除「${row.title}」吗?`,
|
||||
info: "删除后指南附件转为未使用,历史办件不受影响(办件持有发起时快照)",
|
||||
async onOk() {
|
||||
await svcItemApi.delete({ id: row.id, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
if (rows.value.length <= 1 && pageNum.value > 1) pageNum.value -= 1;
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 同步部门名:UserBase 组树改名后刷新本站事项的 dept_name 快照 */
|
||||
function doSyncDeptName() {
|
||||
if (!store.siteId) return;
|
||||
MyConfirm({
|
||||
title: "同步部门名",
|
||||
msg: "按基础平台组织机构刷新全部事项的服务部门名称快照,是否继续?",
|
||||
info: "列表展示的部门名即刻更新;门户指南页缓存会在本次操作后整体失效重建",
|
||||
async onOk() {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await svcItemApi.syncDeptName(store.siteId!).send();
|
||||
Message.success(`同步完成,更新 ${res?.updated ?? 0} 条`);
|
||||
run();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDicts();
|
||||
await run();
|
||||
});
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3)
|
||||
watch(() => store.siteId, async () => {
|
||||
await loadDicts();
|
||||
pageNum.value = 1;
|
||||
await run();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="goEdit()">
|
||||
<Icon type="ios-add" />
|
||||
新建事项
|
||||
</Button>
|
||||
<Button class="fl ml-12" size="large" @click="sortVisible = true">
|
||||
<Icon type="ios-swap" />
|
||||
事项排序
|
||||
</Button>
|
||||
<Button class="fl ml-12" size="large" :loading="syncing" @click="doSyncDeptName">
|
||||
<Icon type="ios-refresh" />
|
||||
同步部门名
|
||||
</Button>
|
||||
</div>
|
||||
<div class="search-wrap fix">
|
||||
<Select
|
||||
v-model="search.objectKey"
|
||||
class="search-wrap_sel"
|
||||
clearable
|
||||
placeholder="全部服务对象"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="d in objectOptions" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="search.categoryKey"
|
||||
class="search-wrap_sel ml-12"
|
||||
clearable
|
||||
placeholder="全部事项类别"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="d in categoryOptions" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="search.status"
|
||||
class="search-wrap_sel ml-12"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
search
|
||||
enter-button
|
||||
class="fr"
|
||||
placeholder="名称/编码关键字"
|
||||
style="width: 220px"
|
||||
v-model="search.keyword"
|
||||
@on-search="pageNum = 1; run()"
|
||||
@on-clear="pageNum = 1; run()"
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无事项"
|
||||
/>
|
||||
<div class="page-panel">
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="(p: number) => { pageNum = p; run(); }"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ServiceItemSortModal v-model:visible="sortVisible" @saved="run" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<Modal v-model="visible" title="事项排序" :width="560" :mask-closable="false">
|
||||
<Alert show-icon>
|
||||
拖动左侧手柄调整顺序,保存后按此顺序在门户服务中心展示(排序值是列表第一优先级)
|
||||
</Alert>
|
||||
<draggable class="sort-list" v-model="localItems" item-key="id" handle=".drag-handle" @change="onDragChange">
|
||||
<template #item="{ element, index }">
|
||||
<div class="sort-item">
|
||||
<span class="drag-handle">⠿</span>
|
||||
<span class="sort-index">{{ index + 1 }}</span>
|
||||
<span class="sort-item-name">{{ element.title || element.itemCode }}</span>
|
||||
<span class="sort-no">{{ element.itemCode }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div class="sort-empty" v-if="localItems.length === 0">暂无事项</div>
|
||||
<div class="sort-tip" v-if="localItems.length >= SEL_LIST_MAX">
|
||||
事项超过 {{ SEL_LIST_MAX }} 条,此处仅展示前 {{ SEL_LIST_MAX }} 条
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="saving" @click="saveSort" :disabled="localItems.length === 0">
|
||||
保存排序
|
||||
</Button>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/* 事项排序:照 CategorySortModal 形态;顺序来源用 selList(已按 sort_no 升序返回),
|
||||
保存时按下标重排 sort_no(1..n),接口 /api/svc/item/sort */
|
||||
import { ref, watch } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { svcItemApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { SvcItemSimpleVo } from "@/types/cms";
|
||||
|
||||
const SEL_LIST_MAX = 500;
|
||||
|
||||
const visible = defineModel<boolean>("visible", { default: false });
|
||||
|
||||
const emit = defineEmits<{ saved: [] }>();
|
||||
|
||||
const store = useCmsStore();
|
||||
const localItems = ref<SvcItemSimpleVo[]>([]);
|
||||
const saving = ref(false);
|
||||
const hasChanged = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
function onDragChange() {
|
||||
hasChanged.value = true;
|
||||
}
|
||||
|
||||
async function resetLocal() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
localItems.value = (await svcItemApi.selList(store.siteId).send()) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
hasChanged.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSort() {
|
||||
if (!store.siteId) return;
|
||||
if (!hasChanged.value) {
|
||||
Message.info("排序未发生变化");
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await svcItemApi
|
||||
.sort(
|
||||
store.siteId,
|
||||
localItems.value.map((x, index) => ({ id: x.id, sortNo: index + 1 }))
|
||||
)
|
||||
.send();
|
||||
Message.success("保存成功");
|
||||
hasChanged.value = false;
|
||||
emit("saved");
|
||||
visible.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, v => {
|
||||
if (v) resetLocal();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sort-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sort-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f4ff;
|
||||
border-color: #c0d0ff;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-list :deep(.sort-item.sortable-chosen),
|
||||
.sort-list :deep(.sort-item.sortable-drag) {
|
||||
background: #fff7e6;
|
||||
border-color: #fa8c16;
|
||||
box-shadow: 0 6px 16px rgba(250, 140, 22, 0.4);
|
||||
cursor: grabbing;
|
||||
|
||||
.drag-handle {
|
||||
color: #fa8c16;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-list :deep(.sort-item.sortable-ghost) {
|
||||
background: #fffbe6;
|
||||
border: 1px dashed #fa8c16;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #577afb;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.sort-item-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-no {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sort-empty,
|
||||
.sort-tip {
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user