feat(svcitem): 事项库+办事大厅 R2 前端三页,并复用富文本组件消除双份实现
build / build (push) Successful in 3m14s

落地(依据《事项库与办事大厅详细设计 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:
chenxing
2026-09-11 14:14:02 +08:00
parent 34733ec280
commit a05b0ded55
18 changed files with 2102 additions and 78 deletions
+135
View File
@@ -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>