feat(W4): 站点path全链路-portalUrlOf/previewPortalUrl纯函数,站点列/向导地址预览/预览frame sitePath,store携带path,mock嵌套路由,T-W4-16..18 绿51/51

This commit is contained in:
chenxing
2026-09-08 15:14:10 +08:00
parent 7b99ec398d
commit dedd4a6a3a
16 changed files with 297 additions and 37 deletions
+160
View File
@@ -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/");
});
});
+11 -1
View File
@@ -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=");
});
});
+4 -3
View File
@@ -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()}`;
}
+5 -5
View File
@@ -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
View File
@@ -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);
+8
View File
@@ -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;
+29
View File
@@ -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/");
});
});
+17
View File
@@ -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}/`;
}
+3 -2
View File
@@ -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);
+4 -4
View File
@@ -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");
}
/** 外链栏目直达 */
+5 -4
View File
@@ -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>
+3 -2
View File
@@ -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) */
+9 -5
View File
@@ -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:校级锁定为本校主站下建院系子站) -->
+23 -5
View File
@@ -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;