落地(依据《事项库与办事大厅详细设计 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:
@@ -12,6 +12,8 @@ const props = defineProps<{
|
||||
modelValue: string;
|
||||
/** 上传归属站点(附件场景引用;缺省不携带) */
|
||||
siteId?: number;
|
||||
/** 附件场景(svc_item 等;缺省不带 scene,与既有调用一致) */
|
||||
scene?: string;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
|
||||
@@ -24,7 +26,10 @@ const previewSrc = computed(() => (props.modelValue ? assetUrl(props.modelValue)
|
||||
|
||||
const action = computed(() => {
|
||||
const base = uploadUrl();
|
||||
return props.siteId ? `${base}?siteId=${props.siteId}` : base;
|
||||
const params: string[] = [];
|
||||
if (props.siteId) params.push(`siteId=${props.siteId}`);
|
||||
if (props.scene) params.push(`scene=${props.scene}`);
|
||||
return params.length ? `${base}?${params.join("&")}` : base;
|
||||
});
|
||||
|
||||
function onUploaded(res: any) {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* RichTextEditor 组件行为(审计批次 6:ArticleEdit 复用后,正文上传契约成为两个页面共用底座,
|
||||
* 需要独立护栏)。覆盖:
|
||||
* ① 上传 FormData 参数:siteId / scene / articleId 按传入拼装,未传的键不出现;
|
||||
* ② 上传成功 → emit uploaded(附件 id) 且 insertFn 收到 (url, fileName, url);
|
||||
* ③ 上传失败(code≠0)→ 不 emit uploaded;
|
||||
* ④ 工具栏配置仍排除视频组与全屏(Notice 趟坑口径)。
|
||||
*
|
||||
* 无 @vue/test-utils 依赖:沿用项目既有 createApp 手写挂载 + vi.mock 桩范式。
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h } from "vue";
|
||||
|
||||
const confState = vi.hoisted(() => ({
|
||||
conf: { baseHost: "", ssoHost: "", serviceHost: "", isMock: false, isDebug: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/assets/config/host.config", () => ({ default: confState.conf }));
|
||||
|
||||
/** wangEditor 桩:把传进来的 defaultConfig 挂到全局,测试借此直接驱动 customUpload */
|
||||
vi.mock("@wangeditor/editor-for-vue", () => ({
|
||||
Toolbar: defineComponent({
|
||||
props: ["editor", "defaultConfig", "mode"],
|
||||
setup(props) {
|
||||
(globalThis as any).__toolbarConfig = props.defaultConfig;
|
||||
return () => h("div", { class: "toolbar-stub" });
|
||||
},
|
||||
}),
|
||||
Editor: defineComponent({
|
||||
props: ["modelValue", "defaultConfig", "mode"],
|
||||
emits: ["onCreated", "update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
(globalThis as any).__editorConfig = props.defaultConfig;
|
||||
(globalThis as any).__editorEmitUpdate = (v: string) => emit("update:modelValue", v);
|
||||
(globalThis as any).__editorCreated = emit;
|
||||
return () => h("div", { class: "editor-stub" });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import RichTextEditor from "@/components/RichTextEditor.vue";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
function mountEditor(props: Record<string, unknown>, onUploaded?: (id: number) => void) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp({
|
||||
render: () => h(RichTextEditor, { modelValue: "", ...props, onUploaded }),
|
||||
});
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/** 直接调用组件内部构造的 customUpload(等同 wangEditor 选中图片后的回调) */
|
||||
async function runUpload(file: File): Promise<{ inserted: string[] }> {
|
||||
const inserted: string[] = [];
|
||||
const cfg = (globalThis as any).__editorConfig;
|
||||
await cfg.MENU_CONF.uploadImage.customUpload(file, (url: string) => inserted.push(url));
|
||||
return { inserted };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
vi.unstubAllGlobals();
|
||||
delete (globalThis as any).__editorConfig;
|
||||
delete (globalThis as any).__toolbarConfig;
|
||||
});
|
||||
|
||||
describe("RichTextEditor - 正文上传与事件契约", () => {
|
||||
it("① 上传 FormData 带 siteId/scene/articleId(事项页口径:scene=svc_item_asset)", async () => {
|
||||
const seen: FormData[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init: any) => {
|
||||
seen.push(init.body as FormData);
|
||||
return { json: async () => ({ code: 0, data: { id: 77, url: "/cms/upload/a.png", fileName: "a.png" } }) };
|
||||
})
|
||||
);
|
||||
mountEditor({ siteId: 5, scene: "svc_item_asset" }, () => {});
|
||||
await runUpload(new File(["x"], "a.png", { type: "image/png" }));
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].get("file")).toBeInstanceOf(File);
|
||||
expect(seen[0].get("siteId")).toBe("5");
|
||||
expect(seen[0].get("scene")).toBe("svc_item_asset");
|
||||
expect(seen[0].get("articleId")).toBeNull();
|
||||
});
|
||||
|
||||
it("①b 文章页口径:传 articleId 时随之提交(编辑已有文章)", async () => {
|
||||
const seen: FormData[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init: any) => {
|
||||
seen.push(init.body as FormData);
|
||||
return { json: async () => ({ code: 0, data: { id: 78, url: "/cms/upload/b.png", fileName: "b.png" } }) };
|
||||
})
|
||||
);
|
||||
mountEditor({ siteId: 5, articleId: 12 }, () => {});
|
||||
await runUpload(new File(["x"], "b.png", { type: "image/png" }));
|
||||
|
||||
expect(seen[0].get("siteId")).toBe("5");
|
||||
expect(seen[0].get("articleId")).toBe("12");
|
||||
expect(seen[0].get("scene")).toBeNull();
|
||||
});
|
||||
|
||||
it("② 上传成功:emit uploaded(id) 且 insertFn 收到 (url, fileName, url)", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
json: async () => ({ code: 0, data: { id: 99, url: "/cms/upload/c.png", fileName: "c.png" } }),
|
||||
}))
|
||||
);
|
||||
const ids: number[] = [];
|
||||
mountEditor({ siteId: 5, scene: "svc_item_asset" }, id => ids.push(id));
|
||||
const { inserted } = await runUpload(new File(["x"], "c.png", { type: "image/png" }));
|
||||
|
||||
expect(ids).toEqual([99]);
|
||||
expect(inserted).toEqual(["/cms/upload/c.png"]);
|
||||
});
|
||||
|
||||
it("③ 上传失败(code≠0):不 emit uploaded、不插入图片", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ json: async () => ({ code: 2, msg: "scene 非法" }) }))
|
||||
);
|
||||
const ids: number[] = [];
|
||||
mountEditor({ siteId: 5, scene: "bad_scene" }, id => ids.push(id));
|
||||
const { inserted } = await runUpload(new File(["x"], "d.png", { type: "image/png" }));
|
||||
|
||||
expect(ids).toEqual([]);
|
||||
expect(inserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("④ 工具栏仍排除视频组与全屏(Notice 趟坑口径不回退)", () => {
|
||||
mountEditor({ siteId: 5 }, () => {});
|
||||
expect((globalThis as any).__toolbarConfig.excludeKeys).toEqual(["group-video", "fullScreen"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
/**
|
||||
* 通用富文本编辑器(事项库 C-5:从 ArticleEdit 内联 wangEditor 抽取的公共组件;
|
||||
* 审计批次 6 起 ArticleEdit 亦复用本组件,消除两份实现)。
|
||||
* v-model 绑定 HTML;图片 customUpload 直传 /api/attachment/upload(fetch + credentials),
|
||||
* 上传成功 emit uploaded(attachmentId) 供调用方收集(保存时绑定归属)。
|
||||
* 销毁时 editor.destroy()(wangEditor 必须显式销毁防内存泄漏)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
siteId?: number;
|
||||
/** 附件场景(svc_item_asset 正文资产 / svc_item 指南附件等;空=不带 scene) */
|
||||
scene?: string;
|
||||
/** 文章 id(文章编辑器上传时的归属参数;无文章 id 的页面不传) */
|
||||
articleId?: number;
|
||||
placeholder?: string;
|
||||
/** 建议 ≥320(长文显示不全修复口径:容器随内容增高,短文铺满 min-height) */
|
||||
height?: number;
|
||||
/** slate 内容区最小高度(CSS 值);缺省用事项页口径,文章页传入自己的口径保持原观感 */
|
||||
slateMinHeight?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", v: string): void;
|
||||
(e: "uploaded", attachmentId: number): void;
|
||||
}>();
|
||||
|
||||
const editorRef = shallowRef();
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: props.placeholder || "请输入内容……",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (props.siteId) {
|
||||
fd.append("siteId", String(props.siteId));
|
||||
}
|
||||
if (props.articleId) {
|
||||
fd.append("articleId", String(props.articleId));
|
||||
}
|
||||
if (props.scene) {
|
||||
fd.append("scene", props.scene);
|
||||
}
|
||||
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) {
|
||||
emit("uploaded", res.data.id);
|
||||
insertFn(res.data.url, res.data.fileName || "", res.data.url);
|
||||
} else {
|
||||
Message.error(res.msg || "图片上传失败");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[rich-editor] 图片上传失败", e);
|
||||
Message.error("图片上传失败");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy();
|
||||
});
|
||||
|
||||
// 外部回显值变化同步进编辑器(编辑页 load 后异步回填)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (editorRef.value && v !== editorRef.value.getHtml()) {
|
||||
editorRef.value.setHtml(v || "<p><br></p>");
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rich-editor"
|
||||
:style="{ minHeight: (height || 360) + 'px', '--rich-editor-slate-min': slateMinHeight || 'max(240px, calc(100vh - 560px))' }"
|
||||
>
|
||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||
<Editor
|
||||
class="editor-content"
|
||||
:model-value="modelValue"
|
||||
:defaultConfig="editorConfig"
|
||||
mode="default"
|
||||
style="height: 100%"
|
||||
@onCreated="handleCreated"
|
||||
@update:model-value="(v: string) => emit('update:modelValue', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rich-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
z-index: 100;
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #dcdee2;
|
||||
}
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
overflow: visible;
|
||||
// 长文显示不全修复(2026-09-10)同款口径:文本容器高度随内容自动增长,
|
||||
// 整篇平铺由页面滚动;空态/短文 slate 区 min-height 铺满(调用方可用 slateMinHeight 覆写口径)
|
||||
:deep(.w-e-text-container) {
|
||||
height: auto !important;
|
||||
}
|
||||
:deep([data-slate-editor]) {
|
||||
min-height: var(--rich-editor-slate-min) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -194,6 +194,9 @@ const getMenuIcon = (item?: any): string => {
|
||||
if (page === "/link") return "icon-cshxd";
|
||||
if (page === "/attachment") return "icon-sjlm";
|
||||
if (page === "/site") return "icon-zhandian";
|
||||
// 事项库 R2(§7.5):办事事项取"申请"图标,服务字典取列表图标(前缀分支,优先于 modId 对照)
|
||||
if (page === "/svc/item" || page.startsWith("/svc/item")) return "icon-shenqing";
|
||||
if (page === "/svc/dict") return "icon-liebiao";
|
||||
switch (item?.modId ?? item?.id) {
|
||||
case 1: return "icon-wenzhang";
|
||||
case 2: return "icon-liebiao";
|
||||
@@ -201,6 +204,8 @@ const getMenuIcon = (item?: any): string => {
|
||||
case 4: return "icon-cshxd";
|
||||
case 5: return "icon-sjlm";
|
||||
case 6: return "icon-zhandian";
|
||||
case 21: return "icon-shenqing";
|
||||
case 22: return "icon-liebiao";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user