315 lines
12 KiB
Vue
315 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, reactive, ref, watch } from "vue";
|
||
import { Message } from "view-ui-plus";
|
||
import { siteApi, themeApi } from "@/api/module/cms";
|
||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson } from "@/utils/themeParamForm";
|
||
import { previewPortalUrl } from "@/utils/sitePath";
|
||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
|
||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
|
||
|
||
/**
|
||
* 新建站点三步向导(方案 §6.1):①选模板 → ②微调+实时预览 → ③站点信息+确认。
|
||
* 预览内容源:当前启用站点(默认第一个);无启用站点时隐藏预览并提示。
|
||
* 向导中途关闭不产生任何数据;确认后创建成功才落库。
|
||
*/
|
||
const props = defineProps<{
|
||
visible: boolean;
|
||
sites: SiteVo[];
|
||
/** B1 建站权限拆分:校级锁定在本校主站下建院系子站(上级禁改、学校信息由服务端继承) */
|
||
lockParentId?: number;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: "update:visible", v: boolean): void;
|
||
(e: "created"): void;
|
||
}>();
|
||
|
||
const step = ref(1);
|
||
const themes = ref<ThemeVo[]>([]);
|
||
const themesLoading = ref(false);
|
||
|
||
const form = reactive({
|
||
code: "",
|
||
parentSiteId: undefined as number | undefined,
|
||
schId: undefined as number | undefined,
|
||
schName: "",
|
||
name: "",
|
||
status: 1,
|
||
configJson: "",
|
||
});
|
||
|
||
const themeName = ref("default");
|
||
const themeParams = ref<FormModelValue>({});
|
||
const sourceSitePath = ref<string>("");
|
||
const saving = ref(false);
|
||
|
||
/** 内容源候选:启用中的站点(顶层/子站皆可作内容源) */
|
||
const sourceSites = computed(() => props.sites.filter(s => s.status === 1));
|
||
const selectedTheme = computed(() => themes.value.find(t => t.name === themeName.value));
|
||
const canPreview = computed(() => !!sourceSitePath.value && !!themeName.value);
|
||
|
||
const previewTick = ref(0);
|
||
const previewParams = ref<FormModelValue>({});
|
||
|
||
/** 锁定模式:上级仅本校主站;否则全部顶层站可选 */
|
||
const parentOptions = computed(() =>
|
||
props.lockParentId
|
||
? props.sites.filter(s => s.id === props.lockParentId && !s.parentSiteId)
|
||
: props.sites.filter(s => !s.parentSiteId)
|
||
);
|
||
|
||
/** W4(G5):门户地址预览——随上级与编码输入联动(子站=上级地址/编码;顶层=编码) */
|
||
const wizardParent = computed(() =>
|
||
form.parentSiteId ? props.sites.find(s => s.id === form.parentSiteId) : undefined
|
||
);
|
||
const previewUrl = computed(() =>
|
||
previewPortalUrl(wizardParent.value?.path ?? wizardParent.value?.code, form.code)
|
||
);
|
||
|
||
function debouncePreview() {
|
||
previewParams.value = { ...themeParams.value };
|
||
previewTick.value = Date.now();
|
||
}
|
||
|
||
watch(themeName, async () => {
|
||
const t = selectedTheme.value;
|
||
if (t) themeParams.value = formModelFromTheme(t);
|
||
debouncePreview();
|
||
});
|
||
|
||
watch(themeParams, debouncePreview, { deep: true });
|
||
|
||
watch(
|
||
() => props.visible,
|
||
async v => {
|
||
if (!v) return;
|
||
step.value = 1;
|
||
Object.assign(form, { code: "", parentSiteId: props.lockParentId, schId: undefined, schName: "", name: "", status: 1, configJson: "" });
|
||
sourceSitePath.value = sourceSites.value[0]?.path || sourceSites.value[0]?.code || "";
|
||
if (!themes.value.length) await loadThemes();
|
||
themeName.value = "default";
|
||
const t = selectedTheme.value;
|
||
themeParams.value = t ? formModelFromTheme(t) : {};
|
||
debouncePreview();
|
||
},
|
||
);
|
||
|
||
async function loadThemes() {
|
||
themesLoading.value = true;
|
||
try {
|
||
themes.value = (await themeApi.list().send()) || [];
|
||
if (!themes.value.some(t => t.name === "default") && themes.value.length) {
|
||
themeName.value = themes.value[0].name;
|
||
}
|
||
} finally {
|
||
themesLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function pickTheme(name: string) {
|
||
themeName.value = name;
|
||
}
|
||
|
||
function next() {
|
||
// 站点信息表单在第 3 步才挂载,步进本身不校验;校验放在「创建站点」时(见 createSite)
|
||
step.value += 1;
|
||
}
|
||
|
||
function prev() {
|
||
if (step.value > 1) step.value -= 1;
|
||
}
|
||
|
||
const formRef = ref();
|
||
const configFormRef = ref();
|
||
const rules = {
|
||
code: [
|
||
{ required: true, message: "站点编码不得为空", trigger: "blur" },
|
||
{ pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, message: "仅限小写字母/数字/短横线", trigger: "blur" },
|
||
],
|
||
name: [{ required: true, message: "站点名称不得为空", trigger: "blur" }],
|
||
};
|
||
|
||
async function createSite() {
|
||
const valid = await formRef.value?.validate();
|
||
if (!valid) return;
|
||
const configValid = await configFormRef.value?.validate();
|
||
if (!configValid) return;
|
||
saving.value = true;
|
||
try {
|
||
const configJson = mergeThemeParamsIntoConfigJson(form.configJson || "", selectedTheme.value!, themeParams.value);
|
||
await siteApi
|
||
.save({
|
||
code: form.code,
|
||
parentSiteId: form.parentSiteId || 0,
|
||
// B1:校级锁定模式学校信息留空,服务端校验后继承主站(防伪造)
|
||
schId: props.lockParentId ? undefined : form.schId,
|
||
schName: props.lockParentId ? undefined : form.schName || undefined,
|
||
name: form.name,
|
||
theme: themeName.value,
|
||
configJson,
|
||
status: form.status,
|
||
})
|
||
.send();
|
||
Message.success("站点创建成功!");
|
||
emit("update:visible", false);
|
||
emit("created");
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Modal
|
||
:model-value="visible"
|
||
:title="`${props.lockParentId ? '新建院系子站' : '新建站点'}向导 · 第 ${step} / 3 步`"
|
||
:mask-closable="false"
|
||
width="980px"
|
||
@update:model-value="emit('update:visible', $event)"
|
||
@on-cancel="emit('update:visible', false)"
|
||
>
|
||
<div class="wizard">
|
||
<!-- ① 选模板 -->
|
||
<div v-if="step === 1" class="wizard-step">
|
||
<p class="step-tip">选择一个皮肤包作为网站风格(后续可在站点管理中微调主色等参数)。</p>
|
||
<ThemePicker v-if="themes.length" :themes="themes" :model-value="themeName" @update:model-value="pickTheme" />
|
||
<p v-else-if="themesLoading" class="loading">主题列表加载中…</p>
|
||
<p v-else class="loading">主题列表为空 —— 请检查后端 /api/theme/list 是否可用。</p>
|
||
</div>
|
||
|
||
<!-- ② 微调 + 实时预览 -->
|
||
<div v-else-if="step === 2" class="wizard-step step2">
|
||
<div class="left">
|
||
<FormItem label="预览内容源" :label-width="90">
|
||
<Select v-model="sourceSitePath" style="width: 260px">
|
||
<Option v-for="s in sourceSites" :key="s.id" :value="s.path || s.code">{{ s.name }}({{ s.path || s.code }})</Option>
|
||
</Select>
|
||
</FormItem>
|
||
<ThemeParamForm :theme="selectedTheme" v-model="themeParams" />
|
||
</div>
|
||
<div class="right">
|
||
<ThemePreviewFrame
|
||
v-if="canPreview"
|
||
:site-path="sourceSitePath"
|
||
:theme="themeName"
|
||
:key="previewTick"
|
||
:theme-params="previewParams"
|
||
/>
|
||
<div v-else class="no-preview">
|
||
暂无启用站点可作为内容源 —— 请先在站点管理创建一个启用站点,或初始化演示数据。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ③ 站点信息 + 确认 -->
|
||
<div v-else class="wizard-step step3">
|
||
<p class="step-tip">
|
||
站点将使用皮肤包 <b>{{ selectedTheme?.title }}</b>({{ themeName }}),并携带上述微调参数。
|
||
</p>
|
||
<Form ref="formRef" :model="form" :rules="rules" :label-width="110" class="info-form">
|
||
<FormItem label="站点编码" prop="code">
|
||
<Input v-model="form.code" placeholder="URL 路径用,如 demo" style="width: 320px" />
|
||
</FormItem>
|
||
<FormItem label="站点名称" prop="name">
|
||
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" style="width: 320px" />
|
||
</FormItem>
|
||
<FormItem label="门户地址预览">
|
||
<span class="portal-preview-url">{{ previewUrl || "输入编码后自动生成" }}</span>
|
||
<div class="pbu-tip">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达。</div>
|
||
</FormItem>
|
||
<FormItem label="上级站点">
|
||
<Select v-model="form.parentSiteId" :disabled="!!props.lockParentId" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
|
||
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
|
||
</Select>
|
||
<div v-if="props.lockParentId" class="pbu-tip">校级角色仅可在本校主站下创建院系子站,学校信息自动继承主站。</div>
|
||
</FormItem>
|
||
<FormItem v-if="!props.lockParentId" label="学校ID">
|
||
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
|
||
<!-- B-8:顶层站 schId 必填(校级角色按此匹配管理权限);院系子站留空自动继承主站 -->
|
||
<div class="pbu-tip">
|
||
顶层站点必填(否则建成即校级角色不可管理);选了上级站点的院系子站可留空,自动继承主站。
|
||
</div>
|
||
</FormItem>
|
||
<FormItem v-if="!props.lockParentId" label="学校名称">
|
||
<Input v-model="form.schName" :maxlength="128" style="width: 320px" />
|
||
</FormItem>
|
||
<FormItem label="状态">
|
||
<RadioGroup v-model="form.status">
|
||
<Radio :label="1">启用</Radio>
|
||
<Radio :label="0">停用</Radio>
|
||
</RadioGroup>
|
||
</FormItem>
|
||
</Form>
|
||
<!-- 站点配置结构化表单(docs/09 §3.1,替代原 JSON textarea) -->
|
||
<SiteConfigForm ref="configFormRef" v-model="form.configJson" class="cfg-form" />
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<Button v-if="step > 1" @click="prev">上一步</Button>
|
||
<Button v-if="step < 3" type="primary" :disabled="!themes.length" @click="next">下一步</Button>
|
||
<Button v-else type="primary" :loading="saving" @click="createSite">创建站点</Button>
|
||
</template>
|
||
</Modal>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.wizard-step {
|
||
min-height: 300px;
|
||
}
|
||
.step-tip {
|
||
color: #888;
|
||
font-size: 13px;
|
||
margin: 0 0 12px;
|
||
}
|
||
.pbu-tip {
|
||
color: #888;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
margin-top: 4px;
|
||
}
|
||
.loading {
|
||
color: #999;
|
||
font-size: 13px;
|
||
text-align: center;
|
||
padding: 60px 0;
|
||
}
|
||
.step2 {
|
||
display: flex;
|
||
gap: 20px;
|
||
.left {
|
||
width: 340px;
|
||
flex: none;
|
||
}
|
||
.right {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
}
|
||
.no-preview {
|
||
height: 200px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #999;
|
||
font-size: 13px;
|
||
border: 1px dashed #e5e6eb;
|
||
border-radius: 6px;
|
||
}
|
||
.info-form {
|
||
margin-top: 4px;
|
||
}
|
||
.portal-preview-url {
|
||
color: #2d8cf0;
|
||
font-family: Consolas, Menlo, monospace;
|
||
word-break: break-all;
|
||
}
|
||
.cfg-form {
|
||
margin-top: 12px;
|
||
padding-top: 4px;
|
||
border-top: 1px dashed #e5e6eb;
|
||
}
|
||
</style>
|