[CMS-VISUAL] 站点管理改版: 表格→站点工作台(主站宽卡+院系子站卡片网格)+ 页头承载站点身份 + v3.1 视觉修订(182 测试绿)
build / build (push) Successful in 2m20s
build / build (push) Successful in 2m20s
- SiteManage.vue: 8 列表格改为站点工作台,按 schId 分组「主站宽卡 + 院系子站网格」, 保留卡片⇄表格视图切换与搜索过滤;scope=all 全局视图支持多校分组,搜索时主站卡始终保留(层级锚点) - 新增 views/cms/components/SiteCard.vue、SiteMainCard.vue - 删除 components/SiteContextCard.vue: 站点身份改由页头「学校名.站点名」承担(SchoolLayout), 侧栏不再铺第二份站点列表,切站入口从 3 处收敛到 2 处(面包屑 + 站点工作台) - v3.1 视觉修订: 色带语义统一为「当前站」(全页同时只有一张卡带选中态,不再一页两处蓝); 删除面包屑 band-sub 橙色接缝条(身份已收回页头);返回按钮 ios-undo 18×18 → ios-arrow-back 26×26 可点方块; 删除 SiteManage .zone-tip 设计说明文字(误当 UI 渲染) - 测试: siteLayer.spec.ts 站点卡 4 用例重写为页头反向/正向断言,siteManage.spec.ts 补卡片视图断言;182 用例绿 - 方案: docs/22-站点管理修订方案.md(含 §9 v3.1 视觉修订与像素对照表)
This commit is contained in:
@@ -298,78 +298,56 @@ describe("docs/13 身份条次级入口与幂等切换(docs/15 T5)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ===== v2 §C.3 方案 A(P-C1 / T-C-01~03):站点身份常驻侧栏站点卡 + 壳层染色 + 标题带站点 =====
|
/* ===== docs/22 v3:页头身份「学校名.站点名」 + 侧栏站点卡撤除 =====
|
||||||
口径:站点卡承载切换与身份,功能菜单仍只有一份(stripSiteSeg 剥段高亮是方案 A 的前置设计,不改)。 */
|
口径:页头 .school-cap(> .sc-sep + .sc-site)是唯一常驻身份点,主站不写站全名统一「主站」;
|
||||||
describe("P-C1 站点卡:身份常驻侧栏(v2 §C.3 方案 A)", () => {
|
切站入口收敛到面包屑站点身份条(T-B-* / SiteSwitcher 用例)与站点工作台卡片两处。
|
||||||
it("T-C-01 主站态渲染站点卡:站名 + 层级(学校 › 主站)+ 皮肤包名 + 展开的站点列表(当前站高亮)", async () => {
|
原 v2 的卡内切站用例(T-C-01b)随卡撤除而删除。 */
|
||||||
|
describe("P-C1 页头身份与侧栏卡撤除(docs/22 v3)", () => {
|
||||||
|
it("T-C-01 主站态:侧栏 .menu-top-box 复归空档(卡已撤),页头 = 学校名.主站", async () => {
|
||||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||||
const el = mountLayout();
|
const el = mountLayout();
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
// 站点卡挂在侧栏原空位 .menu-top-box 内(零布局改造落点,v2 §C.2)
|
// 站点卡撤除:侧栏顶部空档恢复为空 div(回到加卡之前的 layout.css 形态)
|
||||||
const card = el.querySelector(".left-menu .menu-top-box .site-context-card");
|
const topBox = el.querySelector(".left-menu .menu-top-box");
|
||||||
expect(card).toBeTruthy();
|
expect(topBox).toBeTruthy();
|
||||||
expect(card!.querySelector(".scc-name")!.textContent).toContain("云海大学门户");
|
expect(topBox!.children).toHaveLength(0);
|
||||||
expect(card!.querySelector(".scc-tier")!.textContent).toContain("云海大学");
|
|
||||||
expect(card!.querySelector(".scc-tier")!.textContent).toContain("学校主站");
|
// 页头身份:学校名.站点名 —— 主站档位统一「主站」,不写站全名
|
||||||
// 皮肤可见(§C.3 #4):数据源 user.sites[].theme(SiteVo 已带,零后端改动)
|
expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.主站");
|
||||||
expect(card!.querySelector(".scc-theme")!.textContent).toContain("scholar-blue");
|
expect(el.querySelector(".header-container .school-cap")!.textContent).toContain("云海大学");
|
||||||
// 站点列表展开常驻(非下拉):主站 1 + 院系 2 = 3 行,当前站唯一高亮
|
expect(el.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站");
|
||||||
const rows = el.querySelectorAll(".scc-site");
|
|
||||||
expect(rows).toHaveLength(3);
|
|
||||||
expect(el.querySelectorAll(".scc-site.active")).toHaveLength(1);
|
|
||||||
expect(rows[0].classList.contains("active")).toBe(true);
|
|
||||||
expect(rows[1].textContent).toContain("机电学院");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("T-C-01b 站点卡点行即切换:只改 URL、停留当前功能页(复用 SiteSwitcher 的 adminPathOf 补段语义)", async () => {
|
it("T-C-02 身份染色:子站态 .school-cap 挂 band-sub、.sc-site=站名;主站态 band-main、.sc-site=主站", async () => {
|
||||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
|
||||||
const el = mountLayout();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const jdxy = [...el.querySelectorAll(".scc-site")].find(r => r.textContent?.includes("机电学院"))!;
|
|
||||||
jdxy.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
||||||
await flush();
|
|
||||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/article");
|
|
||||||
|
|
||||||
// 点当前站行幂等:目标 === 当前路径,不产生 push
|
|
||||||
box.push.mockClear();
|
|
||||||
const main = [...el.querySelectorAll(".scc-site")].find(r => r.textContent?.includes("云海大学门户"))!;
|
|
||||||
main.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
||||||
await flush();
|
|
||||||
expect(box.push).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("T-C-02 身份染色:子站态壳层挂 band-sub(卡片 sub 态),主站态挂 band-main 且不染色", async () => {
|
|
||||||
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
||||||
const sub = mountLayout();
|
const sub = mountLayout();
|
||||||
await flush();
|
await flush();
|
||||||
|
// 侧栏/面包屑色带档位保留(identityClass 仍在 .left-menu / .path-nav 上工作)
|
||||||
expect(sub.querySelector(".left-menu")!.className).toContain("band-sub");
|
expect(sub.querySelector(".left-menu")!.className).toContain("band-sub");
|
||||||
expect(sub.querySelector(".site-context-card")!.classList.contains("sub")).toBe(true);
|
expect(sub.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(true);
|
||||||
// 皮肤随站而变(§C.3 #4):子站独立皮肤 = 最直观的"两站不同"信号
|
expect(sub.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("机电学院");
|
||||||
expect(sub.querySelector(".scc-theme")!.textContent).toContain("campus-glow");
|
|
||||||
// 皮肤缺省也要有身份:色点常驻
|
|
||||||
expect(sub.querySelector(".scc-dot")).toBeTruthy();
|
|
||||||
|
|
||||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||||
const main = mountLayout();
|
const main = mountLayout();
|
||||||
await flush();
|
await flush();
|
||||||
expect(main.querySelector(".left-menu")!.className).toContain("band-main");
|
expect(main.querySelector(".left-menu")!.className).toContain("band-main");
|
||||||
expect(main.querySelector(".site-context-card")!.classList.contains("sub")).toBe(false);
|
expect(main.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(false);
|
||||||
|
expect(main.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("T-C-03 标题带站点:页头标题与 document.title 均为「页面名 · 站点名」,切站后跟随", async () => {
|
it("T-C-03 标题带站点:页头与 document.title 均为「页面名 · 学校名.站点名」,切站后跟随", async () => {
|
||||||
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
||||||
const el = mountLayout();
|
const el = mountLayout();
|
||||||
await flush();
|
await flush();
|
||||||
expect(document.title).toBe("文章列表 · 机电学院");
|
expect(document.title).toBe("文章列表 · 云海大学.机电学院");
|
||||||
expect(el.querySelector(".header-container h4")!.textContent).toContain("· 机电学院");
|
expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.机电学院");
|
||||||
|
|
||||||
// 同一壳内切到主站形态:标题与 document.title 同步换站名
|
// 同一壳内切回主站形态:标题与 document.title 同步为「学校名.主站」
|
||||||
box.route.path = "/sch/demo/article";
|
box.route.path = "/sch/demo/article";
|
||||||
box.route.fullPath = "/sch/demo/article";
|
box.route.fullPath = "/sch/demo/article";
|
||||||
box.route.params = { schCode: "demo" };
|
box.route.params = { schCode: "demo" };
|
||||||
await flush();
|
await flush();
|
||||||
expect(document.title).toBe("文章列表 · 云海大学门户");
|
expect(document.title).toBe("文章列表 · 云海大学.主站");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
/**
|
/**
|
||||||
* 站点管理列表页回归(docs/16 主线A 整页化后):
|
* 站点管理页回归:docs/22 站点工作台(默认卡片视图)+ 表格视图保留。
|
||||||
* 行「编辑」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式;
|
* 行「站点设置」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式;
|
||||||
* 子站行带 URL 站点段(docs/13 adminPathOf 口径)。沿用手写 createApp 挂载桩风格。
|
* 子站行带 URL 站点段(docs/13 adminPathOf 口径)。沿用手写 createApp 挂载桩风格。
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -93,6 +93,13 @@ function clickRowAction(el: ParentNode, iconClass: string, rowIndex = 0) {
|
|||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** docs/22 后默认视图为卡片;表格行操作用例先切到表格视图 */
|
||||||
|
async function switchToTable(el: ParentNode) {
|
||||||
|
const btn = [...el.querySelectorAll(".seg button")].find(b => b.textContent?.includes("表格"));
|
||||||
|
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
|
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
|
||||||
@@ -105,9 +112,10 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("SiteManage 列表页(docs/16 整页化后)", () => {
|
describe("SiteManage 列表页(docs/16 整页化后)", () => {
|
||||||
it("① 行「编辑」跳站点设置中心(主站形态无站点段)", async () => {
|
it("① 行「站点设置」跳站点设置中心(主站形态无站点段)", async () => {
|
||||||
const el = mountPage();
|
const el = mountPage();
|
||||||
await flush();
|
await flush();
|
||||||
|
await switchToTable(el);
|
||||||
|
|
||||||
const btn = clickRowAction(el, "icon-edit");
|
const btn = clickRowAction(el, "icon-edit");
|
||||||
expect(btn).toBeTruthy();
|
expect(btn).toBeTruthy();
|
||||||
@@ -116,13 +124,14 @@ describe("SiteManage 列表页(docs/16 整页化后)", () => {
|
|||||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/setting/1");
|
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/setting/1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("② 子站行「编辑」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => {
|
it("② 子站行「站点设置」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => {
|
||||||
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([
|
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([
|
||||||
siteRow,
|
siteRow,
|
||||||
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机械学院" },
|
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机械学院" },
|
||||||
])));
|
])));
|
||||||
const el = mountPage();
|
const el = mountPage();
|
||||||
await flush();
|
await flush();
|
||||||
|
await switchToTable(el);
|
||||||
|
|
||||||
clickRowAction(el, "icon-edit", 1);
|
clickRowAction(el, "icon-edit", 1);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -143,3 +152,58 @@ describe("SiteManage 列表页(docs/16 整页化后)", () => {
|
|||||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/new");
|
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/new");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("站点工作台(docs/22 v3)", () => {
|
||||||
|
/** 1 主站 + 2 子站:卡片工作台的最小分组样本 */
|
||||||
|
function mockOneMainTwoSubs() {
|
||||||
|
siteListMock.mockResolvedValue(
|
||||||
|
JSON.parse(
|
||||||
|
JSON.stringify([
|
||||||
|
siteRow,
|
||||||
|
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机电学院" },
|
||||||
|
{ ...siteRow, id: 3, code: "wtxy", path: "demo/wtxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "文学院" },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("T-SM-01 默认卡片视图:主站宽卡 1 + 子站网格 2,当前站唯一高亮;表格视图可切回", async () => {
|
||||||
|
mockOneMainTwoSubs();
|
||||||
|
const el = mountPage(store => {
|
||||||
|
store.user = { superAdmin: true } as any;
|
||||||
|
store.currentSite = { id: 2, code: "jdxy", name: "机电学院" } as any;
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(el.querySelectorAll(".main-card")).toHaveLength(1);
|
||||||
|
expect(el.querySelectorAll(".site-card")).toHaveLength(2);
|
||||||
|
expect(el.querySelectorAll(".site-card.is-cur")).toHaveLength(1);
|
||||||
|
// 主站卡「子站」统计 = 过滤前真实规模(SiteVo 无栏目/文章字段,仅子站数必显)
|
||||||
|
expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2");
|
||||||
|
|
||||||
|
// 切表格视图:卡片网格消失、ivu-table 出现
|
||||||
|
await switchToTable(el);
|
||||||
|
expect(el.querySelector(".card-grid")).toBeNull();
|
||||||
|
expect(el.querySelector(".ivu-table")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T-SM-02 搜索过滤:命中子站保留、主站宽卡不消失(层级锚点),子站统计不随过滤变化", async () => {
|
||||||
|
mockOneMainTwoSubs();
|
||||||
|
const el = mountPage(store => {
|
||||||
|
store.user = { superAdmin: true } as any;
|
||||||
|
store.currentSite = { id: 1, code: "demo", name: "云海大学门户网站" } as any;
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const input = el.querySelector(".seg-search input") as HTMLInputElement;
|
||||||
|
input.value = "机电";
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(el.querySelectorAll(".main-card")).toHaveLength(1);
|
||||||
|
expect(el.querySelectorAll(".site-card")).toHaveLength(1);
|
||||||
|
expect(el.querySelector(".site-card")!.textContent).toContain("机电学院");
|
||||||
|
// rawSubs 按过滤前全量行统计:仍是 2 个子站
|
||||||
|
expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -159,11 +159,12 @@ html.side-plain{--strip-bg:linear-gradient(180deg,#f2f4f8 0,#fff 100%)}
|
|||||||
.layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)}
|
.layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)}
|
||||||
.layout-wrap .path-nav .crumb-home{color:var(--brand)}
|
.layout-wrap .path-nav .crumb-home{color:var(--brand)}
|
||||||
.layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0}
|
.layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0}
|
||||||
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变;子站让位身份橙 */
|
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变。
|
||||||
|
v3:站点身份已由页头「学校名.站点名」承担,原先 band-sub 让位身份橙的覆盖已删,
|
||||||
|
否则子站态上面包屑顶部常驻一条橙色横杠,与页头身份重复,且被误读成边框。 */
|
||||||
.layout-wrap .path-nav::before{
|
.layout-wrap .path-nav::before{
|
||||||
content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1;
|
content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1;
|
||||||
}
|
}
|
||||||
.layout-wrap .path-nav.band-sub::before{background:#ff9900}
|
|
||||||
.layout-wrap .path-nav::after{display:none}
|
.layout-wrap .path-nav::after{display:none}
|
||||||
|
|
||||||
/* ---- 内容区:灰底 + 白卡 ---- */
|
/* ---- 内容区:灰底 + 白卡 ---- */
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { resolveMenuPage } from "@/utils/menuRoute";
|
|||||||
import { useCmsStore } from "@/store";
|
import { useCmsStore } from "@/store";
|
||||||
import { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
|
import { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
|
||||||
import SiteSwitcher from "@/components/SiteSwitcher.vue";
|
import SiteSwitcher from "@/components/SiteSwitcher.vue";
|
||||||
import SiteContextCard from "@/components/SiteContextCard.vue";
|
|
||||||
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
|
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
|
||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
|
|
||||||
@@ -48,13 +47,27 @@ const isSubSite = computed(() => store.isSubSite);
|
|||||||
/** 身份档位类:同名类挂在 .left-menu 与 .path-nav 两处壳层节点,主站不染色(默认为主站、偏离才提示) */
|
/** 身份档位类:同名类挂在 .left-menu 与 .path-nav 两处壳层节点,主站不染色(默认为主站、偏离才提示) */
|
||||||
const identityClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
|
const identityClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
|
||||||
|
|
||||||
/* 标题带站点(§C.3 #3):页头标题与 document.title 都写成「页面名 · 站点名」,多标签页也能分辨 */
|
/* 页头身份(docs/22 v3):学校名.站点名 —— 一处文字同时回答「哪个学校 + 哪个站点」。
|
||||||
|
子站取站点名(云海大学.机电学院),主站固定写「主站」(云海大学.主站);
|
||||||
|
沿用既有 .shell-site 胶囊语汇(band-sub 橙 / band-main 中性白),不引新色值。 */
|
||||||
const siteName = computed(() => store.currentSite?.name || "");
|
const siteName = computed(() => store.currentSite?.name || "");
|
||||||
const shellTitle = computed(() => (siteName.value ? `${appName.value} · ${siteName.value}` : appName.value));
|
const schoolName = computed(() => school.value?.schName || school.value?.mainSiteName || "");
|
||||||
|
/** 页头站点档位文案:主站不写站全名(会很长),统一「主站」 */
|
||||||
|
const siteLabel = computed(() => (isSubSite.value ? siteName.value : "主站"));
|
||||||
|
/** 完整身份串:学校名.站点名(学校未就绪时退化为站点名) */
|
||||||
|
const identityText = computed(() => {
|
||||||
|
const label = siteLabel.value;
|
||||||
|
if (!label) return "";
|
||||||
|
return schoolName.value ? `${schoolName.value}.${label}` : label;
|
||||||
|
});
|
||||||
|
const shellTitle = computed(() =>
|
||||||
|
identityText.value ? `${appName.value} · ${identityText.value}` : appName.value
|
||||||
|
);
|
||||||
const docTitle = computed(() => {
|
const docTitle = computed(() => {
|
||||||
const t = String((route.meta as any)?.title || "");
|
const t = String((route.meta as any)?.title || "");
|
||||||
if (!siteName.value) return t;
|
// 多标签页可分辨:文档标题同样带「学校名.站点名」,与页头口径一致
|
||||||
return t ? `${t} · ${siteName.value}` : siteName.value;
|
if (!identityText.value) return t;
|
||||||
|
return t ? `${t} · ${identityText.value}` : identityText.value;
|
||||||
});
|
});
|
||||||
watch(docTitle, v => { if (v) document.title = v; }, { immediate: true });
|
watch(docTitle, v => { if (v) document.title = v; }, { immediate: true });
|
||||||
|
|
||||||
@@ -324,11 +337,17 @@ watch(
|
|||||||
title="返回学校列表"
|
title="返回学校列表"
|
||||||
@click="goWorkbench"
|
@click="goWorkbench"
|
||||||
>
|
>
|
||||||
<Icon type="ios-undo" :size="18" />
|
<Icon type="ios-arrow-back" :size="20" />
|
||||||
</a>
|
</a>
|
||||||
<div v-if="isSuper" class="delimiter fl align-middle"></div>
|
<div v-if="isSuper" class="delimiter fl align-middle"></div>
|
||||||
<h4 class="fl align-middle" :title="shellTitle">
|
<h4 class="fl align-middle" :title="shellTitle">
|
||||||
{{ appName }}<span v-if="siteName" class="shell-site" :class="identityClass"> · {{ siteName }}</span>
|
{{ appName }}<template v-if="identityText"
|
||||||
|
><span class="h-delimiter"></span
|
||||||
|
><span class="school-cap" :class="identityClass"
|
||||||
|
>{{ schoolName }}<span class="sc-sep">.</span
|
||||||
|
><span class="sc-site">{{ siteLabel }}</span></span
|
||||||
|
></template
|
||||||
|
>
|
||||||
</h4>
|
</h4>
|
||||||
<div class="header-aside fr">
|
<div class="header-aside fr">
|
||||||
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
||||||
@@ -344,8 +363,8 @@ watch(
|
|||||||
<div class="theme-cap fr"><ThemeSwitcher /></div>
|
<div class="theme-cap fr"><ThemeSwitcher /></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="left-menu" :class="identityClass" v-if="!isAlone">
|
<div class="left-menu" :class="identityClass" v-if="!isAlone">
|
||||||
<!-- 站点卡(v2 §C.3 #1):占用原本空置的 .menu-top-box,身份常驻侧栏 -->
|
<!-- 站点卡已于 docs/22 v3 撤除(与站点工作台重复);身份改由页头「学校名.站点名」承担 -->
|
||||||
<div class="menu-top-box"><SiteContextCard /></div>
|
<div class="menu-top-box"></div>
|
||||||
<ul>
|
<ul>
|
||||||
<template v-for="(item, index) in modList" :key="'menu_li_' + index">
|
<template v-for="(item, index) in modList" :key="'menu_li_' + index">
|
||||||
<template v-if="item.children && item.children.length > 0">
|
<template v-if="item.children && item.children.length > 0">
|
||||||
@@ -444,13 +463,23 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
/* 超管返回学校列表图标:header 常驻入口(校级账号不渲染) */
|
/* 超管返回学校列表:header 常驻入口(校级账号不渲染)。
|
||||||
|
与「首页」按钮同规格的可点方块(26×26 + hover 底)+ 左右 10px 呼吸 ——
|
||||||
|
原先是裸 18×18 图标夹在两条分隔线之间(x=126/146),既无点击热区、又比首页按钮小一档;
|
||||||
|
图标同时从 ios-undo(字形像「拐角」)换成 ios-arrow-back。 */
|
||||||
.back-workbench {
|
.back-workbench {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
margin: 0 10px;
|
||||||
|
border-radius: 5px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.16s;
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.85;
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.no-site-tip {
|
.no-site-tip {
|
||||||
@@ -493,39 +522,36 @@ watch(
|
|||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 站点卡落位与身份染色(v2 §C.3 方案 A · P-C1)=====
|
/* 页头身份(docs/22 v3):云海CMS | 学校名.站点名
|
||||||
.menu-top-box 在压缩产物 layout.css 里是「10px 空档 + 右边框」,放卡后必须让位;
|
h4 截断——学校名+站名可能很长,完整串在 :title 与 document.title。
|
||||||
特异性 .left-menu .menu-top-box[data-v-x](0,3,0)> 全局 .left-menu .menu-top-box(0,2,0),可覆盖。
|
胶囊色沿用原 .shell-site 取值(主站 rgba(255,255,255,.18) / 子站橙 rgba(255,153,0,.32)),不引新色值。 */
|
||||||
sticky:侧栏是 overflow:auto 的滚动容器,卡片随之滚走就谈不上"身份常驻",故吸附在顶部并垫同色底。 */
|
|
||||||
.left-menu .menu-top-box {
|
|
||||||
height: auto;
|
|
||||||
border-right: none;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: var(--side-bg);
|
|
||||||
}
|
|
||||||
/* 子站 3px 身份色条贴在常驻卡区顶部(inset 阴影不占布局、不随内容滚走);主站不画——
|
|
||||||
默认为主站、偏离才提示(§C.3 设计原则),避免两态都染色带来视觉噪音 */
|
|
||||||
.left-menu.band-sub .menu-top-box {
|
|
||||||
box-shadow: inset 0 3px 0 #ff9900;
|
|
||||||
padding-top: 3px;
|
|
||||||
}
|
|
||||||
/* 页头站点名胶囊:站名可能很长,h4 截断(完整「页面名 · 站点名」在 title 与 document.title) */
|
|
||||||
.header-container h4 {
|
.header-container h4 {
|
||||||
max-width: 46vw;
|
max-width: 46vw;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.shell-site {
|
.h-delimiter {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0 10px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
.school-cap {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.sc-sep {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.sc-site {
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.18);
|
||||||
}
|
}
|
||||||
.shell-site.band-sub {
|
.school-cap.band-sub .sc-site {
|
||||||
background: rgba(255, 153, 0, 0.32);
|
background: rgba(255, 153, 0, 0.32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { useRoute, useRouter } from "vue-router";
|
|
||||||
import { useCmsStore } from "@/store";
|
|
||||||
import { adminPathOf, schoolSubPath } from "@/utils/sitePath";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 站点卡(v2 §C.3 方案 A · P-C1):侧栏常驻的「站点身份 + 站点切换」卡,占掉 SchoolLayout 的空 .menu-top-box。
|
|
||||||
* 动因(§C.2):站点身份此前只活在面包屑最后一格的 SiteSwitcher 里,进入工作区后侧栏/页头零标识,
|
|
||||||
* 主站与院系子站看起来像同一个站。方案 A 的口径是身份常驻在管理壳(Chrome Identity),功能菜单仍只有一份。
|
|
||||||
* 数据源 store.schoolSites(= user.sites 按本校过滤,含 SiteVo.theme),零后端改动。
|
|
||||||
* 切换语义与 SiteSwitcher 一致:只改 URL(补/省 /site/{code} 段)并停留当前功能页,上下文由路由同步。
|
|
||||||
*/
|
|
||||||
const store = useCmsStore();
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const school = computed(() => store.currentSchool);
|
|
||||||
const isSub = computed(() => store.isSubSite);
|
|
||||||
const currentSite = computed(() => store.currentSite as any);
|
|
||||||
const currentName = computed(() => currentSite.value?.name || "");
|
|
||||||
/** 层级行:学校 › 学校主站 / 学校 › 院系子站(子站是他校站点时无父名可显,退化为站名) */
|
|
||||||
const tierText = computed(() => {
|
|
||||||
const sch = school.value?.schName || school.value?.mainSiteName || "";
|
|
||||||
const tier = isSub.value ? "院系子站" : "学校主站";
|
|
||||||
return sch ? `${sch} › ${tier}` : tier;
|
|
||||||
});
|
|
||||||
/** 皮肤包名(§C.3 #4:子站可独立选皮肤,这是最直观的"两站不同"信号)。取站点列表原始数据,currentSite 精简态不带 theme */
|
|
||||||
const currentTheme = computed(
|
|
||||||
() => (store.schoolSites as any[])?.find((s: any) => s.id === currentSite.value?.id)?.theme || ""
|
|
||||||
);
|
|
||||||
|
|
||||||
const sites = computed(() => (store.schoolSites || []) as any[]);
|
|
||||||
const isMainSite = (site: any) => site?.id === school.value?.mainSiteId;
|
|
||||||
|
|
||||||
/** 当前功能页子路径:点站点只换上下文、不换页面(与 SiteSwitcher.pick 同语义) */
|
|
||||||
const curSub = computed(() => schoolSubPath(route.path) || "/article");
|
|
||||||
|
|
||||||
function pick(site: any) {
|
|
||||||
if (!site) return;
|
|
||||||
const target = adminPathOf(school.value?.mainSiteCode || "", isMainSite(site) ? null : site.code, curSub.value);
|
|
||||||
if (target === route.path) return;
|
|
||||||
router.push(target);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="currentSite" class="site-context-card" :class="{ sub: isSub }">
|
|
||||||
<div class="scc-head">
|
|
||||||
<span class="scc-dot" aria-hidden="true"></span>
|
|
||||||
<div class="scc-id">
|
|
||||||
<b class="scc-name" :title="currentName">{{ currentName }}</b>
|
|
||||||
<span class="scc-tier">{{ tierText }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="scc-theme">皮肤包<b>{{ currentTheme || "未设置" }}</b></p>
|
|
||||||
<p class="scc-cap">切换管理站点({{ sites.length }})</p>
|
|
||||||
<ul class="scc-list">
|
|
||||||
<li
|
|
||||||
v-for="s in sites"
|
|
||||||
:key="s.id"
|
|
||||||
class="scc-site"
|
|
||||||
:class="{ active: s.id === currentSite.id }"
|
|
||||||
:aria-current="s.id === currentSite.id ? 'true' : null"
|
|
||||||
:title="s.name"
|
|
||||||
@click="pick(s)"
|
|
||||||
>
|
|
||||||
<span class="scc-site-bdg" :class="isMainSite(s) ? 'main' : 'sub'">
|
|
||||||
{{ isMainSite(s) ? "主站" : "院系" }}
|
|
||||||
</span>
|
|
||||||
<span class="scc-site-name">{{ s.name }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
/* 配色复用 SiteSwitcher 的定稿身份色(主站 #577afb / 子站 #ff9900 / 子站淡底 #fff7ec),不引新色值。
|
|
||||||
侧栏默认档是深色渐变(yh-theme.css --side-bg),故卡用白底浮起面板 + 8px 右左缩进,
|
|
||||||
与 .left-menu li{margin:1px 8px} 的栅格对齐,深浅两档都成立。 */
|
|
||||||
.site-context-card {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 8px 8px 2px;
|
|
||||||
padding: 10px 10px 8px;
|
|
||||||
border-radius: var(--r-s, 7px);
|
|
||||||
background: #fff;
|
|
||||||
color: #272829;
|
|
||||||
font-size: 12px;
|
|
||||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
|
|
||||||
&.sub {
|
|
||||||
background: #fff7ec;
|
|
||||||
.scc-name {
|
|
||||||
color: #d48806;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scc-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.scc-dot {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
margin-top: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #577afb;
|
|
||||||
}
|
|
||||||
.sub .scc-dot {
|
|
||||||
background: #ff9900;
|
|
||||||
}
|
|
||||||
.scc-id {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.scc-name {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #4465d4;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.scc-tier {
|
|
||||||
margin-top: 2px;
|
|
||||||
color: #969799;
|
|
||||||
line-height: 16px;
|
|
||||||
}
|
|
||||||
.scc-theme {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
color: #969799;
|
|
||||||
line-height: 18px;
|
|
||||||
b {
|
|
||||||
color: #656667;
|
|
||||||
font-weight: normal;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scc-cap {
|
|
||||||
margin: 6px 0 2px;
|
|
||||||
padding-top: 6px;
|
|
||||||
border-top: 1px solid #f0f2f5;
|
|
||||||
color: #c0c4cc;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
.scc-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
/* RK-07 缓解:院系子站多时卡内限高滚动,不把侧栏撑成一屏长的站点列表
|
|
||||||
(>8 站的"最近访问 + 全部"两段式属后续项,未在本工作项清单内) */
|
|
||||||
max-height: 40vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.scc-site {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 5px 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 3px;
|
|
||||||
&:hover {
|
|
||||||
background: #eff4ff;
|
|
||||||
}
|
|
||||||
&.active {
|
|
||||||
background: #eff4ff;
|
|
||||||
.scc-site-name {
|
|
||||||
color: #4465d4;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scc-site-bdg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 16px;
|
|
||||||
padding: 0 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #fff;
|
|
||||||
&.main {
|
|
||||||
background: #577afb;
|
|
||||||
}
|
|
||||||
&.sub {
|
|
||||||
background: #ff9900;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scc-site-name {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+250
-29
@@ -8,13 +8,15 @@ import { adminPathOf, portalUrlOf } from "@/utils/sitePath";
|
|||||||
import { useCmsStore } from "@/store";
|
import { useCmsStore } from "@/store";
|
||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||||
|
import SiteCard from "./components/SiteCard.vue";
|
||||||
|
import SiteMainCard from "./components/SiteMainCard.vue";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 站点管理列表页(docs/16 主线A 收敛后):列表只做表格——
|
* 站点工作台(docs/22 v3,原型 docs/21 屏1):按学校分组 = 主站宽卡 + 院系子站网格,
|
||||||
* 新建 → 站点设置中心创建模式(site/new);行「编辑」→ 站点设置中心(site/setting/:id)。
|
* 站点父子层级用版式分区表达(不靠旧表格「所属主站」列文字);卡片 ⇄ 表格切换 + 名称/编码搜索。
|
||||||
* 分区卡片/皮肤/首页布局等全部配置能力收敛到 SiteSetting.vue(原编辑弹窗与三步向导退役)。
|
* 新建 → 站点设置中心创建模式(site/new);卡/行「站点设置」→ site/setting/:id(docs/16 主线A 收敛不变)。
|
||||||
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点;超管可经工作台「全部站点总览」(?scope=all)。
|
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点;超管可经工作台「全部站点总览」(?scope=all)。
|
||||||
* 行「进入管理」= 院系子站切换入口;跨校行先重建学校上下文;当前站点行带「当前」标记。
|
* 「进入管理」= 院系子站切换入口;跨校行先重建学校上下文;当前站带「当前」徽章且按钮禁用。
|
||||||
*/
|
*/
|
||||||
const store = useCmsStore();
|
const store = useCmsStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -39,37 +41,91 @@ const themeMeta = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 视图模式(docs/22 Q2:保留表格,批量/密集场景) */
|
||||||
|
const view = ref<"card" | "table">("card");
|
||||||
|
/** 搜索关键字:按 站点名称 / 编码 即时过滤(站点多了找不到的痛点) */
|
||||||
|
const keyword = ref("");
|
||||||
|
|
||||||
|
/** 学校分组:每组 = 主站宽卡 + 该主站的院系子站网格。
|
||||||
|
* 单校视角只有 1 组;超管 ?scope=all 时按 schId 分组依次排列。 */
|
||||||
|
interface SiteGroup {
|
||||||
|
schKey: string;
|
||||||
|
schName: string;
|
||||||
|
main: SiteVo | null;
|
||||||
|
subs: SiteVo[];
|
||||||
|
/** 子站总数(过滤前):主站卡的「子站」统计要的是真实规模,不随搜索变化 */
|
||||||
|
rawSubs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = computed<SiteGroup[]>(() => {
|
||||||
|
const kw = keyword.value.trim().toLowerCase();
|
||||||
|
const match = (s: SiteVo) =>
|
||||||
|
!kw || s.name.toLowerCase().includes(kw) || String(s.code || "").toLowerCase().includes(kw);
|
||||||
|
|
||||||
|
const map = new Map<string, SiteGroup>();
|
||||||
|
rows.value.forEach(s => {
|
||||||
|
const key = String(s.schId ?? s.schName ?? "0");
|
||||||
|
let g = map.get(key);
|
||||||
|
if (!g) {
|
||||||
|
g = { schKey: key, schName: s.schName || "", main: null, subs: [], rawSubs: 0 };
|
||||||
|
map.set(key, g);
|
||||||
|
}
|
||||||
|
if (!s.parentSiteId) {
|
||||||
|
g.main = s;
|
||||||
|
} else {
|
||||||
|
g.rawSubs += 1;
|
||||||
|
// 子站按关键字过滤;但组的主站卡始终保留作层级锚点(不因主站名不匹配而掉链)
|
||||||
|
if (match(s)) g.subs.push(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 搜索态:既无命中子站、主站自身也不匹配的组整组隐去
|
||||||
|
return [...map.values()].filter(g => !kw || g.subs.length > 0 || (g.main && match(g.main)));
|
||||||
|
});
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: "站点编码", key: "code", width: 140 },
|
|
||||||
{
|
{
|
||||||
title: "站点名称",
|
title: "站点名称",
|
||||||
key: "name",
|
key: "name",
|
||||||
minWidth: 200,
|
minWidth: 220,
|
||||||
tooltip: true,
|
tooltip: true,
|
||||||
render: (h: any, p: any) =>
|
render: (h: any, p: any) =>
|
||||||
h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
|
h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
|
||||||
|
// 层级徽章前置:主站/院系一眼可辨(取代原本只靠「所属主站」列文字)
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: p.row.parentSiteId ? "tier-bdg sub" : "tier-bdg main" },
|
||||||
|
p.row.parentSiteId ? "院系" : "主站"
|
||||||
|
),
|
||||||
h("span", p.row.name),
|
h("span", p.row.name),
|
||||||
// 当前操作站点标记:壳面包屑校名后缀同源(store.currentSite)
|
|
||||||
p.row.id === store.siteId ? h(Tag, { color: "success" }, () => "当前") : null,
|
p.row.id === store.siteId ? h(Tag, { color: "success" }, () => "当前") : null,
|
||||||
|
p.row.status !== 1 ? h(Tag, { color: "default" }, () => "停用") : null,
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
{ title: "站点编码", key: "code", width: 120 },
|
||||||
{ title: "学校", key: "schName", width: 150, tooltip: true },
|
{ title: "学校", key: "schName", width: 150, tooltip: true },
|
||||||
{
|
{
|
||||||
title: "所属主站",
|
title: "所属主站",
|
||||||
key: "parentSiteId",
|
key: "parentSiteId",
|
||||||
width: 150,
|
width: 160,
|
||||||
tooltip: true,
|
tooltip: true,
|
||||||
render: (h: any, p: any) => h("span", p.row.parentSiteId ? p.row.parentName || `ID ${p.row.parentSiteId}` : "顶层站点"),
|
render: (h: any, p: any) =>
|
||||||
|
h("span", p.row.parentSiteId ? p.row.parentName || `ID ${p.row.parentSiteId}` : "顶层站点"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 200px:让「缩略图 + 名称(包名)」单行放下,消除原 150px 下的 3 行竖排
|
||||||
title: "皮肤包",
|
title: "皮肤包",
|
||||||
key: "theme",
|
key: "theme",
|
||||||
width: 150,
|
width: 200,
|
||||||
render: (h: any, p: any) => {
|
render: (h: any, p: any) => {
|
||||||
const t = themeMeta.value[p.row.theme || "default"];
|
const t = themeMeta.value[p.row.theme || "default"];
|
||||||
return h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
|
return h("span", { style: "display:inline-flex;align-items:center;gap:7px;white-space:nowrap" }, [
|
||||||
t
|
t
|
||||||
? h("img", { src: assetUrl(t.screenshotUrl), style: "width:44px;height:28px;object-fit:cover;border-radius:3px;border:1px solid #eee", alt: "" })
|
? h("img", {
|
||||||
|
src: assetUrl(t.screenshotUrl),
|
||||||
|
style: "width:44px;height:28px;object-fit:cover;border-radius:3px;border:1px solid #eee",
|
||||||
|
alt: "",
|
||||||
|
})
|
||||||
: h("span", { style: "display:inline-block;width:44px;height:28px;background:#f0f2f5;border-radius:3px" }),
|
: h("span", { style: "display:inline-block;width:44px;height:28px;background:#f0f2f5;border-radius:3px" }),
|
||||||
h("span", t ? `${t.title}(${p.row.theme})` : p.row.theme || "default"),
|
h("span", t ? `${t.title}(${p.row.theme})` : p.row.theme || "default"),
|
||||||
]);
|
]);
|
||||||
@@ -82,19 +138,17 @@ const columns = [
|
|||||||
width: 90,
|
width: 90,
|
||||||
align: "center",
|
align: "center",
|
||||||
render: (h: any, p: any) =>
|
render: (h: any, p: any) =>
|
||||||
h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () =>
|
h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () => (p.row.status === 1 ? "启用" : "停用")),
|
||||||
p.row.status === 1 ? "启用" : "停用"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
key: "action",
|
key: "action",
|
||||||
width: 150,
|
width: 200,
|
||||||
fixed: "right",
|
fixed: "right",
|
||||||
align: "center",
|
align: "center",
|
||||||
render: (h: any, p: any) => [
|
render: (h: any, p: any) => [
|
||||||
Utils.operatingHandle(h, "进入管理", "icon-apps", () => enterManage(p.row), p.row.id === store.siteId),
|
Utils.operatingHandle(h, "进入管理", "icon-apps", () => enterManage(p.row), p.row.id === store.siteId || p.row.status !== 1),
|
||||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => openSetting(p.row)),
|
Utils.operatingHandle(h, "站点设置", "icon-edit", () => openSetting(p.row)),
|
||||||
Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)),
|
Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -187,19 +241,186 @@ watch(() => store.currentSchool?.schId, () => run());
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="fix" v-if="scopeAll">
|
<!-- 超管全局视图(?scope=all):按学校分组,每组 = 主站宽卡 + 子站网格 -->
|
||||||
<Alert type="info" show-icon style="width: 100%">
|
<Alert v-if="scopeAll" type="info" show-icon style="width: 100%; margin-bottom: 12px">
|
||||||
全局站点视图:显示全部学校的站点(含各校主站与院系子站)。
|
全局站点视图:按学校分组显示全部站点(含各校主站与院系子站)。
|
||||||
<a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a>
|
<a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
|
||||||
<div class="fix mb-12" v-if="canCreateSite">
|
<!-- 工具行:新建 + 卡片/表格切换 + 搜索 -->
|
||||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
<div class="wb-toolbar mb-12">
|
||||||
<Icon type="ios-add" />
|
<Button v-if="canCreateSite" type="primary" @click="openCreate">
|
||||||
{{ isSchoolAdmin ? "新建院系子站" : "新建站点" }}
|
<Icon type="ios-add" />
|
||||||
</Button>
|
{{ isSchoolAdmin ? "新建院系子站" : "新建站点" }}
|
||||||
</div>
|
</Button>
|
||||||
<Table class="link-table table-bottom-box" :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
|
<div class="seg">
|
||||||
|
<button type="button" :class="{ on: view === 'card' }" @click="view = 'card'">卡片</button>
|
||||||
|
<button type="button" :class="{ on: view === 'table' }" @click="view = 'table'">表格</button>
|
||||||
|
</div>
|
||||||
|
<div class="seg-search">
|
||||||
|
<Input v-model="keyword" placeholder="搜索站点名称 / 编码" clearable style="width: 220px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin v-if="loading" size="large" fix />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 卡片视图:主站宽卡 + 院系子站网格 -->
|
||||||
|
<div v-if="view === 'card'">
|
||||||
|
<template v-if="groups.length">
|
||||||
|
<section v-for="g in groups" :key="g.schKey" class="site-group">
|
||||||
|
<h3 v-if="scopeAll && (g.schName || g.main?.name)" class="group-cap">
|
||||||
|
{{ g.schName || g.main?.name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<SiteMainCard
|
||||||
|
v-if="g.main"
|
||||||
|
:site="g.main"
|
||||||
|
:is-current="g.main.id === store.siteId"
|
||||||
|
:theme="themeMeta[g.main.theme || 'default']"
|
||||||
|
:sub-count="g.rawSubs"
|
||||||
|
@enter="enterManage"
|
||||||
|
@setting="openSetting"
|
||||||
|
@portal="openPortal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="zone-cap">
|
||||||
|
<h3>院系子站</h3>
|
||||||
|
<span class="cnt">{{ g.subs.length }} 个</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="g.subs.length" class="card-grid">
|
||||||
|
<SiteCard
|
||||||
|
v-for="s in g.subs"
|
||||||
|
:key="s.id"
|
||||||
|
:site="s"
|
||||||
|
:is-current="s.id === store.siteId"
|
||||||
|
:theme="themeMeta[s.theme || 'default']"
|
||||||
|
@enter="enterManage"
|
||||||
|
@setting="openSetting"
|
||||||
|
@portal="openPortal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p v-if="keyword">没有匹配「{{ keyword }}」的院系子站。</p>
|
||||||
|
<p v-else>本校暂无院系子站。</p>
|
||||||
|
<Button v-if="canCreateSite && !keyword" type="primary" @click="openCreate">
|
||||||
|
<Icon type="ios-add" />
|
||||||
|
新建院系子站
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p v-if="keyword">没有匹配「{{ keyword }}」的站点。</p>
|
||||||
|
<p v-else>暂无站点,请联系平台管理员在学校工作台添加。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格视图:保留批量速览(列已重排) -->
|
||||||
|
<Table
|
||||||
|
v-else
|
||||||
|
class="link-table table-bottom-box"
|
||||||
|
:columns="columns"
|
||||||
|
:data="rows"
|
||||||
|
:height="tableHeight"
|
||||||
|
:loading="loading"
|
||||||
|
no-data-text="暂无站点"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.wb-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
/* 卡片 ⇄ 表格 视图切换(原型同款分段控件) */
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid #dcdee2;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #515a6e;
|
||||||
|
font-family: inherit;
|
||||||
|
&.on {
|
||||||
|
background: #eff4ff;
|
||||||
|
color: #4465d4;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.seg-search {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.site-group + .site-group {
|
||||||
|
margin-top: 34px;
|
||||||
|
padding-top: 26px;
|
||||||
|
border-top: 1px solid #eef0f3;
|
||||||
|
}
|
||||||
|
.group-cap {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #17233d;
|
||||||
|
}
|
||||||
|
.zone-cap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #17233d;
|
||||||
|
}
|
||||||
|
.cnt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #808695;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(276px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
border: 1px dashed #dcdee2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 44px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #808695;
|
||||||
|
p {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 表格视图的层级徽章(render 出的 span,scoped 属性会下发到该节点) */
|
||||||
|
:deep(.tier-bdg) {
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff;
|
||||||
|
&.main {
|
||||||
|
background: #577afb;
|
||||||
|
}
|
||||||
|
&.sub {
|
||||||
|
background: #ff9900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { assetUrl } from "@/api/module/cms";
|
||||||
|
import { portalUrlOf } from "@/utils/sitePath";
|
||||||
|
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 院系子站卡(docs/22):站点工作台网格单元。
|
||||||
|
* 信息按「身份 → 规模 → 皮肤 → 地址 → 动作」纵向分层,不再与表格争列宽。
|
||||||
|
* 当前站 = 蓝描边 + 「当前」绿徽章;停用 = 降透明 + 灰图标 + 「进入管理」禁用。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
site: SiteVo;
|
||||||
|
isCurrent: boolean;
|
||||||
|
theme?: ThemeVo;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "enter", site: SiteVo): void;
|
||||||
|
(e: "setting", site: SiteVo): void;
|
||||||
|
(e: "portal", site: SiteVo): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const disabled = computed(() => props.site.status !== 1);
|
||||||
|
/** 当前站无需「进入管理」(已在站内),与停用同视为不可达 */
|
||||||
|
const enterDisabled = computed(() => disabled.value || props.isCurrent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规模统计:SiteVo 暂未提供聚合字段(docs/22 Q1),故当前渲染为空、该行自动隐藏;
|
||||||
|
* 后端在 /api/site/list 补齐 categoryCount / articleCount / updateTime 后自动出现,模板不动。
|
||||||
|
*/
|
||||||
|
const stats = computed(() => {
|
||||||
|
const s = props.site as any;
|
||||||
|
const list: { num: string | number; lbl: string }[] = [];
|
||||||
|
if (s.categoryCount != null) list.push({ num: s.categoryCount, lbl: "栏目" });
|
||||||
|
if (s.articleCount != null) list.push({ num: s.articleCount, lbl: "文章" });
|
||||||
|
if (s.updateTime) list.push({ num: String(s.updateTime).slice(5, 10), lbl: "更新" });
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 皮肤缩略图(走 assetUrl 补资源前缀,与表格视图同源) */
|
||||||
|
const themeThumb = computed(() => (props.theme?.screenshotUrl ? assetUrl(props.theme.screenshotUrl) : ""));
|
||||||
|
const themeTitle = computed(() => props.theme?.title || props.site.theme || "default");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="site-card" :class="{ 'is-cur': isCurrent, disabled }">
|
||||||
|
<div class="sc-head">
|
||||||
|
<span class="sc-ico" :class="disabled ? 'off' : 'sub'">
|
||||||
|
<Icon type="ios-cog" />
|
||||||
|
</span>
|
||||||
|
<div class="sc-id">
|
||||||
|
<div class="sc-name">
|
||||||
|
<h4 :title="site.name">{{ site.name }}</h4>
|
||||||
|
<span class="sc-badge sub">院系子站</span>
|
||||||
|
<span v-if="isCurrent" class="sc-badge cur">当前</span>
|
||||||
|
<span v-else-if="disabled" class="sc-badge off">停用</span>
|
||||||
|
</div>
|
||||||
|
<p class="sc-code">{{ site.path || site.code }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stats.length" class="sc-meta">
|
||||||
|
<span v-for="st in stats" :key="st.lbl">{{ st.lbl }} <b>{{ st.num }}</b></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-theme">
|
||||||
|
<span class="thumb" :style="themeThumb ? { backgroundImage: `url(${themeThumb})` } : null"></span>
|
||||||
|
<span class="tname" :title="themeTitle">
|
||||||
|
{{ themeTitle }}
|
||||||
|
<span v-if="site.theme" class="tspec">{{ site.theme }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="sc-domain">
|
||||||
|
<Icon type="ios-globe-outline" />
|
||||||
|
<code :title="portalUrlOf(site)">{{ portalUrlOf(site) }}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="sc-actions">
|
||||||
|
<Button type="primary" size="small" :disabled="enterDisabled" @click="emit('enter', site)">
|
||||||
|
进入管理
|
||||||
|
</Button>
|
||||||
|
<Button size="small" @click="emit('setting', site)">站点设置</Button>
|
||||||
|
<Button size="small" @click="emit('portal', site)">门户</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
/* 与超管 SchoolWorkbench 的卡片语法同构:白底 8px 圆角 + 1px 边 + hover 抬升 2px */
|
||||||
|
.site-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8eaec;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
transition: box-shadow 0.2s, transform 0.2s, border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(31, 42, 58, 0.12);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #c9d6fb;
|
||||||
|
}
|
||||||
|
/* 当前操作站点:4px 左色带 + 蓝描边 + 淡蓝光环。
|
||||||
|
与 SiteMainCard 同一套语义 —— 全页同时只有一张卡带「选中」,不再出现第二处蓝色标识。 */
|
||||||
|
&.is-cur {
|
||||||
|
border-color: #9db4f7;
|
||||||
|
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.12);
|
||||||
|
}
|
||||||
|
&.is-cur::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: #577afb;
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
background: #fafbfc;
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: 0 1px 2px rgba(31, 42, 58, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
/* 身份色:主站 #577afb / 子站 #ff9900(与 SiteSwitcher 定稿身份色一致) */
|
||||||
|
.sc-ico {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
background: #577afb;
|
||||||
|
&.sub {
|
||||||
|
background: #ff9900;
|
||||||
|
}
|
||||||
|
&.off {
|
||||||
|
background: #c0c4cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-id {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.sc-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #17233d;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff;
|
||||||
|
&.sub {
|
||||||
|
background: #ff9900;
|
||||||
|
}
|
||||||
|
&.cur {
|
||||||
|
background: #2ba471;
|
||||||
|
}
|
||||||
|
&.off {
|
||||||
|
background: #c0c4cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-code {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #808695;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
}
|
||||||
|
.sc-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 11px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #515a6e;
|
||||||
|
b {
|
||||||
|
color: #17233d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-theme {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed #eceff3;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #656667;
|
||||||
|
.thumb {
|
||||||
|
width: 52px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e8eaec;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f0f2f5 center/cover no-repeat;
|
||||||
|
}
|
||||||
|
.tname {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tspec {
|
||||||
|
color: #a0a6b0;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-domain {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #808695;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
code {
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
color: #656667;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
/* 三个动作等宽铺满(iview Button 是子组件根节点,scoped 属性会下发到根 DOM) */
|
||||||
|
.ivu-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { assetUrl } from "@/api/module/cms";
|
||||||
|
import { portalUrlOf } from "@/utils/sitePath";
|
||||||
|
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校主站宽卡(docs/22):站点工作台的层级锚点。
|
||||||
|
* 主站唯一、子站都挂在它下面 —— 横向宽卡独占一行(左侧 4px 主色带),
|
||||||
|
* 与子站网格在版式上分区;层级不靠「所属主站」列的文字表达(那是旧表格的做法)。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
site: SiteVo;
|
||||||
|
isCurrent: boolean;
|
||||||
|
theme?: ThemeVo;
|
||||||
|
/** 本校院系子站总数(由父级按全量行统计,搜索态下不随过滤变化) */
|
||||||
|
subCount: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "enter", site: SiteVo): void;
|
||||||
|
(e: "setting", site: SiteVo): void;
|
||||||
|
(e: "portal", site: SiteVo): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const disabled = computed(() => props.site.status !== 1);
|
||||||
|
const enterDisabled = computed(() => disabled.value || props.isCurrent);
|
||||||
|
|
||||||
|
/** 统计:子站数本地可算(必显);栏目/文章同 SiteCard —— 后端补齐字段后自动出现 */
|
||||||
|
const stats = computed(() => {
|
||||||
|
const s = props.site as any;
|
||||||
|
const list: { num: string | number; lbl: string }[] = [];
|
||||||
|
if (s.categoryCount != null) list.push({ num: s.categoryCount, lbl: "栏目" });
|
||||||
|
if (s.articleCount != null) list.push({ num: s.articleCount, lbl: "文章" });
|
||||||
|
list.push({ num: props.subCount, lbl: "子站" });
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const themeThumb = computed(() => (props.theme?.screenshotUrl ? assetUrl(props.theme.screenshotUrl) : ""));
|
||||||
|
const themeTitle = computed(() => props.theme?.title || props.site.theme || "default");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="main-card" :class="{ 'is-cur': isCurrent, disabled }">
|
||||||
|
<div class="mc-body">
|
||||||
|
<div class="mc-left">
|
||||||
|
<span class="sc-ico" :class="disabled ? 'off' : ''">
|
||||||
|
<Icon type="ios-school" />
|
||||||
|
</span>
|
||||||
|
<div class="sc-id">
|
||||||
|
<div class="sc-name">
|
||||||
|
<h4 :title="site.name">{{ site.name }}</h4>
|
||||||
|
<span class="sc-badge main">主站</span>
|
||||||
|
<span v-if="isCurrent" class="sc-badge cur">当前</span>
|
||||||
|
<span v-else-if="disabled" class="sc-badge off">停用</span>
|
||||||
|
</div>
|
||||||
|
<p class="sc-code">{{ site.code }} · {{ portalUrlOf(site) }}</p>
|
||||||
|
<div class="sc-theme">
|
||||||
|
<span class="thumb" :style="themeThumb ? { backgroundImage: `url(${themeThumb})` } : null"></span>
|
||||||
|
<span class="tname" :title="themeTitle">
|
||||||
|
{{ themeTitle }}
|
||||||
|
<span v-if="site.theme" class="tspec">{{ site.theme }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mc-stats">
|
||||||
|
<div class="st" v-for="st in stats" :key="st.lbl">
|
||||||
|
<div class="num">{{ st.num }}</div>
|
||||||
|
<div class="lbl">{{ st.lbl }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mc-actions">
|
||||||
|
<Button type="primary" :disabled="enterDisabled" @click="emit('enter', site)">进入管理</Button>
|
||||||
|
<Button @click="emit('setting', site)">站点设置</Button>
|
||||||
|
<Button @click="emit('portal', site)">门户预览</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
/* 主站宽卡:与子站卡共用同一套「当前站」语义 —— 4px 左色带 + 蓝描边 + 淡蓝光环,
|
||||||
|
全页同时只有一张卡带选中样式(原色带常驻表达「根节点」,会被误读成选中态)。
|
||||||
|
层级改由版式表达:宽卡独占一行 + 「主站」徽章 + 「N 子站」统计。
|
||||||
|
色带走 ::before 绝对定位而非 border-left —— 用边框会在切换态时把内容挤偏 3px。 */
|
||||||
|
.main-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8eaec;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
&.is-cur {
|
||||||
|
border-color: #9db4f7;
|
||||||
|
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.12);
|
||||||
|
}
|
||||||
|
&.is-cur::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: #577afb;
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mc-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.mc-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.sc-ico {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 19px;
|
||||||
|
color: #fff;
|
||||||
|
background: #577afb;
|
||||||
|
&.off {
|
||||||
|
background: #c0c4cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-id {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.sc-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
color: #17233d;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff;
|
||||||
|
&.main {
|
||||||
|
background: #577afb;
|
||||||
|
}
|
||||||
|
&.cur {
|
||||||
|
background: #2ba471;
|
||||||
|
}
|
||||||
|
&.off {
|
||||||
|
background: #c0c4cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sc-code {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #808695;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
}
|
||||||
|
.sc-theme {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #656667;
|
||||||
|
.thumb {
|
||||||
|
width: 52px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e8eaec;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f0f2f5 center/cover no-repeat;
|
||||||
|
}
|
||||||
|
.tname {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tspec {
|
||||||
|
color: #a0a6b0;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mc-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
.st {
|
||||||
|
padding: 0 18px;
|
||||||
|
border-left: 1px solid #eef0f3;
|
||||||
|
text-align: center;
|
||||||
|
&:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #17233d;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #a0a6b0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user