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
build / build (push) Successful in 2m43s
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
新建文章
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user