feat(B5): 栏目工作台-树+类型化内容面板,ArticleList 组件化薄壳
- 新建 ArticleListPanel(props categoryId/fixedCategory):无props=全局检索行为不变(薄壳红线); 固定栏目模式隐藏栏目下拉,watch categoryId 联动 reload(拖拽排序不触发,红线) - CategoryManage 右侧:列表型=本栏目文章面板+新建文章(query.categoryId 预选)+预览; 单页型=摘要+编辑内容+预览;外链型=URL+打开链接;空态引导;工具栏增树折叠(窄屏) - ArticleEdit 支持 query.categoryId 新建态预选栏目 - 后端零改动(红线);vitest 32/32 绿(新增 categoryWorkbench.spec 7 例,手写挂载+vi.mock 桩)
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
/**
|
||||||
|
* B5 栏目工作台组件行为(方案 P1-2):
|
||||||
|
* ① 列表型栏目 → 右侧 ArticleListPanel 且 categoryId 正确传递;
|
||||||
|
* ② 切换树节点 → 面板 watch categoryId 联动 reload;
|
||||||
|
* ③ 单页型 → 摘要面板(含"编辑内容"入口)不渲染文章列表;
|
||||||
|
* ④ 外链型 → linkUrl + 打开链接;
|
||||||
|
* ⑤ 新建文章按钮 → 路由跳转带 categoryId query。
|
||||||
|
*
|
||||||
|
* 无 @vue/test-utils 依赖:createApp 手写挂载 + vi.mock 桩(树面板/排序弹窗/图标/确认框/api/router)。
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import ViewUIPlus from "view-ui-plus";
|
||||||
|
|
||||||
|
const { pageMock, routerPush, openMock } = vi.hoisted(() => ({
|
||||||
|
pageMock: vi.fn(),
|
||||||
|
routerPush: vi.fn(),
|
||||||
|
openMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/api/module/cms", () => ({
|
||||||
|
articleApi: {
|
||||||
|
page: (query: any) => {
|
||||||
|
pageMock(query);
|
||||||
|
return { send: () => Promise.resolve({ list: [], total: 0 }) };
|
||||||
|
},
|
||||||
|
publish: () => ({ send: () => Promise.resolve({}) }),
|
||||||
|
offline: () => ({ send: () => Promise.resolve({}) }),
|
||||||
|
topmost: () => ({ send: () => Promise.resolve({}) }),
|
||||||
|
delete: () => ({ send: () => Promise.resolve({}) }),
|
||||||
|
},
|
||||||
|
categoryApi: {
|
||||||
|
tree: () => ({ send: () => Promise.resolve([]) }),
|
||||||
|
save: () => ({ send: () => Promise.resolve({}) }),
|
||||||
|
delete: () => ({ send: () => Promise.resolve({}) }),
|
||||||
|
},
|
||||||
|
uploadUrl: () => "/api/attachment/upload",
|
||||||
|
portalBase: (code: string) => `/portal/${code}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("vue-router", () => ({
|
||||||
|
useRouter: () => ({ push: routerPush, replace: vi.fn() }),
|
||||||
|
useRoute: () => ({ params: {}, query: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点" */
|
||||||
|
vi.mock("@/components/CategoryTreePanel.vue", () => ({
|
||||||
|
default: defineComponent({
|
||||||
|
props: ["curTreeNode", "treeApi", "defaultSearchVisible", "showCloseButton"],
|
||||||
|
emits: ["update:curTreeNode", "on-change", "on-tree-data"],
|
||||||
|
setup(_, { emit }) {
|
||||||
|
(globalThis as any).__treeEmit = (node: any) => {
|
||||||
|
emit("update:curTreeNode", node);
|
||||||
|
emit("on-change", node);
|
||||||
|
};
|
||||||
|
return () => h("div", { class: "tree-stub" }, "tree-stub");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/CategorySortModal.vue", () => ({
|
||||||
|
default: defineComponent({ props: ["visible", "parentId", "parentName", "children"], emits: ["saved"], render: () => null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/SvgIcon.vue", () => ({
|
||||||
|
default: defineComponent({ props: ["name"], render: () => h("span", "icon") }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||||
|
|
||||||
|
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||||
|
import CategoryManage from "@/views/cms/CategoryManage.vue";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
|
||||||
|
let cleanups: (() => void)[] = [];
|
||||||
|
|
||||||
|
/** 挂载:真实 pinia(预置学校/站点上下文)+ 全局 view-ui-plus */
|
||||||
|
function mountApp(root: Component) {
|
||||||
|
const pinia = createPinia();
|
||||||
|
const store = useCmsStore(pinia);
|
||||||
|
store.user = { superAdmin: true, sites: [] } as any;
|
||||||
|
store.currentSchool = { schId: 1, schName: "演示学校", mainSiteId: 1, mainSiteCode: "demo1", mainSiteName: "演示主站" } as any;
|
||||||
|
store.currentSite = { id: 1, code: "demo1", name: "演示站" } as any;
|
||||||
|
const el = document.createElement("div");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const app = createApp(root as any);
|
||||||
|
app.use(pinia);
|
||||||
|
app.use(ViewUIPlus);
|
||||||
|
app.mount(el);
|
||||||
|
cleanups.push(() => {
|
||||||
|
app.unmount();
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(frames = 5) {
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
(globalThis as any).__treeEmit = undefined;
|
||||||
|
window.open = openMock as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanups.forEach(fn => fn());
|
||||||
|
cleanups = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () => {
|
||||||
|
it("① 固定栏目模式:categoryId 正确传给查询且隐藏栏目下拉(仅剩状态筛选)", async () => {
|
||||||
|
const state = reactive({ cid: 5, fixed: true });
|
||||||
|
const host = defineComponent({
|
||||||
|
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||||
|
});
|
||||||
|
const el = mountApp(host);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(pageMock).toHaveBeenCalled();
|
||||||
|
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryId: 5 });
|
||||||
|
// 栏目下拉隐藏(剩余 .ivu-select 为状态筛选与分页 sizer,占位文本不含"全部栏目")
|
||||||
|
expect(el.textContent).not.toContain("全部栏目");
|
||||||
|
expect([...el.querySelectorAll(".ivu-select-placeholder")].every(p => !p.textContent?.includes("全部栏目"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("② 切换树节点:categoryId 变化触发面板 reload(watch 联动)", async () => {
|
||||||
|
const state = reactive({ cid: 5, fixed: true });
|
||||||
|
const host = defineComponent({
|
||||||
|
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||||
|
});
|
||||||
|
mountApp(host);
|
||||||
|
await flush();
|
||||||
|
expect(pageMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
state.cid = 7;
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(pageMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryId: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("⑤ 面板新建文章:路由跳转带 categoryId query", async () => {
|
||||||
|
const state = reactive({ cid: 5, fixed: true });
|
||||||
|
const host = defineComponent({
|
||||||
|
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||||
|
});
|
||||||
|
const el = mountApp(host);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const btn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("新建文章"));
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit?categoryId=5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
|
||||||
|
it("③ 单页型栏目:显示摘要面板(含编辑内容入口),不渲染文章列表", async () => {
|
||||||
|
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||||
|
await flush();
|
||||||
|
(globalThis as any).__treeEmit({ id: 2, parentId: 0, name: "学校简介", type: 2, content: "<p>云海第一中学始建于 1958 年。</p>", sortNo: 0, status: 1 });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(el.textContent).toContain("学校简介");
|
||||||
|
expect(el.textContent).toContain("云海第一中学始建于 1958 年");
|
||||||
|
expect(el.textContent).toContain("编辑内容");
|
||||||
|
expect(el.querySelector(".article-list-panel")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("④ 外链型栏目:显示 linkUrl 与「打开链接」,点击新窗口打开", async () => {
|
||||||
|
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||||
|
await flush();
|
||||||
|
(globalThis as any).__treeEmit({ id: 3, parentId: 0, name: "教育部链接", type: 3, linkUrl: "https://www.moe.gov.cn", sortNo: 0, status: 1 });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(el.textContent).toContain("https://www.moe.gov.cn");
|
||||||
|
const openBtn = [...el.querySelectorAll("button, a")].find(b => b.textContent?.includes("打开链接"));
|
||||||
|
expect(openBtn).toBeTruthy();
|
||||||
|
openBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await flush();
|
||||||
|
expect(openMock).toHaveBeenCalledWith("https://www.moe.gov.cn", "_blank");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("列表型栏目:右侧渲染 ArticleListPanel(categoryId=选中栏目)", async () => {
|
||||||
|
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||||
|
await flush();
|
||||||
|
(globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(el.querySelector(".article-list-panel")).toBeTruthy();
|
||||||
|
expect(pageMock).toHaveBeenCalled();
|
||||||
|
expect(pageMock.mock.calls[0][0]).toMatchObject({ categoryId: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未选中节点:右侧显示空态引导", async () => {
|
||||||
|
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||||
|
await flush();
|
||||||
|
expect(el.textContent).toContain("选择左侧栏目");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, onMounted, watch } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { Message, Tag } from "view-ui-plus";
|
||||||
|
import Utils from "@/utils/index";
|
||||||
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
import { articleApi, categoryApi } from "@/api/module/cms";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章列表面板(B5 栏目工作台组件化,自 ArticleList.vue 原样抽出):
|
||||||
|
* - 无 props = 全局检索行为(ArticleList.vue 薄壳引用,路由/搜索参数兼容);
|
||||||
|
* - fixedCategory + categoryId = 固定栏目模式:隐藏栏目下拉、查询强制带 categoryId,
|
||||||
|
* categoryId 变化 watch 联动 reload(树节点切换;拖拽排序不改变 id 不触发)。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
categoryId?: number;
|
||||||
|
fixedCategory?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useCmsStore();
|
||||||
|
|
||||||
|
const rows = ref<ArticlePageItem[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const pageNum = ref(1);
|
||||||
|
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||||
|
|
||||||
|
const search = reactive({
|
||||||
|
categoryId: undefined as number | undefined,
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
keyword: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 栏目下拉(树拍平缩进展示;固定栏目模式不加载不显示) */
|
||||||
|
const categoryOptions = ref<{ id: number; label: string }[]>([]);
|
||||||
|
|
||||||
|
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 tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "标题",
|
||||||
|
key: "title",
|
||||||
|
minWidth: 260,
|
||||||
|
tooltip: true,
|
||||||
|
render: (h: any, p: any) =>
|
||||||
|
h("span", null, [
|
||||||
|
h(
|
||||||
|
"a",
|
||||||
|
{
|
||||||
|
style: { color: "#2d8cf0", cursor: "pointer", marginRight: "6px" },
|
||||||
|
onClick: () => goEdit(p.row.id),
|
||||||
|
},
|
||||||
|
p.row.title
|
||||||
|
),
|
||||||
|
p.row.topmost === 1 ? h(Tag, { color: "error" }, () => "置顶") : null,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ title: "栏目", key: "categoryName", width: 140, tooltip: true },
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
key: "status",
|
||||||
|
width: 100,
|
||||||
|
align: "center",
|
||||||
|
render: (h: any, p: any) =>
|
||||||
|
h(Tag, { color: statusColor(p.row.status) }, () => statusText(p.row.status)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "发布时间",
|
||||||
|
key: "publishTime",
|
||||||
|
width: 170,
|
||||||
|
render: (h: any, p: any) => h("span", p.row.publishTime || "-"),
|
||||||
|
},
|
||||||
|
{ title: "浏览量", key: "viewCount", width: 90, align: "center" },
|
||||||
|
{ title: "创建时间", key: "createTime", width: 170 },
|
||||||
|
{
|
||||||
|
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-apps", () => doPublish(p.row)));
|
||||||
|
}
|
||||||
|
if (p.row.status === 1) {
|
||||||
|
ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row)));
|
||||||
|
}
|
||||||
|
ops.push(
|
||||||
|
Utils.operatingHandle(
|
||||||
|
h,
|
||||||
|
p.row.topmost === 1 ? "取消置顶" : "置顶",
|
||||||
|
"icon-saoba",
|
||||||
|
() => doTopmost(p.row)
|
||||||
|
),
|
||||||
|
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row))
|
||||||
|
);
|
||||||
|
return ops;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function flattenCategories(tree: CategoryTreeNode[], depth = 0) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategories() {
|
||||||
|
if (!store.siteId || props.fixedCategory) return;
|
||||||
|
const tree = await categoryApi.tree(store.siteId).send();
|
||||||
|
categoryOptions.value = flattenCategories(tree || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!store.siteId) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const page = await articleApi
|
||||||
|
.page({
|
||||||
|
siteId: store.siteId,
|
||||||
|
categoryId: props.fixedCategory ? props.categoryId : search.categoryId,
|
||||||
|
status: search.status,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定栏目模式下新建文章带 categoryId 预选(ArticleEdit onMounted 回填) */
|
||||||
|
function goEdit(id?: number) {
|
||||||
|
const code = store.schoolCode;
|
||||||
|
if (!code) {
|
||||||
|
router.push("/workbench");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = props.fixedCategory && props.categoryId ? `?categoryId=${props.categoryId}` : "";
|
||||||
|
router.push(id ? `/sch/${code}/article/edit/${id}${query}` : `/sch/${code}/article/edit${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doPublish(row: ArticlePageItem) {
|
||||||
|
await articleApi.publish(store.siteId!, row.id).send();
|
||||||
|
Message.success("发布成功!");
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doOffline(row: ArticlePageItem) {
|
||||||
|
await articleApi.offline(store.siteId!, row.id).send();
|
||||||
|
Message.success("已下架");
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doTopmost(row: ArticlePageItem) {
|
||||||
|
await articleApi.topmost(store.siteId!, row.id, row.topmost !== 1).send();
|
||||||
|
Message.success(row.topmost === 1 ? "已取消置顶" : "已置顶");
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function doDelete(row: ArticlePageItem) {
|
||||||
|
MyConfirm({
|
||||||
|
title: "删除文章",
|
||||||
|
msg: `确定删除「${row.title}」吗?`,
|
||||||
|
info: "删除后正文图片将转为未使用附件,可在附件管理中清理",
|
||||||
|
async onOk() {
|
||||||
|
await articleApi.delete({ id: row.id, siteId: store.siteId! }).send();
|
||||||
|
Message.success("操作成功!");
|
||||||
|
if (rows.value.length <= 1 && pageNum.value > 1) pageNum.value -= 1;
|
||||||
|
run();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadCategories();
|
||||||
|
await run();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 树节点切换联动(B5 红线:仅 watch categoryId,拖拽排序不经过此通道)
|
||||||
|
watch(() => props.categoryId, async () => {
|
||||||
|
pageNum.value = 1;
|
||||||
|
await run();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 站点/学校上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
|
||||||
|
watch(() => store.siteId, async () => {
|
||||||
|
await loadCategories();
|
||||||
|
pageNum.value = 1;
|
||||||
|
await run();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="article-list-panel">
|
||||||
|
<div class="fix mb-12">
|
||||||
|
<Button class="fl" type="primary" size="large" @click="goEdit()">
|
||||||
|
<Icon type="ios-add" />
|
||||||
|
新建文章
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="search-wrap fix">
|
||||||
|
<Select
|
||||||
|
v-if="!fixedCategory"
|
||||||
|
v-model="search.categoryId"
|
||||||
|
class="search-wrap_sel"
|
||||||
|
clearable
|
||||||
|
placeholder="全部栏目"
|
||||||
|
@on-change="pageNum = 1; run()"
|
||||||
|
>
|
||||||
|
<Option v-for="opt in categoryOptions" :key="opt.id" :value="opt.id">
|
||||||
|
{{ opt.label }}
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
v-model="search.status"
|
||||||
|
class="search-wrap_sel"
|
||||||
|
:class="{ 'ml-12': !fixedCategory }"
|
||||||
|
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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.search-wrap .search-wrap_sel {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -112,7 +112,14 @@ async function load() {
|
|||||||
if (!store.siteId) return;
|
if (!store.siteId) return;
|
||||||
const tree = await categoryApi.tree(store.siteId).send();
|
const tree = await categoryApi.tree(store.siteId).send();
|
||||||
categoryOptions.value = flattenCategories(tree || []);
|
categoryOptions.value = flattenCategories(tree || []);
|
||||||
if (!editId.value) return;
|
if (!editId.value) {
|
||||||
|
// B5:栏目工作台「新建文章」带 categoryId 预选(仅新建态;编辑态以详情回填为准)
|
||||||
|
const preselect = route.query.categoryId ? parseInt(String(route.query.categoryId)) : undefined;
|
||||||
|
if (preselect && categoryOptions.value.some(o => o.id === preselect)) {
|
||||||
|
form.categoryId = preselect;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const detail = await articleApi.get(store.siteId, editId.value).send();
|
const detail = await articleApi.get(store.siteId, editId.value).send();
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
|
|||||||
@@ -1,270 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, onMounted, watch } from "vue";
|
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { Message, Tag } from "view-ui-plus";
|
|
||||||
import Utils from "@/utils/index";
|
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
|
||||||
import { articleApi, categoryApi } from "@/api/module/cms";
|
|
||||||
import { useCmsStore } from "@/store";
|
|
||||||
import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
|
|
||||||
|
|
||||||
const router = useRouter();
|
/**
|
||||||
const store = useCmsStore();
|
* 文章列表(全局检索页,B5 组件化后薄壳引用):
|
||||||
|
* 无 props 透传 = 栏目下拉可筛 + 全站关键字检索,路由/搜索参数与组件化前完全一致。
|
||||||
const rows = ref<ArticlePageItem[]>([]);
|
*/
|
||||||
const total = ref(0);
|
|
||||||
const loading = ref(false);
|
|
||||||
const pageNum = ref(1);
|
|
||||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
|
||||||
|
|
||||||
const search = reactive({
|
|
||||||
categoryId: undefined as number | undefined,
|
|
||||||
status: undefined as number | undefined,
|
|
||||||
keyword: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 栏目下拉(树拍平缩进展示) */
|
|
||||||
const categoryOptions = ref<{ id: number; label: string }[]>([]);
|
|
||||||
|
|
||||||
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 tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: "标题",
|
|
||||||
key: "title",
|
|
||||||
minWidth: 260,
|
|
||||||
tooltip: true,
|
|
||||||
render: (h: any, p: any) =>
|
|
||||||
h("span", null, [
|
|
||||||
h(
|
|
||||||
"a",
|
|
||||||
{
|
|
||||||
style: { color: "#2d8cf0", cursor: "pointer", marginRight: "6px" },
|
|
||||||
onClick: () => goEdit(p.row.id),
|
|
||||||
},
|
|
||||||
p.row.title
|
|
||||||
),
|
|
||||||
p.row.topmost === 1 ? h(Tag, { color: "error" }, () => "置顶") : null,
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
{ title: "栏目", key: "categoryName", width: 140, tooltip: true },
|
|
||||||
{
|
|
||||||
title: "状态",
|
|
||||||
key: "status",
|
|
||||||
width: 100,
|
|
||||||
align: "center",
|
|
||||||
render: (h: any, p: any) =>
|
|
||||||
h(Tag, { color: statusColor(p.row.status) }, () => statusText(p.row.status)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "发布时间",
|
|
||||||
key: "publishTime",
|
|
||||||
width: 170,
|
|
||||||
render: (h: any, p: any) => h("span", p.row.publishTime || "-"),
|
|
||||||
},
|
|
||||||
{ title: "浏览量", key: "viewCount", width: 90, align: "center" },
|
|
||||||
{ title: "创建时间", key: "createTime", width: 170 },
|
|
||||||
{
|
|
||||||
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-apps", () => doPublish(p.row)));
|
|
||||||
}
|
|
||||||
if (p.row.status === 1) {
|
|
||||||
ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row)));
|
|
||||||
}
|
|
||||||
ops.push(
|
|
||||||
Utils.operatingHandle(
|
|
||||||
h,
|
|
||||||
p.row.topmost === 1 ? "取消置顶" : "置顶",
|
|
||||||
"icon-saoba",
|
|
||||||
() => doTopmost(p.row)
|
|
||||||
),
|
|
||||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row))
|
|
||||||
);
|
|
||||||
return ops;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function flattenCategories(tree: CategoryTreeNode[], depth = 0) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCategories() {
|
|
||||||
if (!store.siteId) return;
|
|
||||||
const tree = await categoryApi.tree(store.siteId).send();
|
|
||||||
categoryOptions.value = flattenCategories(tree || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
if (!store.siteId) return;
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const page = await articleApi
|
|
||||||
.page({
|
|
||||||
siteId: store.siteId,
|
|
||||||
categoryId: search.categoryId,
|
|
||||||
status: search.status,
|
|
||||||
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;
|
|
||||||
router.push(code ? `/sch/${code}/article/edit${id ? `/${id}` : ""}` : "/workbench");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doPublish(row: ArticlePageItem) {
|
|
||||||
await articleApi.publish(store.siteId!, row.id).send();
|
|
||||||
Message.success("发布成功!");
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doOffline(row: ArticlePageItem) {
|
|
||||||
await articleApi.offline(store.siteId!, row.id).send();
|
|
||||||
Message.success("已下架");
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doTopmost(row: ArticlePageItem) {
|
|
||||||
await articleApi.topmost(store.siteId!, row.id, row.topmost !== 1).send();
|
|
||||||
Message.success(row.topmost === 1 ? "已取消置顶" : "已置顶");
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
|
|
||||||
function doDelete(row: ArticlePageItem) {
|
|
||||||
MyConfirm({
|
|
||||||
title: "删除文章",
|
|
||||||
msg: `确定删除「${row.title}」吗?`,
|
|
||||||
info: "删除后正文图片将转为未使用附件,可在附件管理中清理",
|
|
||||||
async onOk() {
|
|
||||||
await articleApi.delete({ id: row.id, siteId: store.siteId! }).send();
|
|
||||||
Message.success("操作成功!");
|
|
||||||
if (rows.value.length <= 1 && pageNum.value > 1) pageNum.value -= 1;
|
|
||||||
run();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadCategories();
|
|
||||||
await run();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 站点/学校上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
|
|
||||||
watch(() => store.siteId, async () => {
|
|
||||||
await loadCategories();
|
|
||||||
pageNum.value = 1;
|
|
||||||
await run();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="fix mb-12">
|
<ArticleListPanel />
|
||||||
<Button class="fl" type="primary" size="large" @click="goEdit()">
|
|
||||||
<Icon type="ios-add" />
|
|
||||||
新建文章
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="search-wrap fix">
|
|
||||||
<Select
|
|
||||||
v-model="search.categoryId"
|
|
||||||
class="search-wrap_sel"
|
|
||||||
clearable
|
|
||||||
placeholder="全部栏目"
|
|
||||||
@on-change="pageNum = 1; run()"
|
|
||||||
>
|
|
||||||
<Option v-for="opt in categoryOptions" :key="opt.id" :value="opt.id">
|
|
||||||
{{ opt.label }}
|
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.search-wrap .search-wrap_sel {
|
|
||||||
width: 180px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import MyConfirm from "@/components/MyConfirm";
|
|||||||
import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
|
import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
|
||||||
import CategorySortModal from "@/components/CategorySortModal.vue";
|
import CategorySortModal from "@/components/CategorySortModal.vue";
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
|
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||||
import { categoryApi } from "@/api/module/cms";
|
import { categoryApi } from "@/api/module/cms";
|
||||||
import { useCmsStore } from "@/store";
|
import { useCmsStore } from "@/store";
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
import { CategoryTreeNode } from "@/types/cms";
|
import { CategoryTreeNode } from "@/types/cms";
|
||||||
|
|
||||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除/搜索)+ ztree */
|
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除/搜索)+ ztree */
|
||||||
const store = useCmsStore();
|
const store = useCmsStore();
|
||||||
|
|
||||||
|
/** 树折叠(窄屏/专注内容时收起左树,右侧面板占满;不触碰 ztree 内部交互) */
|
||||||
|
const treeCollapsed = ref(false);
|
||||||
|
|
||||||
const refTreePanel = useTemplateRef("refTreePanel");
|
const refTreePanel = useTemplateRef("refTreePanel");
|
||||||
const refFormData = useTemplateRef("refFormData");
|
const refFormData = useTemplateRef("refFormData");
|
||||||
const refNodeNameInput = useTemplateRef("refNodeNameInput");
|
const refNodeNameInput = useTemplateRef("refNodeNameInput");
|
||||||
@@ -195,10 +200,38 @@ async function save() {
|
|||||||
function onSortSaved() {
|
function onSortSaved() {
|
||||||
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */
|
||||||
|
|
||||||
|
/** 单页摘要:去 HTML 标签截断展示 */
|
||||||
|
function summaryText(node: any): string {
|
||||||
|
const text = String(node?.content || "").replace(/<[^>]+>/g, "").trim();
|
||||||
|
return text.length > 200 ? text.slice(0, 200) + "…" : text || "(暂无内容,点击「编辑内容」补充)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填) */
|
||||||
|
function goNewArticle(categoryId: number) {
|
||||||
|
window.location.hash = `#/sch/${store.schoolCode}/article/edit?categoryId=${categoryId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门户预览:新开 /portal/{siteCode}/c/{id} */
|
||||||
|
function openPreview(node: any) {
|
||||||
|
const code = store.currentSite?.code || store.schoolCode;
|
||||||
|
if (!code) {
|
||||||
|
Message.warning("当前无站点上下文,无法预览");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(`${config.serviceHost}/portal/${code}/c/${node.id}`, "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 外链栏目直达 */
|
||||||
|
function openLink(url: string) {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tree-wrapper category-page">
|
<div class="tree-wrapper category-page" :class="{ 'tree-collapsed': treeCollapsed }">
|
||||||
<!-- 左侧栏目树(userbase rule-tree 风格) -->
|
<!-- 左侧栏目树(userbase rule-tree 风格) -->
|
||||||
<div class="tree-container category-tree-box">
|
<div class="tree-container category-tree-box">
|
||||||
<ul class="opera-ul">
|
<ul class="opera-ul">
|
||||||
@@ -227,6 +260,11 @@ function onSortSaved() {
|
|||||||
<Icon type="md-search" />
|
<Icon type="md-search" />
|
||||||
</li>
|
</li>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip content="收起/展开栏目树" placement="top" transfer>
|
||||||
|
<li @click="treeCollapsed = !treeCollapsed">
|
||||||
|
<Icon :type="treeCollapsed ? 'ios-arrow-forward' : 'ios-arrow-back'" />
|
||||||
|
</li>
|
||||||
|
</Tooltip>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<CategoryTreePanel
|
<CategoryTreePanel
|
||||||
@@ -240,18 +278,71 @@ function onSortSaved() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧:选中栏目详情 -->
|
<!-- 右侧:类型化内容面板(B5 栏目工作台) -->
|
||||||
<div class="main-container">
|
<div class="main-container workbench-main">
|
||||||
<div class="info-panel" v-if="selected">
|
<template v-if="selected">
|
||||||
<h3>{{ selected.name }}</h3>
|
<div class="panel-header">
|
||||||
<p>类型:{{ typeDict.find(t => t.value === selected!.type)?.label }}</p>
|
<div class="panel-title">
|
||||||
<p v-if="selected.type === 3">外链:{{ selected.linkUrl }}</p>
|
<h3>{{ selected.name }}</h3>
|
||||||
<p v-if="selected.type === 2">单页内容:见门户预览</p>
|
<Tag :color="selected.type === 1 ? 'primary' : selected.type === 2 ? 'success' : 'warning'">
|
||||||
<p>排序号:{{ selected.sortNo }} · 状态:{{ selected.status === 1 ? "显示" : "隐藏" }}</p>
|
{{ typeDict.find(t => t.value === selected!.type)?.label }}
|
||||||
<p v-if="selected.seoTitle">SEO 标题:{{ selected.seoTitle }}</p>
|
</Tag>
|
||||||
<p v-if="selected.seoDescription">SEO 描述:{{ selected.seoDescription }}</p>
|
<span class="panel-meta">排序 {{ selected.sortNo }} · {{ selected.status === 1 ? "显示" : "隐藏" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-tip">请选择左侧栏目,或使用顶部图标新增子栏目</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>
|
||||||
|
|
||||||
|
<!-- 列表型:本栏目文章管理(联动仅经 categoryId watch,拖拽排序不触发) -->
|
||||||
|
<ArticleListPanel
|
||||||
|
v-if="selected.type === 1"
|
||||||
|
class="panel-body"
|
||||||
|
:category-id="selected.id"
|
||||||
|
fixed-category
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 单页型:内容摘要 + 编辑入口 -->
|
||||||
|
<div v-else-if="selected.type === 2" class="single-summary">
|
||||||
|
<p class="summary-text">{{ summaryText(selected) }}</p>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 编辑弹窗 -->
|
||||||
@@ -359,6 +450,17 @@ function onSortSaved() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 树折叠态:隐藏左树,右侧面板占满(覆盖全局 .tree-wrapper .main-container 的 264px 边距) */
|
||||||
|
.category-page.tree-collapsed {
|
||||||
|
.tree-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
margin-left: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.info-panel {
|
.info-panel {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -371,6 +473,98 @@ function onSortSaved() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 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;
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
.summary-text {
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.empty-tip {
|
.empty-tip {
|
||||||
color: #999;
|
color: #999;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user