chore: 前置快照-assetUrl前缀与学校切换收敛会话遗留(执行方案B1~B5前既有未提交变更)

This commit is contained in:
chenxing
2026-09-08 15:14:07 +08:00
parent 67a7164897
commit c77be8b5d8
6 changed files with 232 additions and 137 deletions
+101
View File
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* 路径参数型 POST 接口的 siteId 透传回归(publish 曾因 postJSON 丢第三参,
* 请求裸发 POST /api/article/publish/{id},后端报「缺少必填参数:siteId」)。
* alova 实例与 host.config(顶层 document 依赖)均 mock,node 环境纯断言。
* host.config 用 vi.hoisted 可变对象:assetUrl 用例需要切换 serviceHost 模拟生产 /yschcms。
*/
const confState = vi.hoisted(() => ({
conf: { baseHost: "", ssoHost: "", serviceHost: "", isMock: false, isDebug: false },
}));
vi.mock("@/assets/config/host.config", () => ({
default: confState.conf,
}));
vi.mock("@/utils/alova/alova-iview", () => ({
alovaInstance: {
Get: vi.fn(),
Post: vi.fn(() => ({})),
},
}));
import { articleApi, assetUrl } from "./cms";
import { alovaInstance } from "@/utils/alova/alova-iview";
const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" };
describe("cms.ts 路径参数型 POST 的 query 透传", () => {
beforeEach(() => {
vi.mocked(alovaInstance.Post).mockClear();
});
it("publish: siteId 以 query 参数随 Post config 发出", () => {
articleApi.publish(5, 2);
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/publish/2", undefined, {
headers: JSON_HEADERS,
params: { siteId: 5 },
});
});
it("offline: 同款透传", () => {
articleApi.offline(5, 3);
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/offline/3", undefined, {
headers: JSON_HEADERS,
params: { siteId: 5 },
});
});
it("topmost: siteId 与 enable 一并透传", () => {
articleApi.topmost(5, 3, true);
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/topmost/3", undefined, {
headers: JSON_HEADERS,
params: { siteId: 5, enable: true },
});
});
it("save/delete: siteId 走 body,不带 params(守卫勿误伤)", () => {
articleApi.delete({ id: 2, siteId: 5 });
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/delete", { id: 2, siteId: 5 }, {
headers: JSON_HEADERS,
params: undefined,
});
});
});
describe("assetUrl 站内资产路径补 serviceHost 前缀(线上主题截图破图回归)", () => {
afterEach(() => {
confState.conf.serviceHost = "";
confState.conf.isMock = false;
});
it("生产:/api 裸路径拼上 context-path(/yschcms)", () => {
confState.conf.serviceHost = "/yschcms";
expect(assetUrl("/api/theme/default/asset/screenshot.png")).toBe(
"/yschcms/api/theme/default/asset/screenshot.png"
);
});
it("dev/mock:serviceHost 为空串时原样返回", () => {
expect(assetUrl("/api/theme/default/asset/screenshot.png")).toBe(
"/api/theme/default/asset/screenshot.png"
);
});
it("绝对地址(http/data/blob)不拼前缀", () => {
confState.conf.serviceHost = "/yschcms";
expect(assetUrl("https://cdn.example.com/x.png")).toBe("https://cdn.example.com/x.png");
expect(assetUrl("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
expect(assetUrl("blob:http://localhost/uuid")).toBe("blob:http://localhost/uuid");
});
it("空值原样返回;isMock 时不拼 serviceHost", () => {
expect(assetUrl("")).toBe("");
confState.conf.serviceHost = "/yschcms";
confState.conf.isMock = true;
expect(assetUrl("/api/theme/default/asset/screenshot.png")).toBe(
"/api/theme/default/asset/screenshot.png"
);
});
});
+16 -11
View File
@@ -41,10 +41,11 @@ function get<T>(url: string, params?: IObj): Method {
return alovaInstance.Get<T>(url, { params });
}
/** POST JSON(@RequestBody) */
function postJSON<T>(url: string, data?: unknown): Method {
/** POST JSON(@RequestBody);params 为追加到 URL 的 query 参数(alova Post config 同 Get) */
function postJSON<T>(url: string, data?: unknown, params?: IObj): Method {
return alovaInstance.Post<T>(url, data, {
headers: { "Content-Type": "application/json;charset=UTF-8" },
params,
});
}
@@ -58,6 +59,16 @@ export function portalBase(siteCode: string): string {
return `${conf.isMock ? "" : conf.serviceHost}/portal/${siteCode}`;
}
/**
* 后端下发的站内资产路径补 serviceHost 前缀(如主题截图 /api/theme/{name}/asset/screenshot.png)。
* 生产 context-path=/yschcms,裸 /api 绝对路径会打到网关根上 404(破图);
* dev/mock 下 serviceHost 为空串,拼接后与原路径一致不受影响。
*/
export function assetUrl(url: string): string {
if (!url || /^(https?:|data:|blob:)/i.test(url)) return url;
return `${conf.isMock ? "" : conf.serviceHost}${url}`;
}
class AuthApi {
/** 登录态:用户 + 可管理站点 + 是否超管 */
getUser() {
@@ -137,21 +148,15 @@ class ArticleApi {
/** 发布(publishTime 为未来时间即定时发布) */
publish(siteId: number, id: number) {
return postJSON<void>(`${ARTICLE}/publish/${id}`, undefined, {
params: { siteId },
});
return postJSON<void>(`${ARTICLE}/publish/${id}`, undefined, { siteId });
}
offline(siteId: number, id: number) {
return postJSON<void>(`${ARTICLE}/offline/${id}`, undefined, {
params: { siteId },
});
return postJSON<void>(`${ARTICLE}/offline/${id}`, undefined, { siteId });
}
topmost(siteId: number, id: number, enable: boolean) {
return postJSON<void>(`${ARTICLE}/topmost/${id}`, undefined, {
params: { siteId, enable },
});
return postJSON<void>(`${ARTICLE}/topmost/${id}`, undefined, { siteId, enable });
}
delete(data: DeleteRequest) {
+29 -97
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
* header 左侧「←学校列表」+ 校名徽标/快速切换下拉;面包屑首段=校名;
* 面包屑栏右侧「站点切换(校内主站+院系子站)+ 预览门户」承接院系子站维度。
* header 左侧仅超管可见「↩返回学校列表」图标(校级账号不显示);
* 面包屑首段=校名(落点为院系子站时后跟当前站点名),超管在校名前多一段可点「学校列表」(与返回图标同效)。
* 学校/站点切换一律离壳完成:学校在 /workbench,站点在站点管理行「进入管理」。
* 菜单/高亮/子页面面包屑逻辑与原 layout 一致,布局数据来自 store.session。 */
import { ref, computed, watch, onBeforeMount } from "vue";
import { useRoute, useRouter } from "vue-router";
@@ -9,7 +10,6 @@ import { Message } from "view-ui-plus";
import { resolveMenuPage } from "@/utils/menuRoute";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { portalBase } from "@/api/module/cms";
const route = useRoute();
const router = useRouter();
@@ -31,13 +31,18 @@ const modList = computed(() => store.session.modList || []);
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
/** 当前学校 + 校内站点(主站在前,切换下拉用) */
/** 当前学校上下文(面包屑首段) */
const school = computed(() => store.currentSchool);
const schoolSites = computed(() => store.schoolSites);
/** 快速切换学校候选 = 可管理顶层站点(主站) */
const schoolOptions = computed(() =>
((store.user?.sites || []) as any[]).filter((s: any) => !s.parentSiteId)
);
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 || "";
});
const flaten = (tree: any[], pid = 0): any[] => {
const arr: any[] = [];
@@ -187,29 +192,6 @@ function goWorkbench() {
router.push("/workbench");
}
/** 快速切换学校:守卫负责恢复上下文,进入后落在文章列表 */
function onSchoolChange(code: string) {
if (!code || code === store.schoolCode) return;
router.push(`/sch/${code}/article`);
}
function onSiteChange(siteId: number) {
const site = schoolSites.value.find((s: any) => s.id === siteId);
if (site) {
store.setCurrentSite({ id: site.id, code: site.code, name: site.name });
}
}
function openPortal() {
if (!store.currentSite) return;
if (config.isMock) {
// mock 模式:SPA 内部门户预览(免依赖后端 Thymeleaf 门户)
router.push(`/sch/${store.schoolCode}/portal-preview`);
} else {
window.open(`${portalBase(store.currentSite.code)}/`, "_blank");
}
}
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文 */
watch(
() => store.user,
@@ -263,28 +245,18 @@ watch(
</div>
</div>
<div class="delimiter fl align-middle"></div>
<a class="fl align-middle back-workbench" href="javascript:void(0);" title="返回学校列表" @click="goWorkbench">
<Icon type="ios-arrow-back" />
学校列表
<!-- 返回学校列表:仅超管显示(校级账号固定本校,无需返回入口) -->
<a
v-if="isSuper"
class="fl align-middle back-workbench"
href="javascript:void(0);"
title="返回学校列表"
@click="goWorkbench"
>
<Icon type="ios-undo" :size="18" />
</a>
<div class="delimiter fl align-middle"></div>
<div v-if="isSuper" class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="delimiter fl align-middle"></div>
<!-- 校名徽标 + 快速切换(当前学校常驻 header) -->
<div class="school-badge fl align-middle" v-if="school">
<Icon type="ios-school" :size="16" />
<Select
size="small"
:model-value="school.mainSiteCode"
class="school-select"
transfer
@on-change="onSchoolChange"
>
<Option v-for="s in schoolOptions" :key="s.id" :value="s.code">
{{ s.schName || s.name }}
</Option>
</Select>
</div>
<div class="header-aside fr">
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
@@ -335,10 +307,12 @@ watch(
<div class="path-nav" :style="pathNavStyle">
<Breadcrumb separator=">">
<!-- 面包屑首段 = 学校上下文(常驻) -->
<!-- 超管:校名前多一段可点「学校列表」,与 header 返回图标同效(回工作台切换学校) -->
<BreadcrumbItem v-if="isSuper" to="/workbench">学校列表</BreadcrumbItem>
<!-- 面包屑首段 = 学校上下文(常驻);落点为院系子站时跟出当前站点名 -->
<BreadcrumbItem v-if="school">
<Icon type="ios-school" />
{{ school.schName || school.mainSiteName }}
{{ school.schName || school.mainSiteName }}<template v-if="currentSiteSuffix"> · {{ currentSiteSuffix }}</template>
</BreadcrumbItem>
<template v-for="(item, index) in navPath" :key="index">
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
@@ -347,26 +321,6 @@ watch(
<BreadcrumbItem v-else>{{ item.name }}</BreadcrumbItem>
</template>
</Breadcrumb>
<!-- 站点上下文(校内:主站 + 院系子站;切换后各页面即时跟随) -->
<div class="cms-site-bar fr">
<span class="site-label">站点:</span>
<Select
size="small"
:model-value="store.currentSite?.id"
:placeholder="schoolSites.length ? '选择站点' : '暂无可管理站点'"
style="width: 200px"
transfer
@on-change="onSiteChange"
>
<Option v-for="opt in schoolSites" :key="opt.id" :value="opt.id">
{{ opt.name }}{{ opt.parentSiteId ? "(院系)" : "" }}
</Option>
</Select>
<Button size="small" type="primary" ghost style="margin-left: 10px" @click="openPortal">
<Icon type="ios-eye" />
预览门户
</Button>
</div>
</div>
<transition name="fade_apps">
@@ -401,37 +355,15 @@ watch(
</template>
<style scoped lang="scss">
/* 返回学校列表 + 校名徽标:header 常驻学校上下文(docs/09 §3.3) */
/* 超管返回学校列表图标:header 常驻入口(校级账号不渲染) */
.back-workbench {
color: #fff;
font-size: 13px;
display: inline-flex;
align-items: center;
&:hover {
opacity: 0.85;
}
}
.school-badge {
display: inline-flex;
align-items: center;
color: #fff;
.school-select {
width: 180px;
margin-left: 6px;
}
}
/* 站点切换条:挂在面包屑条右侧,沿用 path-nav 白底视觉 */
.cms-site-bar {
display: flex;
align-items: center;
height: 56px;
margin-right: 24px;
.site-label {
color: #777;
font-size: 13px;
}
}
.no-site-tip {
height: 60vh;
display: flex;
+3 -1
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ThemeVo } from "@/types/cms";
import { assetUrl } from "@/api/module/cms";
/**
* 主题卡片单选(方案 §6.3):建站向导①与站点编辑共用。
* 展示 screenshot + 名称/描述;选中高亮;卡片布局在父级控制宽度。
* 截图 src 经 assetUrl 补 serviceHost 前缀(生产 context-path=/yschcms,裸 /api 会 404 破图)。
*/
defineProps<{
themes: ThemeVo[];
@@ -25,7 +27,7 @@ const emit = defineEmits<{
:class="{ selected: modelValue === t.name }"
@click="emit('update:modelValue', t.name)"
>
<img class="theme-shot" :src="t.screenshotUrl" :alt="t.title" loading="lazy" />
<img class="theme-shot" :src="assetUrl(t.screenshotUrl)" :alt="t.title" loading="lazy" />
<div class="theme-info">
<div class="theme-title">
{{ t.title }}
+40 -24
View File
@@ -88,7 +88,8 @@ const form = ref({
});
const rules = {
schId: [{ required: true, message: "请选择学校(来自用户中心)", trigger: "change" }],
// 必须声明 type:"number":schId 是 UserBase 组 ID(数字),缺省按 string 校验会把已选中的数字 ID 判为不通过
schId: [{ required: true, type: "number", message: "请选择学校(来自用户中心)", trigger: "change" }],
code: [
{ required: true, message: "主站编码不得为空", trigger: "blur" },
{ pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, message: "仅限小写字母/数字/短横线", trigger: "blur" },
@@ -125,11 +126,10 @@ async function loadThemes() {
function onSchoolPick(id: number) {
const opt = options.value.find(o => o.id === id);
// 校名/编码/名称始终跟随当前选中学校带出,避免切换学校后残留上一所的默认值造成“已选好”的假象
form.value.schName = opt?.name || "";
if (!form.value.name && opt) {
if (opt) {
form.value.name = `${opt.name}门户网站`;
}
if (!form.value.code && opt) {
form.value.code = `sch-${opt.id}`;
}
}
@@ -171,27 +171,30 @@ onMounted(run);
<template>
<div class="workbench">
<header class="wb-header fix">
<h4 class="fl align-middle">云海CMS · 学校工作台</h4>
<div class="wb-header-aside fr">
<Button v-if="isSuper" size="small" class="wb-btn" @click="goAllSites" :disabled="!cards.length">
<Icon type="ios-globe" />
全部站点总览
</Button>
<Button v-if="isSuper" size="small" type="primary" class="wb-btn" @click="openAdd">
<Icon type="ios-add" />
添加学校
</Button>
<span class="delimiter align-middle"></span>
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle wb-avatar" @click="linkUserInfo" />
<a class="name fl align-middle" href="javascript:void(0);" @click="linkUserInfo">{{ userInfo.name }}</a>
<span class="delimiter align-middle"></span>
<a href="javascript:;" @click="logout" title="退出登陆">
<header class="wb-header">
<h4>云海CMS · 学校工作台</h4>
<div class="wb-header-aside">
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="wb-avatar" @click="linkUserInfo" />
<a class="name" href="javascript:void(0);" @click="linkUserInfo">{{ userInfo.name }}</a>
<span class="delimiter"></span>
<a href="javascript:;" class="logout" @click="logout" title="退出登陆">
<i class="iconfont icon-logout"></i>
</a>
</div>
</header>
<!-- 操作工具行(Notice search-wrap 同款灰底条):按钮居左,账号区保持在深色 header 右侧 -->
<div class="wb-toolbar" v-if="isSuper">
<Button class="wb-btn" @click="goAllSites" :disabled="!cards.length">
<Icon type="ios-globe" />
全部站点总览
</Button>
<Button type="primary" class="wb-btn" @click="openAdd">
<Icon type="ios-add" />
添加学校
</Button>
</div>
<div class="wb-body">
<div class="wb-title">
<h3>我的学校</h3>
@@ -225,7 +228,7 @@ onMounted(run);
</div>
<div v-else class="empty-state">
<Icon type="ios-school" :size="48" />
<p v-if="isSuper">还没有学校 —— 点击右上角「添加学校」绑定用户中心学校并创建门户主站。</p>
<p v-if="isSuper">还没有学校 —— 点击上方「添加学校」绑定用户中心学校并创建门户主站。</p>
<p v-else>暂无可管理的学校站点,请联系平台管理员。</p>
<Button v-if="isSuper" type="primary" @click="openAdd">
<Icon type="ios-add" />
@@ -279,6 +282,7 @@ onMounted(run);
}
.wb-header {
height: 56px;
flex-shrink: 0;
background: #1f2a3a;
color: #fff;
display: flex;
@@ -288,9 +292,9 @@ onMounted(run);
color: #fff;
font-size: 16px;
margin: 0;
line-height: 56px;
}
.wb-header-aside {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
@@ -307,12 +311,24 @@ onMounted(run);
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
}
.wb-btn {
// 深色 header 上按钮保持默认浅色块
.logout {
color: #fff;
display: inline-flex;
align-items: center;
}
}
}
/* 工具行:Notice front .search-wrap 同款(#f7f7f7 底 + 10px 16px 内边距 ≈ 52px 高),按钮居左 */
.wb-toolbar {
flex-shrink: 0;
background: #f7f7f7;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.wb-body {
flex: 1;
overflow-y: auto;
+43 -4
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import Utils from "@/utils/index";
import { siteApi, themeApi, themePreviewUrl } from "@/api/module/cms";
import { assetUrl, siteApi, themeApi, themePreviewUrl } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { SiteVo, ThemeVo } from "@/types/cms";
@@ -18,6 +18,8 @@ import SiteWizard from "@/views/cms/SiteWizard.vue";
* 站点管理(方案 §9.1 + §6.2 主题选择增强)。
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点(主站+院系子站);
* 超管可经工作台「全部站点总览」(?scope=all)查看全局列表。
* 行「进入管理」= 院系子站切换入口(壳面包屑条切换器已按 UX 收敛移除):
* 切 store.currentSite 落文章列表,跨校行先重建学校上下文;当前站点行带「当前」标记。
* 新建 → 三步向导(选模板→微调预览→确认);已建站点 → 编辑弹窗内
* 主题卡片单选 + 参数微调表单,保存带 themeParams(服务端合并进 config_json,方案 §5.3)。
* config_json 结构化编辑见 SiteConfigForm(docs/09 §3.1):
@@ -25,6 +27,7 @@ import SiteWizard from "@/views/cms/SiteWizard.vue";
*/
const store = useCmsStore();
const route = useRoute();
const router = useRouter();
/** 超管全局视图(工作台入口带入 scope=all) */
const scopeAll = computed(() => route.query.scope === "all" && store.isSuperAdmin);
@@ -43,7 +46,18 @@ const themeMeta = computed(() => {
const columns = [
{ title: "站点编码", key: "code", width: 140 },
{ title: "站点名称", key: "name", minWidth: 200, tooltip: true },
{
title: "站点名称",
key: "name",
minWidth: 200,
tooltip: true,
render: (h: any, p: any) =>
h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
h("span", p.row.name),
// 当前操作站点标记:壳面包屑校名后缀同源(store.currentSite)
p.row.id === store.siteId ? h(Tag, { color: "success" }, () => "当前") : null,
]),
},
{ title: "学校", key: "schName", width: 150, tooltip: true },
{
title: "所属主站",
@@ -60,7 +74,7 @@ const columns = [
const t = themeMeta.value[p.row.theme || "default"];
return h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
t
? h("img", { src: 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", t ? `${t.title}(${p.row.theme})` : p.row.theme || "default"),
]);
@@ -84,6 +98,7 @@ const columns = [
fixed: "right",
align: "center",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "进入管理", "icon-apps", () => enterManage(p.row), p.row.id === store.siteId),
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)),
],
@@ -219,6 +234,30 @@ function openPortal(row: SiteVo) {
window.open(`${config.serviceHost}/portal/${row.code}/`, "_blank");
}
/** 行级「进入管理」(docs/09 §3.3 子站管理入口):切当前操作站点后落文章列表;
* 全局视图跨校行先重建学校上下文(面包屑/左侧菜单跟随),子站行向上找主站行定学校壳 */
function enterManage(row: SiteVo) {
const main = row.parentSiteId ? rows.value.find(s => s.id === row.parentSiteId) : row;
if (!main) {
Message.warning("未找到所属主站,无法进入管理");
return;
}
if (main.code !== store.schoolCode) {
store.enterSchool(
{
schId: row.schId,
schName: row.schName,
mainSiteId: main.id,
mainSiteCode: main.code,
mainSiteName: main.name,
},
rows.value.filter(s => s.id === main.id || s.parentSiteId === main.id)
);
}
store.setCurrentSite({ id: row.id, code: row.code, name: row.name });
router.push(`/sch/${main.code}/article`);
}
function openEditPreview() {
previewVisible.value = true;
}