feat(W1): 皮肤包管理页-卡片网格+上传/派生/编辑/删除,ThemeApi五方法,路由/入口/mock modId=7,ThemeVo增source/inUseCount/editable/usedBySiteNames,T-W1-21..24 绿56/56
build / build (push) Successful in 2m16s
build / build (push) Successful in 2m16s
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
|
||||
import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
|
||||
|
||||
/**
|
||||
* 皮肤包管理(W1,方案 §5.2/§5.4):内置只读(仅预览)、外部可管(上传/派生/编辑/删除)。
|
||||
* 入口:站点管理按钮(平台模块表注册前的兜底,R3)+ 平台菜单(modId=7)。
|
||||
*/
|
||||
|
||||
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[]>([]);
|
||||
|
||||
const deriveVisible = ref(false);
|
||||
const deriveForm = ref({ source: "default", name: "" });
|
||||
|
||||
const metaVisible = ref(false);
|
||||
const metaTheme = ref<ThemeVo | null>(null);
|
||||
const metaTitle = ref("");
|
||||
const metaParams = ref<ThemeParamVo[]>([]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
themeApi
|
||||
.upload(file)
|
||||
.send()
|
||||
.then(() => {
|
||||
Message.success("上传成功,皮肤包已注册(免重启)");
|
||||
run();
|
||||
})
|
||||
.catch(() => {
|
||||
// 错误详情由 alova 拦截器统一弹出(校验报告逐条展示)
|
||||
});
|
||||
return false; // 阻断 Upload 组件自动上传,改走手动
|
||||
}
|
||||
|
||||
function openDerive() {
|
||||
deriveForm.value = { source: themes.value[0]?.name || "default", name: "" };
|
||||
deriveVisible.value = true;
|
||||
}
|
||||
|
||||
async function doDerive() {
|
||||
if (!/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/.test(deriveForm.value.name)) {
|
||||
Message.warning("新包名仅限小写字母/数字/短横线,且以字母开头、数字或字母结尾");
|
||||
return;
|
||||
}
|
||||
await themeApi.derive(deriveForm.value.source, deriveForm.value.name).send();
|
||||
Message.success("派生成功,新皮肤包可编辑可删除");
|
||||
deriveVisible.value = false;
|
||||
run();
|
||||
}
|
||||
|
||||
function openMeta(theme: ThemeVo) {
|
||||
metaTheme.value = theme;
|
||||
metaTitle.value = theme.title;
|
||||
metaParams.value = (theme.params || []).map(p => ({ ...p }));
|
||||
metaVisible.value = true;
|
||||
}
|
||||
|
||||
async function doSaveMeta() {
|
||||
await themeApi
|
||||
.updateMeta(metaTheme.value!.name, {
|
||||
title: metaTitle.value,
|
||||
params: metaParams.value,
|
||||
})
|
||||
.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-manage">
|
||||
<div class="toolbar">
|
||||
<Upload :before-upload="beforeUpload" accept=".zip" :show-upload-list="false">
|
||||
<Button type="primary" icon="ios-cloud-upload-outline">上传皮肤包(zip)</Button>
|
||||
</Upload>
|
||||
<Button icon="ios-copy" @click="openDerive">从现有包派生</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>
|
||||
<template v-if="t.source === 'external'">
|
||||
<Tooltip
|
||||
:content="(t.inUseCount || 0) > 0
|
||||
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
|
||||
: '外部包可编辑参数 schema'"
|
||||
placement="top"
|
||||
transfer
|
||||
>
|
||||
<Button size="small" @click="openMeta(t)">编辑</Button>
|
||||
</Tooltip>
|
||||
<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>
|
||||
|
||||
<!-- 预览弹窗(复用 ThemePreviewFrame,内容源下拉同向导) -->
|
||||
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" width="860px">
|
||||
<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 :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
|
||||
</Modal>
|
||||
|
||||
<!-- 派生弹窗 -->
|
||||
<Modal v-model="deriveVisible" title="从现有包派生新皮肤" :mask-closable="false" width="480px">
|
||||
<Form :label-width="100">
|
||||
<FormItem label="派生源">
|
||||
<Select v-model="deriveForm.source" style="width: 280px">
|
||||
<Option v-for="t in themes" :key="t.name" :value="t.name">{{ t.title }}({{ t.name }})</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="新包名" required>
|
||||
<Input v-model="deriveForm.name" placeholder="如 my-skin(小写字母/数字/短横线)" style="width: 280px" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="deriveVisible = false">取消</Button>
|
||||
<Button type="primary" @click="doDerive">派生</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 编辑弹窗(外部包:title + 参数 schema 默认值) -->
|
||||
<Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px">
|
||||
<Form :label-width="110">
|
||||
<FormItem label="标题">
|
||||
<Input v-model="metaTitle" :maxlength="64" />
|
||||
</FormItem>
|
||||
<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>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="metaVisible = false">取消</Button>
|
||||
<Button type="primary" @click="doSaveMeta">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-manage {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user