feat(W4): 站点path全链路-portalUrlOf/previewPortalUrl纯函数,站点列/向导地址预览/预览frame sitePath,store携带path,mock嵌套路由,T-W4-16..18 绿51/51
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* W4 向导地址预览联动(方案 §8.3 T-W4-18):
|
||||
* 第 3 步「门户地址预览」随上级与编码输入联动(校级 lockParent 模式预置上级=选上级)。
|
||||
* 挂载 SiteWizard(Modal transfer 到 body),桩掉主题三件套与配置表单。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
const themesMock = vi.hoisted(() => ({
|
||||
list: vi.fn(async () => [
|
||||
{
|
||||
name: "default",
|
||||
title: "云端默认",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
themeApi: { list: () => ({ send: themesMock.list }) },
|
||||
siteApi: { save: () => ({ send: async () => ({}) }) },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePicker.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["themes", "modelValue"],
|
||||
emits: ["update:modelValue", "pick"],
|
||||
setup(_, { emit }) {
|
||||
(globalThis as any).__wizardPickTheme = (name: string) => emit("pick", name);
|
||||
return () => h("div", { class: "theme-picker-stub" }, "themes");
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
|
||||
default: defineComponent({ props: ["modelValue"], emits: ["update:modelValue"], render: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["url", "tick"], render: () => h("div", "preview-stub") }),
|
||||
}));
|
||||
|
||||
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["modelValue"],
|
||||
methods: { validate: async () => true },
|
||||
render: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import SiteWizard from "@/views/cms/SiteWizard.vue";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
async function flush(frames = 6) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function mountWizard(lockParentId?: number, secondMain = false) {
|
||||
const sites = [
|
||||
{ id: 1, code: "sch-3", path: "sch-3", parentSiteId: 0, schId: 1, schName: "第一中学", name: "第一中学门户", status: 1 },
|
||||
...(secondMain
|
||||
? [{ id: 2, code: "sch-8", path: "sch-8", parentSiteId: 0, schId: 2, schName: "第八中学", name: "第八中学门户", status: 1 }]
|
||||
: []),
|
||||
];
|
||||
const visible = ref(false); // 挂载后翻转以触发 SiteWizard 的 visible watch(真实打开路径)
|
||||
const pinia = createPinia();
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(SiteWizard, {
|
||||
visible: visible.value,
|
||||
sites,
|
||||
lockParentId,
|
||||
"onUpdate:visible": (v: boolean) => { visible.value = v; },
|
||||
});
|
||||
},
|
||||
}));
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
// SiteWizard 第 2 步的 FormItem 不在 Form 内(现状如此),jsdom 卸载时 view-ui-plus
|
||||
// 内部 removeField/colon 读取 undefined 崩溃 → 本测试用展示桩覆盖 FormItem
|
||||
app.component("FormItem", defineComponent({
|
||||
props: ["label", "prop", "labelWidth"],
|
||||
setup(props, { slots }) {
|
||||
return () => h("div", { class: "form-item-stub" }, [
|
||||
props.label ? h("label", props.label) : null,
|
||||
slots.default?.(),
|
||||
]);
|
||||
},
|
||||
}));
|
||||
app.mount(el);
|
||||
visible.value = true;
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
async function gotoStep3() {
|
||||
// step1 → step2 → step3(footer 按钮 transfer 到 body)
|
||||
const clickNext = async () => {
|
||||
await flush();
|
||||
const btn = [...document.querySelectorAll(".ivu-modal-footer button")]
|
||||
.find(b => b.textContent?.includes("下一步"));
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
};
|
||||
await clickNext();
|
||||
await clickNext();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteWizard 门户地址预览(T-W4-18)", () => {
|
||||
it("选上级(lockParent 预置 sch-3)+ 输入 jdxy → 预览 /portal/sch-3/jdxy/", async () => {
|
||||
mountWizard(1);
|
||||
await gotoStep3();
|
||||
|
||||
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, "jdxy");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain("门户地址预览");
|
||||
expect(document.body.textContent).toContain("/portal/sch-3/jdxy/");
|
||||
});
|
||||
|
||||
it("改上级即变:换到另一主站 sch-8 → 预览 /portal/sch-8/jdxy/", async () => {
|
||||
mountWizard(2, true);
|
||||
await gotoStep3();
|
||||
|
||||
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, "jdxy");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain("/portal/sch-8/jdxy/");
|
||||
expect(document.body.textContent).not.toContain("/portal/sch-3/jdxy/");
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ vi.mock("@/utils/alova/alova-iview", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { articleApi, assetUrl } from "./cms";
|
||||
import { articleApi, assetUrl, themePreviewUrl } from "./cms";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
|
||||
const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" };
|
||||
@@ -99,3 +99,13 @@ describe("assetUrl 站内资产路径补 serviceHost 前缀(线上主题截图
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("themePreviewUrl 参数语义(T-W4-17:W4 起 sitePath)", () => {
|
||||
it("query 携带 sitePath(嵌套路径 URL 编码)与 p_ 前缀参数", () => {
|
||||
const url = themePreviewUrl("sch-3/jdxy", "t-demo-red", { primary: "#b01a2e" });
|
||||
expect(url).toContain("sitePath=sch-3%2Fjdxy");
|
||||
expect(url).toContain("theme=t-demo-red");
|
||||
expect(url).toContain("p_primary=%23b01a2e");
|
||||
expect(url).not.toContain("siteCode=");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,9 +214,10 @@ class ThemeApi {
|
||||
}
|
||||
}
|
||||
|
||||
/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀) */
|
||||
export function themePreviewUrl(siteCode: string, theme: string, themeParams: Record<string, string> = {}): string {
|
||||
const params = new URLSearchParams({ siteCode, theme, page: "home" });
|
||||
/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀)。
|
||||
* W4:内容源站点参数 sitePath(嵌套路径语义,后端 siteSnapshotByPath 解析)。 */
|
||||
export function themePreviewUrl(sitePath: string, theme: string, themeParams: Record<string, string> = {}): string {
|
||||
const params = new URLSearchParams({ sitePath, theme, page: "home" });
|
||||
Object.keys(themeParams).forEach(k => params.set(`p_${k}`, themeParams[k]));
|
||||
return `${conf.isMock ? "" : conf.serviceHost}/api/theme/preview?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -8,21 +8,21 @@ import { FormModelValue } from "@/utils/themeParamForm";
|
||||
* no-store),参数变更由父级 debounce 后更新 src。内容源站点不存在时提示。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 预览内容源站点编码(取该站栏目/文章/轮播渲染) */
|
||||
siteCode?: string;
|
||||
/** 预览内容源站点门户路径(W4:sitePath 语义,取该站栏目/文章/轮播渲染) */
|
||||
sitePath?: string;
|
||||
theme?: string;
|
||||
themeParams?: FormModelValue;
|
||||
}>();
|
||||
|
||||
const src = computed(() => {
|
||||
if (!props.siteCode || !props.theme) return "";
|
||||
return themePreviewUrl(props.siteCode, props.theme, props.themeParams || {});
|
||||
if (!props.sitePath || !props.theme) return "";
|
||||
return themePreviewUrl(props.sitePath, props.theme, props.themeParams || {});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-preview">
|
||||
<template v-if="!siteCode || !theme">
|
||||
<template v-if="!sitePath || !theme">
|
||||
<div class="preview-empty">
|
||||
无可用内容源站点 —— 预览需要借用一个已有站点的内容
|
||||
<p class="hint">请先创建/启用一个站点(或初始化演示数据)后再预览</p>
|
||||
|
||||
+2
-2
@@ -85,7 +85,7 @@ export const useCmsStore = defineStore("cmsStore", {
|
||||
return this.currentSite;
|
||||
},
|
||||
/** 进入学校上下文:校卡 + 默认站点(记住的站点仍属本校则沿用,否则主站) */
|
||||
enterSchool(school: CurrentSchool, sites: { id: number; code: string; name: string; parentSiteId?: number }[]) {
|
||||
enterSchool(school: CurrentSchool, sites: { id: number; code: string; path?: string; name: string; parentSiteId?: number }[]) {
|
||||
this.setCurrentSchool(school);
|
||||
const remembered = this.currentSite;
|
||||
const belongs = remembered && sites.some(s => s.id === remembered.id);
|
||||
@@ -94,7 +94,7 @@ export const useCmsStore = defineStore("cmsStore", {
|
||||
}
|
||||
const main = sites.find(s => s.id === school.mainSiteId) || sites[0];
|
||||
if (main) {
|
||||
this.setCurrentSite({ id: main.id, code: main.code, name: main.name });
|
||||
this.setCurrentSite({ id: main.id, code: main.code, path: main.path || main.code, name: main.name });
|
||||
} else {
|
||||
// 该校无可管理站点:清掉他校残留,避免页面串数据
|
||||
this.setCurrentSite(null);
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface AuthUserInfo {
|
||||
export interface SiteVo {
|
||||
id: number;
|
||||
code: string;
|
||||
/** 门户路径(W4:顶层=code;子站=父path/code,/portal/{path}/ 唯一标识) */
|
||||
path?: string;
|
||||
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id) */
|
||||
parentSiteId?: number;
|
||||
/** 上级站点名称(列表展示用,服务端装配) */
|
||||
@@ -44,6 +46,8 @@ export interface SiteVo {
|
||||
export interface CurrentSite {
|
||||
id: number;
|
||||
code: string;
|
||||
/** 门户路径(W4:嵌套 path) */
|
||||
path?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -53,6 +57,8 @@ export interface CurrentSchool {
|
||||
schName?: string;
|
||||
mainSiteId: number;
|
||||
mainSiteCode: string;
|
||||
/** 主站门户路径(W4:/portal/{mainSitePath}/) */
|
||||
mainSitePath?: string;
|
||||
mainSiteName: string;
|
||||
}
|
||||
|
||||
@@ -62,6 +68,8 @@ export interface SchoolCard {
|
||||
schName?: string;
|
||||
mainSiteId: number;
|
||||
mainSiteCode: string;
|
||||
/** 主站门户路径(W4:/portal/{mainSitePath}/) */
|
||||
mainSitePath?: string;
|
||||
mainSiteName: string;
|
||||
theme?: string;
|
||||
siteCount: number;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* W4 嵌套路径:站点门户 URL 拼接纯函数(方案 §8.3 T-W4-16)。
|
||||
* portalUrlOf:站点对象 → 门户地址(path 优先,尾斜杠;path 缺失回退 code 兼容历史数据);
|
||||
* previewPortalUrl:上级 path + 编码 → 子站门户地址(parentPath 空 → 单段)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { portalUrlOf, previewPortalUrl } from "@/utils/sitePath";
|
||||
|
||||
describe("portalUrlOf(T-W4-16)", () => {
|
||||
it("path 优先:嵌套 path 拼单段门户地址 + 尾斜杠", () => {
|
||||
expect(portalUrlOf({ path: "sch-3/jdxy", code: "jdxy" })).toBe("/portal/sch-3/jdxy/");
|
||||
});
|
||||
|
||||
it("path 缺失回退 code(历史数据兼容)", () => {
|
||||
expect(portalUrlOf({ code: "demo" })).toBe("/portal/demo/");
|
||||
expect(portalUrlOf({ path: "", code: "demo" })).toBe("/portal/demo/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewPortalUrl(T-W4-16)", () => {
|
||||
it("上级 path 非空 → 两段嵌套 + 尾斜杠", () => {
|
||||
expect(previewPortalUrl("sch-3", "jdxy")).toBe("/portal/sch-3/jdxy/");
|
||||
});
|
||||
|
||||
it("上级 path 空 → 单段(顶层站)", () => {
|
||||
expect(previewPortalUrl("", "sch-9")).toBe("/portal/sch-9/");
|
||||
expect(previewPortalUrl(undefined, "sch-9")).toBe("/portal/sch-9/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* W4 嵌套路径:站点门户 URL 拼接纯函数(前后端各一份同构语义,后端 SiteService.buildPath)。
|
||||
* 规范:门户地址 = /portal/{path}/(尾斜杠);path 缺失回退 code(历史数据兼容);
|
||||
* 子站预览地址 = /portal/{parentPath}/{code}/(parentPath 空 → 单段顶层)。
|
||||
*/
|
||||
|
||||
/** 站点对象 → 门户地址(尾斜杠;path 优先,缺失回退 code) */
|
||||
export function portalUrlOf(site: { path?: string; code: string }): string {
|
||||
const seg = site.path && site.path.trim() ? site.path.trim() : site.code;
|
||||
return `/portal/${seg}/`;
|
||||
}
|
||||
|
||||
/** 上级 path + 编码 → 子站门户地址预览(parentPath 空 → 单段顶层站) */
|
||||
export function previewPortalUrl(parentPath: string | undefined | null, code: string): string {
|
||||
const parent = parentPath && parentPath.trim() ? parentPath.trim() : "";
|
||||
return parent ? `/portal/${parent}/${code}/` : `/portal/${code}/`;
|
||||
}
|
||||
@@ -4,9 +4,10 @@ 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, portalBase, uploadUrl } from "@/api/module/cms";
|
||||
import { articleApi, categoryApi, uploadUrl } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -205,7 +206,7 @@ async function saveAndPublish() {
|
||||
/** 预览:仅已发布文章在门户可见 */
|
||||
function preview() {
|
||||
if (!editId.value || !store.currentSite) return;
|
||||
window.open(`${portalBase(store.currentSite.code)}/a/${editId.value}`, "_blank");
|
||||
window.open(`${config.serviceHost}${portalUrlOf(store.currentSite)}a/${editId.value}`, "_blank");
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
|
||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除) */
|
||||
const store = useCmsStore();
|
||||
@@ -216,14 +217,13 @@ function goNewArticle(categoryId: number) {
|
||||
window.location.hash = `#/sch/${store.schoolCode}/article/edit?categoryId=${categoryId}`;
|
||||
}
|
||||
|
||||
/** 门户预览:新开 /portal/{siteCode}/c/{id} */
|
||||
/** 门户预览:新开 /portal/{path}/c/{id}(W4 嵌套路径) */
|
||||
function openPreview(node: any) {
|
||||
const code = store.currentSite?.code || store.schoolCode;
|
||||
if (!code) {
|
||||
if (!store.currentSite) {
|
||||
Message.warning("当前无站点上下文,无法预览");
|
||||
return;
|
||||
}
|
||||
window.open(`${config.serviceHost}/portal/${code}/c/${node.id}`, "_blank");
|
||||
window.open(`${config.serviceHost}${portalUrlOf(store.currentSite)}c/${node.id}`, "_blank");
|
||||
}
|
||||
|
||||
/** 外链栏目直达 */
|
||||
|
||||
@@ -4,6 +4,7 @@ import { categoryApi, articleApi, bannerApi, linkApi, siteApi } from "@/api/modu
|
||||
import { useCmsStore } from "@/store";
|
||||
import { CategoryTreeNode, ArticlePageItem, BannerVo, LinkVo, SiteVo } from "@/types/cms";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
|
||||
/**
|
||||
* 门户预览(SPA 内简版门户):mock 模式下「预览门户」跳转到这里,
|
||||
@@ -36,9 +37,9 @@ const colleges = computed(() => {
|
||||
return sites.value.filter(s => s.parentSiteId === anchor && s.status === 1);
|
||||
});
|
||||
|
||||
/** 切换到该院系的真实门户(mock 模式由 mock 中间件承接,真实模式为 SSR 门户) */
|
||||
function openCollege(code: string) {
|
||||
window.open(`${config.serviceHost}/portal/${code}/`, "_blank");
|
||||
/** 切换到该院系的真实门户(mock 模式由 mock 中间件承接,真实模式为 SSR 门户;W4 嵌套 path) */
|
||||
function openCollege(site: SiteVo) {
|
||||
window.open(`${config.serviceHost}${portalUrlOf(site)}`, "_blank");
|
||||
}
|
||||
const linkGroups = computed(() => {
|
||||
const map = new Map<string, LinkVo[]>();
|
||||
@@ -129,7 +130,7 @@ onBeforeUnmount(stopCarousel);
|
||||
<span class="colleges-toggle">专业院系 <i>▾</i></span>
|
||||
<ul class="colleges-menu">
|
||||
<li v-for="c in colleges" :key="c.id" :class="{ on: c.id === store.siteId }">
|
||||
<a href="javascript:;" @click="openCollege(c.code)" :title="c.name">{{ c.name }}</a>
|
||||
<a href="javascript:;" @click="openCollege(c)" :title="c.name">{{ c.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { schoolApi, siteApi, themeApi, portalBase } from "@/api/module/cms";
|
||||
import { schoolApi, siteApi, themeApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,7 @@ function enter(card: SchoolCard) {
|
||||
}
|
||||
|
||||
function openPortal(card: SchoolCard) {
|
||||
window.open(`${portalBase(card.mainSiteCode)}/`, "_blank");
|
||||
window.open(`${config.serviceHost}${portalUrlOf({ path: card.mainSitePath, code: card.mainSiteCode })}`, "_blank");
|
||||
}
|
||||
|
||||
/** 超管全局站点视图(借道当前/第一个学校上下文 + scope=all) */
|
||||
|
||||
@@ -3,7 +3,8 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import { assetUrl, siteApi, themeApi, themePreviewUrl } from "@/api/module/cms";
|
||||
import { assetUrl, siteApi, themeApi } from "@/api/module/cms";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
@@ -84,7 +85,7 @@ const columns = [
|
||||
]);
|
||||
},
|
||||
},
|
||||
{ title: "门户路径", key: "portalPath", minWidth: 200, tooltip: true, render: (h: any, p: any) => h("span", `/portal/${p.row.code}/`) },
|
||||
{ title: "门户路径", key: "portalPath", minWidth: 200, tooltip: true, render: (h: any, p: any) => h("span", portalUrlOf(p.row)) },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
@@ -175,6 +176,8 @@ async function loadThemes() {
|
||||
}
|
||||
}
|
||||
|
||||
const editSitePath = ref<string>("");
|
||||
|
||||
function openWizard() {
|
||||
wizardVisible.value = true;
|
||||
}
|
||||
@@ -184,6 +187,7 @@ function onWizardCreated() {
|
||||
}
|
||||
|
||||
function openEdit(row: SiteVo) {
|
||||
editSitePath.value = row.path || row.code;
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
@@ -264,7 +268,7 @@ async function save() {
|
||||
}
|
||||
|
||||
function openPortal(row: SiteVo) {
|
||||
window.open(`${config.serviceHost}/portal/${row.code}/`, "_blank");
|
||||
window.open(`${config.serviceHost}${portalUrlOf(row)}`, "_blank");
|
||||
}
|
||||
|
||||
/** 行级「进入管理」(docs/09 §3.3 子站管理入口):切当前操作站点后落文章列表;
|
||||
@@ -287,7 +291,7 @@ function enterManage(row: SiteVo) {
|
||||
rows.value.filter(s => s.id === main.id || s.parentSiteId === main.id)
|
||||
);
|
||||
}
|
||||
store.setCurrentSite({ id: row.id, code: row.code, name: row.name });
|
||||
store.setCurrentSite({ id: row.id, code: row.code, path: row.path || row.code, name: row.name });
|
||||
router.push(`/sch/${main.code}/article`);
|
||||
}
|
||||
|
||||
@@ -387,7 +391,7 @@ watch(() => store.currentSchool?.schId, () => run());
|
||||
|
||||
<!-- 编辑预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
|
||||
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
|
||||
<ThemePreviewFrame v-if="previewVisible && editTheme" :site-code="form.code" :theme="form.theme" :theme-params="editThemeParams" />
|
||||
<ThemePreviewFrame v-if="previewVisible && editTheme" :site-path="editSitePath" :theme="form.theme" :theme-params="editThemeParams" />
|
||||
</Modal>
|
||||
|
||||
<!-- 新建站点三步向导(B1:校级锁定为本校主站下建院系子站) -->
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Message } from "view-ui-plus";
|
||||
import { siteApi, themeApi } from "@/api/module/cms";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson } from "@/utils/themeParamForm";
|
||||
import { previewPortalUrl } from "@/utils/sitePath";
|
||||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||||
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
|
||||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||
@@ -42,13 +43,13 @@ const form = reactive({
|
||||
|
||||
const themeName = ref("default");
|
||||
const themeParams = ref<FormModelValue>({});
|
||||
const sourceSiteCode = ref<string>("");
|
||||
const sourceSitePath = ref<string>("");
|
||||
const saving = ref(false);
|
||||
|
||||
/** 内容源候选:启用中的站点(顶层/子站皆可作内容源) */
|
||||
const sourceSites = computed(() => props.sites.filter(s => s.status === 1));
|
||||
const selectedTheme = computed(() => themes.value.find(t => t.name === themeName.value));
|
||||
const canPreview = computed(() => !!sourceSiteCode.value && !!themeName.value);
|
||||
const canPreview = computed(() => !!sourceSitePath.value && !!themeName.value);
|
||||
|
||||
const previewTick = ref(0);
|
||||
const previewParams = ref<FormModelValue>({});
|
||||
@@ -60,6 +61,14 @@ const parentOptions = computed(() =>
|
||||
: props.sites.filter(s => !s.parentSiteId)
|
||||
);
|
||||
|
||||
/** W4(G5):门户地址预览——随上级与编码输入联动(子站=上级地址/编码;顶层=编码) */
|
||||
const wizardParent = computed(() =>
|
||||
form.parentSiteId ? props.sites.find(s => s.id === form.parentSiteId) : undefined
|
||||
);
|
||||
const previewUrl = computed(() =>
|
||||
previewPortalUrl(wizardParent.value?.path ?? wizardParent.value?.code, form.code)
|
||||
);
|
||||
|
||||
function debouncePreview() {
|
||||
previewParams.value = { ...themeParams.value };
|
||||
previewTick.value = Date.now();
|
||||
@@ -79,7 +88,7 @@ watch(
|
||||
if (!v) return;
|
||||
step.value = 1;
|
||||
Object.assign(form, { code: "", parentSiteId: props.lockParentId, schId: undefined, schName: "", name: "", status: 1, configJson: "" });
|
||||
sourceSiteCode.value = sourceSites.value[0]?.code || "";
|
||||
sourceSitePath.value = sourceSites.value[0]?.path || sourceSites.value[0]?.code || "";
|
||||
if (!themes.value.length) await loadThemes();
|
||||
themeName.value = "default";
|
||||
const t = selectedTheme.value;
|
||||
@@ -176,7 +185,7 @@ async function createSite() {
|
||||
<div class="left">
|
||||
<FormItem label="预览内容源" :label-width="90">
|
||||
<Select v-model="sourceSiteCode" style="width: 260px">
|
||||
<Option v-for="s in sourceSites" :key="s.id" :value="s.code">{{ s.name }}({{ s.code }})</Option>
|
||||
<Option v-for="s in sourceSites" :key="s.id" :value="s.path || s.code">{{ s.name }}({{ s.path || s.code }})</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<ThemeParamForm :theme="selectedTheme" v-model="themeParams" />
|
||||
@@ -184,7 +193,7 @@ async function createSite() {
|
||||
<div class="right">
|
||||
<ThemePreviewFrame
|
||||
v-if="canPreview"
|
||||
:site-code="sourceSiteCode"
|
||||
:site-path="sourceSitePath"
|
||||
:theme="themeName"
|
||||
:key="previewTick"
|
||||
:theme-params="previewParams"
|
||||
@@ -207,6 +216,10 @@ async function createSite() {
|
||||
<FormItem label="站点名称" prop="name">
|
||||
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" style="width: 320px" />
|
||||
</FormItem>
|
||||
<FormItem label="门户地址预览">
|
||||
<span class="portal-preview-url">{{ previewUrl || "输入编码后自动生成" }}</span>
|
||||
<div class="pbu-tip">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达。</div>
|
||||
</FormItem>
|
||||
<FormItem label="上级站点">
|
||||
<Select v-model="form.parentSiteId" :disabled="!!props.lockParentId" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
|
||||
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
|
||||
@@ -288,6 +301,11 @@ async function createSite() {
|
||||
.info-form {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.portal-preview-url {
|
||||
color: #2d8cf0;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
.cfg-form {
|
||||
margin-top: 12px;
|
||||
padding-top: 4px;
|
||||
|
||||
Reference in New Issue
Block a user