793 lines
27 KiB
Vue
793 lines
27 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, reactive, ref, shallowRef, useTemplateRef, 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 MyConfirm from "@/components/MyConfirm";
|
||
import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
|
||
import CategorySortModal from "@/components/CategorySortModal.vue";
|
||
import BatchAddCategory from "@/components/BatchAddCategory.vue";
|
||
import SvgIcon from "@/components/SvgIcon.vue";
|
||
import CategoryContentSummary from "@/components/category/CategoryContentSummary.vue";
|
||
import { categoryApi } from "@/api/module/cms";
|
||
import { useCmsStore } from "@/store";
|
||
import config from "@/assets/config/host.config";
|
||
import { CategoryTreeNode } from "@/types/cms";
|
||
import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree";
|
||
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
||
|
||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除)。
|
||
B5 栏目工作台:右侧类型化面板(单页=富文本预览 / 外链=直达)。
|
||
P-D1 内容管理 IA 切分:列表型栏目右侧降级为「栏目内容概览」(只读统计 + 最近 5 条),
|
||
文章的检索与操作统一在 /article(内容视图)。 */
|
||
const store = useCmsStore();
|
||
|
||
const refTreePanel = useTemplateRef("refTreePanel");
|
||
const refFormData = useTemplateRef("refFormData");
|
||
const refNodeNameInput = useTemplateRef("refNodeNameInput");
|
||
|
||
const curTreeNode = ref<any>({});
|
||
const treeData = ref<any[]>([]);
|
||
const selected = computed(() => (curTreeNode.value?.id ? curTreeNode.value : null));
|
||
|
||
const typeDict = [
|
||
{ value: 1, label: "文章列表" },
|
||
{ value: 2, label: "单页" },
|
||
{ value: 3, label: "外链" },
|
||
];
|
||
const typeLabel = (t?: number) => typeDict.find(i => i.value === t)?.label || "未知";
|
||
|
||
// F3(docs/20 §四):「首页展示形态」配置入口已收敛到「站点设置 → 首页布局」区块属性,
|
||
// 栏目管理只管数据(名称/类型/排序/状态/SEO);存量 homeStyle 值由后端保存时保留。
|
||
|
||
const sortModal = ref(false);
|
||
const sortParentId = ref<number | null>(null);
|
||
const sortParentName = ref("");
|
||
const sortChildrenSource = ref<any[]>([]);
|
||
|
||
const batchModal = ref(false);
|
||
const batchDefaultType = ref<number | null>(null);
|
||
|
||
const modalVisible = ref(false);
|
||
const saving = ref(false);
|
||
|
||
/** 编辑表单 */
|
||
const form = reactive({
|
||
id: undefined as number | undefined,
|
||
parentId: 0 as number,
|
||
name: "",
|
||
type: 1,
|
||
linkUrl: "",
|
||
content: "",
|
||
sortNo: 0,
|
||
seoTitle: "",
|
||
seoDescription: "",
|
||
status: 1,
|
||
});
|
||
|
||
const rules = {
|
||
name: [{ required: true, message: "栏目名称不得为空", trigger: "blur" }],
|
||
type: [{ required: true, type: "number", message: "请选择类型", trigger: "change" }],
|
||
linkUrl: [{ required: true, message: "外链地址不得为空", trigger: "blur" }],
|
||
};
|
||
|
||
/** 根节点 label(W2 虚拟根,D5):主站=学校名(缺省回退 mainSiteName),子站=子站名;随站点上下文响应 */
|
||
const rootLabel = computed(() => rootLabelOf({ currentSchool: store.currentSchool, currentSite: store.currentSite }));
|
||
|
||
/** 树数据源(CategoryTreePanel 拉取用;站点切换即时重载;W2:包装虚拟根后返回) */
|
||
async function loadTreeApi(): Promise<CategoryTreeNode[]> {
|
||
const tree = await categoryApi.tree(store.siteId!).send();
|
||
return [wrapTreeWithRoot(tree as any[], rootLabel.value)];
|
||
}
|
||
|
||
// 站点上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
|
||
watch(
|
||
() => store.siteId,
|
||
() => {
|
||
curTreeNode.value = {};
|
||
nextTick(() => refTreePanel.value?.loadTree());
|
||
}
|
||
);
|
||
|
||
function onTreeData(tree: any[]) {
|
||
treeData.value = tree;
|
||
}
|
||
|
||
function onTreeChange(node: any) {
|
||
curTreeNode.value = node || {};
|
||
}
|
||
|
||
/** 在全量树中找节点(子组排序取 children 用) */
|
||
function findNode(list: any[], id: number): any {
|
||
for (const n of list || []) {
|
||
if (n.id === id) return n;
|
||
if (n.children?.length) {
|
||
const r = findNode(n.children, id);
|
||
if (r) return r;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** target 是否 node 自身或其子孙(成环判定:把栏目挂到自己子树下会形成环) */
|
||
function isSelfOrDescendant(node: any, targetId: number | undefined): boolean {
|
||
if (!node || targetId == null) return false;
|
||
if (node.id === targetId) return true;
|
||
return (node.children || []).some((c: any) => isSelfOrDescendant(c, targetId));
|
||
}
|
||
|
||
/**
|
||
* 上级栏目选项:虚拟根(0=rootLabel)+ 真实树拍平缩进。
|
||
*
|
||
* 2026-09-11 口径调整:**不再置灰任何选项**(含外链型栏目、含自身及其子栏目)——
|
||
* 类型与"能否作父"无关,而且灰色项只显示一个无解释的锁,用户看到的往往是
|
||
* "办事大厅被禁用了"这类误解。成环改为保存前校验(见 save())并给出明确原因,
|
||
* 后端 CategoryService.validateParent 同步升级为沿祖先链防任意层级成环。
|
||
*/
|
||
const parentOptions = computed(() => {
|
||
const list: { id: number; label: string }[] = [
|
||
{ id: 0, label: rootLabel.value || "根栏目" },
|
||
];
|
||
const walk = (nodes: any[], depth: number) => {
|
||
(nodes || []).forEach((n: any) => {
|
||
list.push({ id: n.id, label: `${" ".repeat(depth)}${n.name}` });
|
||
walk(n.children || [], depth + 1);
|
||
});
|
||
};
|
||
walk(treeData.value[0]?.children || [], 1);
|
||
return list;
|
||
});
|
||
|
||
function currentNode() {
|
||
return selected.value || {};
|
||
}
|
||
|
||
async function showAddNode() {
|
||
const node = currentNode();
|
||
refFormData.value?.resetFields();
|
||
await nextTick();
|
||
modalVisible.value = true;
|
||
form.id = undefined;
|
||
form.name = "";
|
||
form.parentId = node?.id ?? 0;
|
||
form.type = 1;
|
||
form.linkUrl = "";
|
||
form.content = "";
|
||
form.sortNo = 0;
|
||
form.seoTitle = "";
|
||
form.seoDescription = "";
|
||
form.status = 1;
|
||
setTimeout(() => refNodeNameInput.value?.focus(), 300);
|
||
}
|
||
|
||
function showEditNode() {
|
||
const node = currentNode();
|
||
if (!node?.id) {
|
||
Message.warning("请先选择左侧栏目");
|
||
return;
|
||
}
|
||
refFormData.value?.resetFields();
|
||
modalVisible.value = true;
|
||
Object.assign(form, {
|
||
id: node.id,
|
||
parentId: node.parentId ?? 0,
|
||
name: node.name ?? "",
|
||
type: node.type ?? 1,
|
||
linkUrl: node.linkUrl || "",
|
||
content: node.content || "",
|
||
sortNo: node.sortNo ?? 0,
|
||
seoTitle: node.seoTitle || "",
|
||
seoDescription: node.seoDescription || "",
|
||
status: node.status ?? 1,
|
||
});
|
||
nextTick().then(() => refNodeNameInput.value?.focus());
|
||
}
|
||
|
||
function showSortNode() {
|
||
const node = currentNode();
|
||
if (!node?.id) {
|
||
Message.warning("请先选择左侧栏目");
|
||
return;
|
||
}
|
||
const found = findNode(treeData.value, node.id);
|
||
sortChildrenSource.value = found?.children ?? [];
|
||
sortParentId.value = node.id;
|
||
sortParentName.value = node.name ?? "";
|
||
sortModal.value = true;
|
||
}
|
||
|
||
function showDeleteNode() {
|
||
const node = currentNode();
|
||
if (!node?.id) {
|
||
Message.warning("请先选择左侧栏目");
|
||
return;
|
||
}
|
||
MyConfirm({
|
||
title: "删除栏目",
|
||
msg: `确定删除「${node.name}」吗?`,
|
||
info: "仅允许删除空栏目(无子栏目且无文章)",
|
||
async onOk() {
|
||
await categoryApi.delete({ id: node.id, siteId: store.siteId! }).send();
|
||
Message.success("操作成功!");
|
||
curTreeNode.value = {};
|
||
await refTreePanel.value?.refreshTree();
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 保存前成环校验:上级栏目不能是自身或自己的子栏目。
|
||
* 返回提示文案(命中)或空串(通过)。
|
||
*/
|
||
function findCycleParent(): string {
|
||
const parentId = Number(form.parentId);
|
||
if (!parentId) return "";
|
||
if (form.id != null && parentId === Number(form.id)) {
|
||
return "上级栏目不能是自身,请另选一个上级栏目";
|
||
}
|
||
const parentNode = findNode(treeData.value, parentId);
|
||
if (parentNode && isSelfOrDescendant(parentNode, form.id)) {
|
||
return `「${parentNode.name}」是当前栏目的子栏目,挂到它下面会形成循环,请另选上级栏目`;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function save() {
|
||
const valid = await refFormData.value?.validate();
|
||
if (!valid) {
|
||
// bsch 规范 §4:校验失败统一提示
|
||
Message.warning("请填写红色星号内容!");
|
||
return;
|
||
}
|
||
// 成环校验(下拉已不再置灰,这里给出可读原因;后端 CategoryService.validateParent 同口径兜底)
|
||
const cycleHit = findCycleParent();
|
||
if (cycleHit) {
|
||
Message.warning(cycleHit);
|
||
return;
|
||
}
|
||
saving.value = true;
|
||
try {
|
||
await categoryApi
|
||
.save({
|
||
id: form.id,
|
||
siteId: store.siteId!,
|
||
parentId: form.parentId,
|
||
name: form.name,
|
||
type: form.type,
|
||
linkUrl: form.linkUrl || undefined,
|
||
content: form.content || undefined,
|
||
sortNo: form.sortNo,
|
||
seoTitle: form.seoTitle || undefined,
|
||
seoDescription: form.seoDescription || undefined,
|
||
status: form.status,
|
||
})
|
||
.send();
|
||
Message.success("保存成功!");
|
||
modalVisible.value = false;
|
||
await refTreePanel.value?.refreshTree(form.id ?? form.parentId);
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
function onSortSaved() {
|
||
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
||
}
|
||
|
||
/** 批量添加完成:刷新树并定位到最后创建成功的节点 */
|
||
function onBatchSuccess(lastSavedId?: number) {
|
||
refTreePanel.value?.refreshTree(lastSavedId ?? curTreeNode.value?.id);
|
||
}
|
||
|
||
/** 新增模态框 → 批量添加(UserBase switchToBatch 同款,仅新增态开放) */
|
||
function switchToBatch() {
|
||
batchDefaultType.value = form.type;
|
||
modalVisible.value = false;
|
||
batchModal.value = true;
|
||
}
|
||
|
||
/* ===== 单页富文本编辑(wangEditor,配置对齐 ArticleEdit;贴图 scene=category 标记引用) ===== */
|
||
const editorRef = shallowRef();
|
||
const editorReady = ref(false);
|
||
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));
|
||
// scene=category:单页贴图计入栏目引用,附件管理不计为「未使用」(后端白名单)
|
||
fd.append("scene", "category");
|
||
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) {
|
||
insertFn(res.data.url, res.data.fileName || "", res.data.url);
|
||
} else {
|
||
Message.error(res.msg || "图片上传失败");
|
||
}
|
||
} catch {
|
||
Message.error("图片上传失败");
|
||
}
|
||
},
|
||
},
|
||
},
|
||
};
|
||
function handleCreated(editor: any) {
|
||
editorRef.value = editor;
|
||
}
|
||
|
||
// wangEditor 不能在 display:none 容器内创建(宽度塌陷):模态框展开动画结束后再挂载,
|
||
// 关闭即卸载销毁(@wangeditor/editor-for-vue 组件卸载时自动 destroy)
|
||
watch(modalVisible, v => {
|
||
if (v) {
|
||
editorReady.value = false;
|
||
setTimeout(() => {
|
||
editorReady.value = form.type === 2;
|
||
}, 320);
|
||
} else {
|
||
editorReady.value = false;
|
||
}
|
||
});
|
||
|
||
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */
|
||
|
||
/** 单页正文纯文本(字数统计/空态判断用) */
|
||
function plainText(html: string): string {
|
||
return String(html || "")
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/ /g, " ")
|
||
.trim();
|
||
}
|
||
|
||
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填);站点段跟随当前上下文 */
|
||
function goNewArticle(categoryId: number) {
|
||
const code = store.schoolCode;
|
||
if (!code) return;
|
||
window.location.hash = `#${adminPathOf(code, store.siteSegCode, "article/edit")}?categoryId=${categoryId}`;
|
||
}
|
||
|
||
/** 门户预览:新开 /portal/{path}/c/{id}(W4 嵌套路径) */
|
||
function openPreview(node: any) {
|
||
if (!store.currentSite) {
|
||
Message.warning("当前无站点上下文,无法预览");
|
||
return;
|
||
}
|
||
window.open(`${config.serviceHost}${portalUrlOf(store.currentSite)}c/${node.id}`, "_blank");
|
||
}
|
||
|
||
/** 外链栏目直达 */
|
||
function openLink(url: string) {
|
||
window.open(url, "_blank");
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="tree-wrapper category-page">
|
||
<!-- 左侧栏目树(userbase rule-tree 风格) -->
|
||
<div class="tree-container category-tree-box">
|
||
<ul class="opera-ul">
|
||
<li class="add-node-li" @click="showAddNode">
|
||
<Tooltip content="新增子栏目" placement="top" transfer>
|
||
<SvgIcon name="tree-icon-1" />
|
||
</Tooltip>
|
||
</li>
|
||
<Tooltip content="编辑栏目" placement="top" transfer>
|
||
<li @click="showEditNode">
|
||
<SvgIcon name="tree-icon-2" />
|
||
</li>
|
||
</Tooltip>
|
||
<Tooltip content="子栏目排序" placement="top" transfer>
|
||
<li @click="showSortNode">
|
||
<SvgIcon name="tree-icon-3" />
|
||
</li>
|
||
</Tooltip>
|
||
<Tooltip content="删除栏目" placement="top" transfer>
|
||
<li @click="showDeleteNode">
|
||
<SvgIcon name="tree-icon-5" />
|
||
</li>
|
||
</Tooltip>
|
||
</ul>
|
||
|
||
<CategoryTreePanel
|
||
ref="refTreePanel"
|
||
v-model:curTreeNode="curTreeNode"
|
||
:treeApi="loadTreeApi"
|
||
:defaultSearchVisible="false"
|
||
:showCloseButton="true"
|
||
@on-change="onTreeChange"
|
||
@on-tree-data="onTreeData"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 右侧:类型化内容面板(B5 栏目工作台) -->
|
||
<div class="main-container workbench-main">
|
||
<template v-if="selected">
|
||
<div class="panel-header">
|
||
<div class="panel-title">
|
||
<h3>{{ selected.name }}</h3>
|
||
<Tag :color="selected.type === 1 ? 'primary' : selected.type === 2 ? 'success' : 'warning'">
|
||
{{ typeLabel(selected.type) }}
|
||
</Tag>
|
||
<span class="panel-meta">排序 {{ selected.sortNo }} · {{ selected.status === 1 ? "显示" : "隐藏" }}</span>
|
||
</div>
|
||
<div class="panel-tools">
|
||
<Button v-if="selected.type === 1" type="primary" @click="goNewArticle(selected.id)">
|
||
<Icon type="ios-add" />
|
||
新建文章
|
||
</Button>
|
||
<Button v-if="selected.type !== 3" @click="openPreview(selected)">
|
||
<Icon type="ios-eye" />
|
||
预览
|
||
</Button>
|
||
<Button @click="showEditNode">
|
||
<Icon type="ios-create" />
|
||
编辑栏目
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 列表型:只读内容概览(P-D1 结构/内容视图切分;文章操作统一在 /article) -->
|
||
<CategoryContentSummary
|
||
v-if="selected.type === 1"
|
||
class="panel-body"
|
||
:category-id="selected.id"
|
||
:category-name="selected.name"
|
||
:tree="treeData"
|
||
/>
|
||
|
||
<!-- 单页型:正文渲染预览 + 元信息 + 编辑入口 -->
|
||
<div v-else-if="selected.type === 2" class="single-summary">
|
||
<div class="summary-meta">
|
||
<span>共 {{ plainText(selected.content).length }} 字</span>
|
||
<span>{{ selected.status === 1 ? "已显示" : "已隐藏" }}</span>
|
||
</div>
|
||
<!-- content 入库前经后端 jsoup 白名单清洗,回显渲染安全 -->
|
||
<div v-if="plainText(selected.content)" class="rich-preview" v-html="selected.content"></div>
|
||
<div v-else class="summary-empty">
|
||
<p>暂无内容</p>
|
||
<Button type="primary" ghost @click="showEditNode">
|
||
<Icon type="ios-create" />
|
||
去编写
|
||
</Button>
|
||
</div>
|
||
<div class="summary-actions">
|
||
<Button type="primary" @click="showEditNode">
|
||
<Icon type="ios-create" />
|
||
编辑内容
|
||
</Button>
|
||
<Button @click="openPreview(selected)">
|
||
<Icon type="ios-eye" />
|
||
预览单页
|
||
</Button>
|
||
</div>
|
||
<p v-if="selected.seoTitle" class="seo-line">SEO 标题:{{ selected.seoTitle }}</p>
|
||
<p v-if="selected.seoDescription" class="seo-line">SEO 描述:{{ selected.seoDescription }}</p>
|
||
</div>
|
||
|
||
<!-- 外链型:URL 展示 + 直达 -->
|
||
<div v-else class="link-info">
|
||
<p class="link-url-label">跳转地址</p>
|
||
<p class="link-url">{{ selected.linkUrl }}</p>
|
||
<div class="summary-actions">
|
||
<Button type="primary" @click="openLink(selected.linkUrl)">
|
||
<Icon type="ios-open-outline" />
|
||
打开链接
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="empty-tip">选择左侧栏目查看内容</div>
|
||
</div>
|
||
|
||
<!-- 编辑弹窗 -->
|
||
<Modal
|
||
v-model="modalVisible"
|
||
:title="form.id ? '编辑栏目' : '新增子栏目'"
|
||
:mask-closable="false"
|
||
width="640px"
|
||
>
|
||
<Form ref="refFormData" :model="form" :rules="rules" :label-width="100">
|
||
<Divider size="small" orientation="left">基本信息</Divider>
|
||
<FormItem label="上级栏目" prop="parentId">
|
||
<Select v-model="form.parentId" transfer>
|
||
<Option v-for="opt in parentOptions" :key="opt.id" :value="opt.id">
|
||
{{ opt.label }}
|
||
</Option>
|
||
</Select>
|
||
<p class="form-tip">任何类型的栏目都可作为上级(外链型也可以);不能选自身或自己的子栏目(会成环)。</p>
|
||
</FormItem>
|
||
<FormItem label="栏目名称" prop="name">
|
||
<Input ref="refNodeNameInput" v-model="form.name" :maxlength="64" placeholder="栏目名称" />
|
||
</FormItem>
|
||
<FormItem label="栏目类型" prop="type">
|
||
<RadioGroup v-model="form.type">
|
||
<Radio v-for="t in typeDict" :key="t.value" :label="t.value">{{ t.label }}</Radio>
|
||
</RadioGroup>
|
||
</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>
|
||
|
||
<Divider size="small" orientation="left">内容设置</Divider>
|
||
<FormItem v-if="form.type === 3" label="外链地址" prop="linkUrl">
|
||
<Input v-model="form.linkUrl" :maxlength="500" placeholder="http(s):// 开头" />
|
||
</FormItem>
|
||
<FormItem v-if="form.type === 2" label="单页内容">
|
||
<div class="editor-wrap">
|
||
<template v-if="editorReady">
|
||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||
<Editor
|
||
class="editor-content"
|
||
v-model="form.content"
|
||
:defaultConfig="editorConfig"
|
||
mode="default"
|
||
@onCreated="handleCreated"
|
||
/>
|
||
</template>
|
||
<div v-else class="editor-placeholder">编辑器加载中……</div>
|
||
</div>
|
||
</FormItem>
|
||
|
||
<Divider size="small" orientation="left">SEO 设置</Divider>
|
||
<FormItem label="SEO 标题">
|
||
<Input v-model="form.seoTitle" :maxlength="200" placeholder="空=用栏目名" />
|
||
</FormItem>
|
||
<FormItem label="SEO 描述">
|
||
<Input v-model="form.seoDescription" type="textarea" :rows="2" :maxlength="500" />
|
||
</FormItem>
|
||
</Form>
|
||
<template #footer>
|
||
<Button v-if="!form.id" type="text" @click="switchToBatch">切换至批量添加</Button>
|
||
<Button @click="modalVisible = false">取消</Button>
|
||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||
</template>
|
||
</Modal>
|
||
|
||
<!-- 批量添加栏目(UserBase BatchAddGroup 同款:一行一个名称,前端循环调 save) -->
|
||
<BatchAddCategory
|
||
v-model:visible="batchModal"
|
||
:default-pid="curTreeNode?.id ?? 0"
|
||
:parent-name="curTreeNode?.id ? curTreeNode?.name : rootLabel"
|
||
:default-type="batchDefaultType"
|
||
@success="onBatchSuccess"
|
||
/>
|
||
|
||
<!-- 子栏目排序(userbase GroupSort 同款拖拽) -->
|
||
<CategorySortModal
|
||
v-model:visible="sortModal"
|
||
:parent-id="sortParentId"
|
||
:parent-name="sortParentName"
|
||
:children="sortChildrenSource"
|
||
@saved="onSortSaved"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.form-tip {
|
||
color: #999;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* UserBase group/index 同款工具栏样式 */
|
||
.opera-ul {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
align-items: center;
|
||
border-bottom: 1px solid #ebeef5;
|
||
padding-bottom: 10px;
|
||
|
||
li {
|
||
padding: 10px;
|
||
height: 38px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
|
||
&.active,
|
||
&:hover {
|
||
background-color: #f5f5f5;
|
||
}
|
||
|
||
svg {
|
||
width: 18px;
|
||
height: 18px;
|
||
}
|
||
|
||
.ivu-icon {
|
||
font-size: 18px;
|
||
}
|
||
}
|
||
|
||
.add-node-li {
|
||
position: relative;
|
||
}
|
||
}
|
||
|
||
.category-tree-box {
|
||
padding: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.group-tree-search-panel {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
}
|
||
|
||
/* ===== B5 栏目工作台右侧面板 ===== */
|
||
.workbench-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
|
||
.panel-header {
|
||
background: #fff;
|
||
border-radius: 4px;
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
flex-shrink: 0;
|
||
|
||
.panel-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
|
||
h3 {
|
||
margin: 0;
|
||
max-width: 320px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.panel-meta {
|
||
color: #999;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.panel-tools {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
}
|
||
|
||
.panel-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
margin-top: 10px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.single-summary,
|
||
.link-info {
|
||
background: #fff;
|
||
border-radius: 4px;
|
||
padding: 16px;
|
||
margin-top: 10px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
|
||
.summary-meta {
|
||
display: flex;
|
||
gap: 16px;
|
||
color: #999;
|
||
font-size: 12px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.rich-preview {
|
||
line-height: 1.8;
|
||
word-break: break-all;
|
||
|
||
:deep(img) {
|
||
max-width: 100%;
|
||
}
|
||
|
||
:deep(table) {
|
||
border-collapse: collapse;
|
||
|
||
td,
|
||
th {
|
||
border: 1px solid #ccc;
|
||
padding: 4px 8px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.summary-empty {
|
||
text-align: center;
|
||
color: #999;
|
||
padding: 40px 0;
|
||
|
||
p {
|
||
margin-bottom: 12px;
|
||
}
|
||
}
|
||
|
||
.summary-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.seo-line {
|
||
color: #999;
|
||
font-size: 12px;
|
||
margin-top: 10px;
|
||
}
|
||
}
|
||
|
||
.link-info {
|
||
.link-url-label {
|
||
color: #999;
|
||
font-size: 12px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.link-url {
|
||
color: #2d8cf0;
|
||
word-break: break-all;
|
||
margin-bottom: 14px;
|
||
}
|
||
}
|
||
|
||
/* 单页富文本编辑器(模态框内):wangEditor v5 高度打在内层 .w-e-text-container */
|
||
.editor-wrap {
|
||
border: 1px solid #dcdee2;
|
||
border-radius: 4px;
|
||
z-index: 100;
|
||
|
||
.editor-toolbar {
|
||
border-bottom: 1px solid #dcdee2;
|
||
}
|
||
|
||
.editor-content {
|
||
:deep(.w-e-text-container) {
|
||
height: 320px !important;
|
||
}
|
||
overflow-y: hidden;
|
||
}
|
||
|
||
.editor-placeholder {
|
||
height: 320px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #999;
|
||
}
|
||
}
|
||
|
||
.empty-tip {
|
||
color: #999;
|
||
text-align: center;
|
||
padding: 60px 0;
|
||
}
|
||
</style>
|