feat(site-layer): 学校站点分层方案C落地(docs/13)+栏目管理增强+管理端三修复+首页视觉升级管理端+审计复检T1~T5(docs/15)整合批次-URL站点段/site/{siteCode}无component镜像层(去name防重名,router-view跳过推进渲染)+身份条SiteSwitcher自绘(徽章#577afb/#ff9900+分组下拉✓+path次级列+Esc/外点收起)+URL唯一事实源syncSiteFromRoute(非法/跨校/主站码当子站段告警打回同页主站,localStorage降级刷新首帧兜底)+菜单剥段高亮stripSiteSeg/withSiteSeg补段+menuRoute镜像层防重影+跳转收敛adminPathOf(工作台进入/全部站点总览/站点管理行进入管理纯URL切站);栏目管理-上级栏目可选可改(禁选自身及后代防成环)+批量添加BatchAddCategory(行序写排序号单行失败不中断)+单页wangEditor富文本预览字数分区+categoryIds契约前端/types/mock三处统一+SchoolLayout高度链.layout-wrap修复+ArticleListPanel fixed模式;管理端三修复-皮肤预览Modal全屏(ThemePreviewFrame可选fill prop)+SiteManage补themeUnregistered声明+长图文编辑器随内容增高;管理端视觉升级配套-SiteConfigForm 12新键+BannerManage gallery展示位切换+栏目homeStyle下拉+PortalPreview侧栏同步;审计复检-新增siteRouter.spec真实路由集成7用例(镜像层matched3层/守卫子站码定向主站前缀/越权回工作台/旧链带id重定向)+sitePath空白容错+siteLayer持久化round-trip与身份条次级入口(docs/15);vitest 99/99+build通过+S1~S10 GUI走查全过
build / build (push) Successful in 2m43s

This commit is contained in:
chenxing
2026-09-10 11:11:23 +08:00
parent 9ba4cb135a
commit 6ee9ab5684
26 changed files with 1931 additions and 222 deletions
+127
View File
@@ -0,0 +1,127 @@
<template>
<Modal v-model="visible" title="批量添加栏目" :width="520" :mask-closable="false">
<div class="batch-body">
<Form :label-width="90">
<Form-Item label="上级栏目:">
<span class="batch-val">{{ parentName || '根栏目' }}</span>
</Form-Item>
<Form-Item label="栏目类型:">
<RadioGroup v-model="typeValue">
<!-- 外链必须逐个填地址,不提供批量 -->
<Radio :label="1">文章列表</Radio>
<Radio :label="2">单页</Radio>
</RadioGroup>
</Form-Item>
<Form-Item label="栏目名称:">
<Input v-model="rawNames" type="textarea" :rows="8" placeholder="一行一个栏目名称,保存时按行顺序写入排序号" />
</Form-Item>
</Form>
<p class="batch-tip">将按行逐个创建栏目;某行失败不影响其余行,结束后统一提示结果。</p>
</div>
<template #footer>
<Button type="primary" :loading="loading" @click="submit">确认</Button>
<Button @click="visible = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
/* UserBase BatchAddGroup 同款交互:textarea 一行一个名称批量建组;
CMS 无后端批量接口,前端循环调 categoryApi.save(用户约定方案),按行序写 sortNo=1..n。 */
import { ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
const visible = defineModel<boolean>('visible', { default: false })
const props = defineProps<{
/** 上级栏目 id(0=根;来自当前选中节点,虚拟根 id=0 天然合法) */
defaultPid?: number | null
/** 上级栏目名称(展示用) */
parentName?: string
defaultType?: number | null
}>()
const emit = defineEmits<{ success: [lastSavedId?: number] }>()
const store = useCmsStore();
const loading = ref(false)
const rawNames = ref('')
const typeValue = ref<number>(1)
function normalizeNames() {
return rawNames.value
.split(/\r?\n/)
.map((x) => x.trim())
.filter(Boolean);
}
async function submit() {
if (!store.siteId) {
Message.warning('请先选择站点');
return;
}
const names = normalizeNames();
if (!names.length) {
Message.warning('请输入栏目名称');
return;
}
const parentId = props.defaultPid ?? 0;
loading.value = true;
const failed: string[] = [];
let lastSavedId: number | undefined;
try {
for (let i = 0; i < names.length; i++) {
const name = names[i];
try {
const saved = await categoryApi
.save({
siteId: store.siteId,
parentId,
name,
type: typeValue.value,
sortNo: i + 1,
status: 1,
})
.send();
lastSavedId = saved?.id ?? lastSavedId;
} catch (e: any) {
failed.push(name);
console.warn(`栏目「${name}」创建失败`, e?.message);
}
}
if (failed.length) {
Message.warning(`创建完成:成功 ${names.length - failed.length} 条,失败 ${failed.length} 条(${failed.join('、')})`);
} else {
Message.success(`成功创建 ${names.length} 个栏目!`);
}
emit('success', lastSavedId);
visible.value = false;
} finally {
loading.value = false;
}
}
watch(visible, (v) => {
if (v) {
rawNames.value = '';
typeValue.value = props.defaultType === 2 ? 2 : 1;
}
})
</script>
<style lang="scss" scoped>
.batch-body {
.batch-val {
color: #333;
font-weight: 600;
}
.batch-tip {
color: #999;
font-size: 12px;
margin: 0 0 8px 12px;
}
}
</style>
+101 -25
View File
@@ -1,14 +1,20 @@
<script setup lang="ts">
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
* header 左侧仅超管可见「↩返回学校列表」图标(校级账号不显示);
* 面包屑首段=校名(落点为院系子站时后跟当前站点名),超管在校名前多一段可点「学校列表」(与返回图标同效)。
* 学校/站点切换一律离壳完成:学校在 /workbench,站点在站点管理行「进入管理」。
* 面包屑 = 学校列表(超管)> 校名 > 站点身份条 > 页面。
* 站点分层(docs/13 方案 C):
* URL 为站点上下文唯一事实源——主站 /sch/{code}/{page},子站 /sch/{code}/site/{siteCode}/{page};
* 站点身份条(SiteSwitcher)一步切换并停留当前功能页,上下文由 syncSiteFromRoute 同步;
* 菜单 page 保持主站形态,高亮剥段匹配、跳转按当前站点补段。
* 学校切换仍在壳外完成:/workbench 工作台选校。
* 菜单/高亮/子页面面包屑逻辑与原 layout 一致,布局数据来自 store.session。 */
import { ref, computed, watch, onBeforeMount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { resolveMenuPage } from "@/utils/menuRoute";
import { useCmsStore } from "@/store";
import { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
import SiteSwitcher from "@/components/SiteSwitcher.vue";
import config from "@/assets/config/host.config";
const route = useRoute();
@@ -31,18 +37,21 @@ const modList = computed(() => store.session.modList || []);
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
/** 当前学校上下文(面包屑首段) */
/** 当前学校上下文(面包屑校名段) */
const school = computed(() => store.currentSchool);
const isSuper = computed(() => store.isSuperAdmin);
/** 面包屑校名后缀:操作落点非主站时跟随展示当前站点名(院系子站管理可见性,
* 站点切换入口在站点管理行「进入管理」,壳上不再放切换器) */
const currentSiteSuffix = computed(() => {
const s = store.currentSite;
const sc = school.value;
if (!s || !sc || s.id === sc.mainSiteId) return "";
return s.name || "";
});
/** 当前落点是否院系子站(store 契约):决定身份条徽章与底边色带 */
const isSubSite = computed(() => store.isSubSite);
const pathNavClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
/** 菜单/面包屑 page(主站形态完整路径)→ 当前站点形态:子站插入 /site/{code} 段(docs/13 §4)。
* 非 /sch/ 前缀(外链模块)原样返回,防拼坏 */
const withSiteSeg = (page: string): string => {
const code = school.value?.mainSiteCode;
if (!page || !code || !page.startsWith("/sch/")) return page;
return adminPathOf(code, store.siteSegCode, schoolSubPath(page));
};
const flaten = (tree: any[], pid = 0): any[] => {
const arr: any[] = [];
@@ -70,6 +79,8 @@ const routeChange = (to: any) => {
const meta: any = to.meta || {};
const { modId, title, subOf } = meta;
const navArr: any[] = [];
// 菜单 page 为主站形态,匹配前剥掉站点中缀(docs/13 §4 剥段匹配)
const toLogic = stripSiteSeg(to.path || "");
// 侧边栏子菜单组自动展开:当前高亮落在某组子项时展开该组(改善直达体验)
const expandGroupOf = (page: string) => {
@@ -95,7 +106,7 @@ const routeChange = (to: any) => {
const gp = menuArr.value.find((x: any) => x.id === parent.pid);
if (gp) navArr.push({ name: gp.name });
}
navArr.push({ name: parent.name, page: parent.page });
navArr.push({ name: parent.name, page: withSiteSeg(parent.page) });
navArr.push({ name: title });
navPath.value = navArr;
return;
@@ -105,7 +116,7 @@ const routeChange = (to: any) => {
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
// 匹配不到再退回 modId 对照
const find: any =
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
menuArr.value.find((x: any) => x.page && x.page === toLogic) ||
menuArr.value.find((x: any) => x.modId === modId);
if (find) {
currPath.value = find.page || "";
@@ -114,7 +125,7 @@ const routeChange = (to: any) => {
const pfind = menuArr.value.find((x: any) => x.id === find.pid);
if (pfind) navArr.push({ name: pfind.name });
}
navArr.push({ name: find.name || title, page: find.page });
navArr.push({ name: find.name || title, page: withSiteSeg(find.page) });
navPath.value = navArr;
return;
}
@@ -122,8 +133,8 @@ const routeChange = (to: any) => {
if (modId != null) {
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
if (r) {
currPath.value = r.path;
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
currPath.value = stripSiteSeg(r.path);
navPath.value = [{ name: (r.meta as any).title, page: withSiteSeg(r.path) }];
return;
}
}
@@ -144,10 +155,12 @@ const linkMenu = (item: any) => {
window.location.href = page;
return;
}
if (page === route.path) {
// 子站上下文下菜单目标补站点段(主站形态 → 当前站点形态)
const target = withSiteSeg(page);
if (target === route.path) {
router.go(0);
} else {
router.push(page);
router.push(target);
}
};
@@ -192,7 +205,34 @@ function goWorkbench() {
router.push("/workbench");
}
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文 */
/**
* URL 站点段 → store 同步(docs/13 §3:URL 是站点上下文唯一事实源)。
* 无 siteCode = 主站形态;有则按 code 匹配本校站点(parentSiteId 归属校验),
* 非法/跨校 siteCode 打回同页主站形态并告警。
* 启动引导期(user 未装载 → schoolSites 为空)跳过,由 user 就绪后的 watch 兜底重跑。
*/
function syncSiteFromRoute() {
const sc = school.value;
if (!sc || !store.user) return;
const sites = (store.schoolSites || []) as any[];
if (!sites.length) return;
const { site: target, invalid } = resolveRouteSite(sites, sc.mainSiteId, route.params.siteCode as string);
if (invalid) {
Message.warning("站点不存在或无权限,已返回主站");
router.replace({ path: stripSiteSeg(route.path), query: route.query, hash: route.hash });
return;
}
if (target && store.currentSite?.id !== target.id) {
store.setCurrentSite({
id: target.id,
code: target.code,
path: target.path || target.code,
name: target.name,
});
}
}
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文,再按 URL 同步站点 */
watch(
() => store.user,
u => {
@@ -216,6 +256,8 @@ watch(
sites.filter((s: any) => s.id === main.id || s.parentSiteId === main.id)
);
}
syncSiteFromRoute();
routeChange(route);
},
{ immediate: true }
);
@@ -226,13 +268,19 @@ onBeforeMount(() => {
watch(
() => route.fullPath,
() => routeChange(route)
() => {
syncSiteFromRoute();
routeChange(route);
}
);
// 深链启动期:学校上下文/菜单晚于首次 routeChange 到达,就绪后重算面包屑与高亮
watch(
() => [store.currentSchool?.mainSiteCode, store.session.modList.length],
() => routeChange(route)
() => {
syncSiteFromRoute();
routeChange(route);
}
);
</script>
@@ -305,14 +353,18 @@ watch(
<div class="clear-box"></div>
</div>
<div class="path-nav" :style="pathNavStyle">
<div class="path-nav" :class="pathNavClass" :style="pathNavStyle">
<Breadcrumb separator=">">
<!-- 超管:校名前多一段可点「学校列表」,与 header 返回图标同效(回工作台切换学校) -->
<BreadcrumbItem v-if="isSuper" to="/workbench">学校列表</BreadcrumbItem>
<!-- 面包屑首段 = 学校上下文(常驻);落点为院系子站时跟出当前站点名 -->
<!-- 面包屑第二段 = 学校上下文(常驻,不承载站点信息) -->
<BreadcrumbItem v-if="school">
<Icon type="ios-school" />
{{ school.schName || school.mainSiteName }}<template v-if="currentSiteSuffix"> · {{ currentSiteSuffix }}</template>
{{ school.schName || school.mainSiteName }}
</BreadcrumbItem>
<!-- 站点身份条(docs/13 §1):徽章+站点名▼,下拉一步切换;不传 to(否则 mixins-link 抢走点击) -->
<BreadcrumbItem v-if="store.currentSite">
<SiteSwitcher />
</BreadcrumbItem>
<template v-for="(item, index) in navPath" :key="index">
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
@@ -373,11 +425,35 @@ watch(
font-size: 14px;
}
/* 站点色带(docs/13 §1.4):::after 挂底边——.path-nav 高度 56px 固定且未设 border-box,
直接加 border-bottom 会改盒高(W2 高度链教训),绝对定位不参与布局 */
.path-nav::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
}
.path-nav.band-main::after {
background: #577afb;
}
.path-nav.band-sub::after {
background: #ff9900;
}
/* W2(D6):左树高度链修复——UserBase App.vue:222-224 同款覆盖式。
layout.css 全局 .main-wrapper 只有 min-height:100% 无 height,
子级 .tree-wrapper{height:100%} 因父链高度 auto 解析失败,栏目树缩在一起。
scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点(:348-349) */
scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点 */
.base-wrap-main .main-wrapper {
height: 100%;
}
/* 高度链顶层:layout.css 只定义到 .base-wrap-main{height:calc(100% - 56px)},
其百分比基于 .layout-wrap——本组件根节点,无高度定义则整条链解析失败
(UserBase 同名壳在 layout.vue 全局样式中补了这一层,此前 CMS 缺失) */
.layout-wrap {
height: 100%;
}
</style>
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useCmsStore } from "@/store";
import { adminPathOf, schoolSubPath } from "@/utils/sitePath";
/**
* 站点身份条(docs/13 方案 C):面包屑站点格内的「徽章 + 站点名 + 下拉」。
* 数据源 store.schoolSites(主站 + 本校院系子站,零后端改动);选中只改 URL,
* 站点上下文由 SchoolLayout.syncSiteFromRoute 按 URL 段同步——见 docs/13 §1.3/§3。
* 不用 iView Dropdown:分组头/徽章/✓/path 次级列的表达力不足且无可靠收起 API,自绘面板。
*/
const store = useCmsStore();
const route = useRoute();
const router = useRouter();
const open = ref(false);
const anchor = ref<HTMLElement | null>(null);
const schoolCode = computed(() => store.currentSchool?.mainSiteCode || "");
const mainSite = computed(
() => store.schoolSites.find((s: any) => s.id === store.currentSchool?.mainSiteId) || null
);
const subSites = computed(() =>
store.schoolSites.filter((s: any) => s.parentSiteId === store.currentSchool?.mainSiteId)
);
/** 当前落点是否子站:主站徽章/配色/落点判定统一走 store 契约 */
const isSub = computed(() => store.isSubSite);
const currentName = computed(() => store.currentSite?.name || "");
/** 当前功能页子路径:切换站点停留当前页(剥掉站点段与学校前缀) */
const curSub = computed(() => schoolSubPath(route.path) || "/article");
function pick(site: any) {
open.value = false;
if (!site) return;
const isMain = site.id === store.currentSchool?.mainSiteId;
const target = adminPathOf(schoolCode.value, isMain ? null : site.code, curSub.value);
if (target === route.path) return;
router.push(target);
}
/** 底部入口:管理站点集合(带段=保持当前站点上下文,语义与菜单「站点管理」一致) */
function gotoManage() {
open.value = false;
router.push(adminPathOf(schoolCode.value, store.siteSegCode, "site"));
}
function onDocClick(e: MouseEvent) {
if (!open.value) return;
if (anchor.value && !anchor.value.contains(e.target as Node)) {
open.value = false;
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") open.value = false;
}
onMounted(() => {
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKey);
});
</script>
<template>
<span ref="anchor" class="site-identity" :class="{ sub: isSub }">
<span class="bdg" :class="isSub ? 'bdg-sub' : 'bdg-main'" @click.stop="open = !open">
{{ isSub ? "院系子站" : "校主站" }}
</span>
<b class="site-name" @click.stop="open = !open">{{ currentName }}</b>
<Icon type="ios-arrow-down" class="caret" @click.stop="open = !open" />
<div v-if="open" class="site-switcher-dd" @click.stop>
<div class="dd-cap">切换管理站点(当前:<b>{{ currentName }}</b>)</div>
<div class="dd-group">
<div v-if="mainSite" class="dd-item" :class="{ active: !isSub }" @click="pick(mainSite)">
<span class="check">{{ !isSub ? "✓" : "" }}</span>
<span>{{ mainSite.name }}</span>
<span class="bdg bdg-main">主站</span>
<span class="code">{{ mainSite.path || mainSite.code }}</span>
</div>
</div>
<div class="dd-group">
<div class="dd-cap sub-cap">院系子站({{ subSites.length }})</div>
<div v-if="!subSites.length" class="dd-empty">本校暂无院系子站(可在「站点管理」新建)</div>
<div
v-for="s in subSites"
:key="s.id"
class="dd-item"
:class="{ active: store.currentSite?.id === s.id }"
@click="pick(s)"
>
<span class="check">{{ store.currentSite?.id === s.id ? "✓" : "" }}</span>
<span>{{ s.name }}</span>
<span class="bdg bdg-sub">院系</span>
<span class="code">{{ s.path || s.code }}</span>
</div>
</div>
<div class="dd-foot" @click="gotoManage">前往「站点管理」维护站点 →</div>
</div>
</span>
</template>
<style scoped lang="scss">
/* 徽章:实心色块对齐已验收原型(bdg-main #577afb / bdg-sub #ff9900);
iView Tag 预设为浅底描边样式,视觉与原型不一致,故自绘 */
.bdg {
font-size: 10px;
border-radius: 3px;
padding: 1px 5px;
color: #fff;
line-height: 16px;
flex-shrink: 0;
cursor: pointer;
}
.bdg-main {
background: #577afb;
}
.bdg-sub {
background: #ff9900;
}
.site-identity {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 3px 8px;
border-radius: 4px;
position: relative;
&:hover {
background: #f5f7fa;
}
.site-name {
color: #4465d4;
font-weight: bold;
}
.caret {
font-size: 10px;
color: #c0c4cc;
}
&.sub {
background: #fff7ec;
&:hover {
background: #ffefd6;
}
.site-name {
color: #d48806;
}
}
}
.site-switcher-dd {
position: absolute;
top: calc(100% + 12px);
left: -8px;
min-width: 300px;
background: #fff;
color: #272829;
border-radius: 8px;
box-shadow: 0 8px 28px rgba(31, 42, 58, 0.2);
overflow: hidden;
z-index: 10;
text-align: left;
animation: dd-in 0.15s ease-out;
cursor: default;
.dd-cap {
padding: 10px 14px 4px;
font-size: 12px;
color: #999;
}
.sub-cap {
padding: 2px 14px;
}
.dd-group {
padding: 6px 0;
& + .dd-group {
border-top: 1px solid #f0f2f5;
}
}
.dd-empty {
padding: 6px 14px 8px;
font-size: 12px;
color: #c0c4cc;
}
.dd-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
font-size: 13px;
cursor: pointer;
&:hover {
background: #eff4ff;
}
&.active {
background: #eff4ff;
color: #4465d4;
font-weight: bold;
}
.check {
color: #577afb;
font-weight: bold;
width: 11px;
}
.code {
margin-left: auto;
color: #c0c4cc;
font-size: 11px;
}
}
.dd-foot {
border-top: 1px solid #f0f2f5;
padding: 9px 14px;
font-size: 12px;
color: #577afb;
cursor: pointer;
background: #fafbfc;
&:hover {
background: #f0f3ff;
}
}
}
@keyframes dd-in {
from {
transform: translateY(-6px);
}
to {
transform: none;
}
}
</style>
+16 -4
View File
@@ -7,6 +7,7 @@ import MyConfirm from "@/components/MyConfirm";
import { articleApi, categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
import { adminPathOf } from "@/utils/sitePath";
/**
* 文章列表面板(B5 栏目工作台组件化,自 ArticleList.vue 原样抽出):
@@ -17,6 +18,8 @@ import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
const props = defineProps<{
categoryId?: number;
fixedCategory?: boolean;
/** 表格高度额外扣减(固定栏目模式嵌入工作台时扣面板头高度) */
extraHeight?: number;
}>();
const router = useRouter();
@@ -45,7 +48,12 @@ const statusDict = [
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 tableHeight = Utils.useTableHeight({
isSearch: true,
isOperat: !props.fixedCategory,
isPage: true,
otherH: props.extraHeight ?? 0,
});
const columns = [
{
@@ -137,7 +145,9 @@ async function run() {
const page = await articleApi
.page({
siteId: store.siteId,
categoryId: props.fixedCategory ? props.categoryId : search.categoryId,
// 后端 ArticlePageQuery 字段为 categoryIds(List<Integer>),传 categoryId 会绑定不上、
// 退化成全站检索(栏目工作台"切节点表格不变"的根因)
categoryIds: props.fixedCategory ? props.categoryId : search.categoryId,
status: search.status,
keyword: search.keyword || undefined,
page: pageNum.value,
@@ -166,7 +176,8 @@ function goEdit(id?: number) {
return;
}
const query = props.fixedCategory && props.categoryId ? `?categoryId=${props.categoryId}` : "";
router.push(id ? `/sch/${code}/article/edit/${id}${query}` : `/sch/${code}/article/edit${query}`);
// 站点段跟随当前上下文(docs/13):子站编辑页 URL 带 /site/{code}
router.push(adminPathOf(code, store.siteSegCode, id ? `article/edit/${id}` : "article/edit") + query);
}
async function doPublish(row: ArticlePageItem) {
@@ -222,7 +233,8 @@ watch(() => store.siteId, async () => {
<template>
<div class="article-list-panel">
<div class="fix mb-12">
<!-- 固定栏目模式:新建入口由工作台面板头承担,避免双按钮重复 -->
<div v-if="!fixedCategory" class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="goEdit()">
<Icon type="ios-add" />
新建文章
+26 -7
View File
@@ -7,12 +7,17 @@ import { FormModelValue } from "@/utils/themeParamForm";
* 主题预览框(方案 §5.5/§6.3):iframe 加载 /api/theme/preview(同源 CAS 会话,
* no-store),参数变更由父级 debounce 后更新 src。内容源站点不存在时提示。
*/
const props = defineProps<{
/** 预览内容源站点门户路径(W4:sitePath 语义,取该站栏目/文章/轮播渲染) */
sitePath?: string;
theme?: string;
themeParams?: FormModelValue;
}>();
const props = withDefaults(
defineProps<{
/** 预览内容源站点门户路径(W4:sitePath 语义,取该站栏目/文章/轮播渲染) */
sitePath?: string;
theme?: string;
themeParams?: FormModelValue;
/** 填满父容器高度(皮肤包管理全屏预览弹窗);默认固定高,向导/编辑弹窗内不变 */
fill?: boolean;
}>(),
{ fill: false }
);
const src = computed(() => {
if (!props.sitePath || !props.theme) return "";
@@ -21,7 +26,7 @@ const src = computed(() => {
</script>
<template>
<div class="theme-preview">
<div class="theme-preview" :class="{ 'theme-preview--fill': fill }">
<template v-if="!sitePath || !theme">
<div class="preview-empty">
无可用内容源站点 —— 预览需要借用一个已有站点的内容
@@ -66,4 +71,18 @@ const src = computed(() => {
font-size: 12px;
}
}
.theme-preview--fill {
height: 100%;
display: flex;
flex-direction: column;
.preview-frame {
height: auto;
flex: 1;
min-height: 420px;
}
.preview-empty {
height: auto;
flex: 1;
}
}
</style>