472 lines
18 KiB
Vue
472 lines
18 KiB
Vue
<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>
|