Files
YschcmsFront/src/views/cms/ThemeManage.vue
T
chenxing 6ee9ab5684
build / build (push) Successful in 2m43s
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走查全过
2026-09-10 11:11:23 +08:00

472 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
import { useCmsStore } from "@/store";
/**
* 皮肤包管理(W1 + 任务二/四,2026-09-08):平台级全屏页(/theme-manage,超管专属,
* 工作台按钮进入、不再挂左侧菜单)。内置包内容只读但显示名/描述可改(.meta 覆盖层)、
* 外部包可管(上传/派生/编辑/删除)。标识名(name,即主题路径/URL 段)仅创建时可定,创建后不可改。
*/
const router = useRouter();
const store = useCmsStore();
const themes = ref<ThemeVo[]>([]);
const loading = ref(false);
const previewVisible = ref(false);
const previewTheme = ref<ThemeVo | null>(null);
const previewSitePath = ref("");
const contentSources = ref<SiteVo[]>([]);
// 上传表单(任务四:zip + 显示名称/描述)
const uploadVisible = ref(false);
const uploading = ref(false);
const uploadFile = ref<File | null>(null);
const uploadForm = ref({ title: "", description: "" });
// 添加皮肤包表单(任务四:派生升级——源包 + 标识名(主题路径) + 显示名称/描述一步建成)
const addVisible = ref(false);
const adding = ref(false);
const addForm = ref({ source: "default", name: "", title: "", description: "" });
const metaVisible = ref(false);
const metaTheme = ref<ThemeVo | null>(null);
const metaTitle = ref("");
const metaDescription = ref("");
const metaParams = ref<ThemeParamVo[]>([]);
// 非超管深链兜底(引导期守卫放行,user 装载后弹回工作台)
watch(
() => store.user,
u => {
if (u && !store.isSuperAdmin) {
router.replace("/workbench");
}
},
{ immediate: true }
);
async function run() {
loading.value = true;
try {
themes.value = (await themeApi.list().send()) || [];
} finally {
loading.value = false;
}
}
onMounted(run);
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览) */
async function openPreview(theme: ThemeVo) {
previewTheme.value = theme;
if (!contentSources.value.length) {
try {
contentSources.value = ((await siteApi.list().send()) || []).filter(s => s.status === 1);
} catch {
contentSources.value = [];
}
}
const first = contentSources.value[0];
previewSitePath.value = first ? first.path || first.code : "";
previewVisible.value = true;
}
function onContentSourceChange(path: string) {
previewSitePath.value = path;
}
/* ===== 上传表单(任务四):选 zip + 命名,提交走手动上传通道 ===== */
function openUpload() {
uploadFile.value = null;
uploadForm.value = { title: "", description: "" };
uploadVisible.value = true;
}
function onPickUploadFile(file: File) {
uploadFile.value = file;
return false; // 阻断 Upload 自动上传,统一由「上传」按钮提交
}
async function doUpload() {
if (!uploadFile.value) {
Message.warning("请先选择皮肤包 zip 文件");
return;
}
uploading.value = true;
try {
await themeApi
.upload(uploadFile.value, {
title: uploadForm.value.title || undefined,
description: uploadForm.value.description,
})
.send();
Message.success("上传成功,皮肤包已注册(免重启)");
uploadVisible.value = false;
run();
} catch {
// 错误详情由 alova 拦截器统一弹出(校验报告逐条展示)
} finally {
uploading.value = false;
}
}
/* ===== 添加皮肤包(任务四):从现有包复制 + 命名,一步建成 ===== */
function openAdd() {
addForm.value = { source: themes.value[0]?.name || "default", name: "", title: "", description: "" };
addVisible.value = true;
}
async function doAdd() {
if (!/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/.test(addForm.value.name)) {
Message.warning("标识名仅限小写字母/数字/短横线,且以字母开头、数字或字母结尾");
return;
}
adding.value = true;
try {
await themeApi
.derive(addForm.value.source, addForm.value.name, {
title: addForm.value.title || undefined,
description: addForm.value.description,
})
.send();
Message.success("添加成功,新皮肤包可编辑可删除");
addVisible.value = false;
run();
} finally {
adding.value = false;
}
}
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层) ===== */
function openMeta(theme: ThemeVo) {
metaTheme.value = theme;
metaTitle.value = theme.title;
metaDescription.value = theme.description || "";
metaParams.value = (theme.params || []).map(p => ({ ...p }));
metaVisible.value = true;
}
async function doSaveMeta() {
const builtin = metaTheme.value?.source === "builtin";
await themeApi
.updateMeta(metaTheme.value!.name, {
title: metaTitle.value,
description: metaDescription.value,
// 内置包参数 schema 不可改(后端强校验),不传即不改动
params: builtin ? undefined : metaParams.value,
})
.send();
Message.success("已保存,引用该皮肤的站点门户与预览立即生效");
metaVisible.value = false;
run();
}
/** 内置包恢复默认显示名/描述(清空 .meta 覆盖层) */
async function doResetBuiltinMeta() {
MyConfirm({
title: "恢复默认名称",
msg: `确定恢复「${metaTheme.value?.name}」的默认显示名称与描述吗?`,
info: "将清除改名覆盖层,恢复包内声明的原始名称",
async onOk() {
await themeApi.updateMeta(metaTheme.value!.name, { title: "", description: "" }).send();
Message.success("已恢复默认名称");
metaVisible.value = false;
run();
},
});
}
function askRemove(theme: ThemeVo) {
if (theme.source === "builtin") {
Message.warning("内置包不可删除");
return;
}
if ((theme.inUseCount || 0) > 0) {
Message.warning(`皮肤包正被站点使用,无法删除:${(theme.usedBySiteNames || []).join("、")}`);
return;
}
MyConfirm({
title: "删除皮肤包",
msg: `确定删除外部皮肤包「${theme.title}(${theme.name})」吗?`,
info: "删除后目录移除并热注销;内置包不受影响",
async onOk() {
await themeApi.remove(theme.name).send();
Message.success("删除成功");
run();
},
});
}
const builtinCount = computed(() => themes.value.filter(t => t.source === "builtin").length);
</script>
<template>
<div class="theme-page">
<WorkbenchHeader title="云海CMS · 皮肤包管理" back-to="/workbench" />
<div class="theme-body">
<div class="toolbar">
<Button type="primary" icon="ios-cloud-upload-outline" @click="openUpload">上传皮肤包</Button>
<Button icon="ios-copy" @click="openAdd">添加皮肤包</Button>
<Button icon="ios-refresh" @click="run">刷新</Button>
<span class="summary">
内置 {{ builtinCount }} 个 · 外部 {{ themes.length - builtinCount }} 个(内置可改名,外部可管;标识名即主题路径,创建后不可改)
</span>
</div>
<Spin v-if="loading" size="large" style="display: block; margin: 60px auto" />
<div v-else class="cards">
<div v-for="t in themes" :key="t.name" class="card">
<div class="shot">
<img :src="assetUrl(t.screenshotUrl)" :alt="t.title" />
<Tag class="badge" :color="t.source === 'builtin' ? 'default' : 'success'">
{{ t.source === "builtin" ? "内置" : "外部" }}
</Tag>
</div>
<div class="meta">
<p class="title">{{ t.title }}<span class="name">({{ t.name }})</span></p>
<p class="desc">{{ t.description || "—" }}</p>
<p class="stats">参数 {{ (t.params || []).length }} 个 · 被用 {{ t.inUseCount || 0 }} 站点</p>
</div>
<div class="actions">
<Button size="small" @click="openPreview(t)">预览</Button>
<Tooltip
:content="t.source === 'builtin'
? '内置包内容只读,可改显示名称与描述'
: (t.inUseCount || 0) > 0
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
: '外部包可编辑参数 schema'"
placement="top"
transfer
>
<Button size="small" @click="openMeta(t)">编辑</Button>
</Tooltip>
<template v-if="t.source === 'external'">
<Tooltip
:content="(t.inUseCount || 0) > 0
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
: '外部包可删除'"
placement="top"
transfer
>
<Button
size="small"
type="error"
ghost
:disabled="(t.inUseCount || 0) > 0"
@click="askRemove(t)"
>
删除
</Button>
</Tooltip>
</template>
</div>
</div>
</div>
</div>
<!-- 上传表单弹窗(任务四):zip + 显示名称/描述 -->
<Modal v-model="uploadVisible" title="上传皮肤包" :mask-closable="false" width="520px">
<Form :label-width="100">
<FormItem label="皮肤包 zip" required>
<Upload :before-upload="onPickUploadFile" accept=".zip" :show-upload-list="false">
<Button icon="ios-folder-open">选择文件</Button>
</Upload>
<p class="form-tip">
{{ uploadFile ? `已选择:${uploadFile.name}` : "尚未选择文件(结构:zip 顶层唯一文件夹,名字与 theme.json 的 name 一致)" }}
</p>
</FormItem>
<FormItem label="显示名称">
<Input v-model="uploadForm.title" :maxlength="64" placeholder="选填,留空沿用包内 theme.json 的标题" />
</FormItem>
<FormItem label="描述">
<Input v-model="uploadForm.description" :maxlength="128" placeholder="选填" />
</FormItem>
</Form>
<template #footer>
<Button @click="uploadVisible = false">取消</Button>
<Button type="primary" :loading="uploading" :disabled="!uploadFile" @click="doUpload">上传</Button>
</template>
</Modal>
<!-- 添加皮肤包(任务四):源包 + 标识名(主题路径) + 命名,一步建成 -->
<Modal v-model="addVisible" title="添加皮肤包" :mask-closable="false" width="520px">
<Form :label-width="100">
<FormItem label="复制自">
<Select v-model="addForm.source" style="width: 100%">
<Option v-for="t in themes" :key="t.name" :value="t.name">{{ t.title }}({{ t.name }})</Option>
</Select>
<p class="form-tip">新包完整复制源包的模板与参数,可独立编辑、删除。</p>
</FormItem>
<FormItem label="标识名" required>
<Input v-model="addForm.name" placeholder="如 mint-green(小写字母/数字/短横线)" />
<p class="form-tip">即主题路径(URL 中的主题段),创建后不可修改。</p>
</FormItem>
<FormItem label="显示名称">
<Input v-model="addForm.title" :maxlength="64" placeholder="选填,如 薄荷绿;留空沿用源包名称" />
</FormItem>
<FormItem label="描述">
<Input v-model="addForm.description" :maxlength="128" placeholder="选填" />
</FormItem>
</Form>
<template #footer>
<Button @click="addVisible = false">取消</Button>
<Button type="primary" :loading="adding" @click="doAdd">添加</Button>
</template>
</Modal>
<!-- 预览弹窗(全屏展示,复用 ThemePreviewFrame,内容源下拉同向导) -->
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" fullscreen footer-hide>
<div class="preview-full">
<div class="preview-source">
<label>内容源站点</label>
<Select :model-value="previewSitePath" style="width: 280px" @on-change="onContentSourceChange">
<Option v-for="s in contentSources" :key="s.id" :value="s.path || s.code">
{{ s.name }}({{ s.path || s.code }})
</Option>
</Select>
</div>
<ThemePreviewFrame fill :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
</div>
</Modal>
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema) -->
<Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px">
<Alert v-if="metaTheme?.source === 'builtin'" type="info" show-icon style="margin-bottom: 12px">
内置包内容(模板/资产)不可修改,仅显示名称与描述生效;标识名({{ metaTheme?.name }})不可改。
</Alert>
<Form :label-width="110">
<FormItem label="显示名称">
<Input v-model="metaTitle" :maxlength="64" />
</FormItem>
<FormItem label="描述">
<Input v-model="metaDescription" :maxlength="128" />
</FormItem>
<template v-if="metaTheme?.source !== 'builtin'">
<FormItem v-for="p in metaParams" :key="p.key" :label="p.label">
<ColorPicker v-if="p.type === 'color'" v-model="p.defaultValue" />
<Input v-else v-model="p.defaultValue" :disabled="p.type === 'select'" />
</FormItem>
</template>
</Form>
<template #footer>
<Button v-if="metaTheme?.source === 'builtin'" style="float: left" @click="doResetBuiltinMeta">
恢复默认名称
</Button>
<Button @click="metaVisible = false">取消</Button>
<Button type="primary" @click="doSaveMeta">保存</Button>
</template>
</Modal>
</div>
</template>
<style scoped lang="scss">
.theme-page {
height: 100%;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
.theme-body {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
}
/* 全屏预览:弹窗 body 高度确定(top:51px/bottom:0),内容源行占自然高、预览框吃满余量 */
.preview-full {
height: 100%;
display: flex;
flex-direction: column;
}
.preview-source {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
label {
color: #666;
font-size: 13px;
}
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
.summary {
color: #999;
font-size: 12px;
}
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.card {
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 6px;
overflow: hidden;
.shot {
position: relative;
height: 150px;
background: #f0f2f5;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.badge {
position: absolute;
top: 8px;
right: 8px;
}
}
.meta {
padding: 10px 12px 4px;
.title {
font-weight: 600;
margin: 0 0 4px;
.name {
color: #999;
font-weight: 400;
font-size: 12px;
}
}
.desc {
color: #888;
font-size: 12px;
margin: 0 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stats {
color: #999;
font-size: 12px;
margin: 0 0 6px;
}
}
.actions {
padding: 0 12px 12px;
display: flex;
gap: 8px;
}
}
.form-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
</style>