init local code

This commit is contained in:
chenxing
2026-09-07 09:11:37 +08:00
commit a3a75c3680
112 changed files with 24837 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
<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 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[];
}>();
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 sourceSiteCode = 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(() => !!sourceSiteCode.value && !!themeName.value);
const previewTick = ref(0);
const previewParams = ref<FormModelValue>({});
const parentOptions = computed(() => props.sites.filter(s => !s.parentSiteId));
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: undefined, schId: undefined, schName: "", name: "", status: 1, configJson: "" });
sourceSiteCode.value = 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,
schId: form.schId,
schName: 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="`新建站点向导 · 第 ${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="sourceSiteCode" style="width: 260px">
<Option v-for="s in sourceSites" :key="s.id" :value="s.code">{{ s.name }}({{ s.code }})</Option>
</Select>
</FormItem>
<ThemeParamForm :theme="selectedTheme" v-model="themeParams" />
</div>
<div class="right">
<ThemePreviewFrame
v-if="canPreview"
:site-code="sourceSiteCode"
: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="上级站点">
<Select v-model="form.parentSiteId" clearable placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select>
</FormItem>
<FormItem 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 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;
}
.cfg-form {
margin-top: 12px;
padding-top: 4px;
border-top: 1px dashed #e5e6eb;
}
</style>