Files
YschcmsFront/src/views/cms/SiteWizard.vue
T

315 lines
12 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, 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>