feat(B1): 校级建站入口-新建院系子站(向导锁定本校主站)+学校信息服务端继承

- SiteManage: 新建按钮校级显示"新建院系子站";编辑弹窗校级隐藏schId/schName(payload留空服务端继承)
- SiteWizard: lockParentId 锁定上级为本校主站禁改
- SchoolWorkbench: 副文案改自助创建口径
- vitest 24/24 绿 + build 成功
This commit is contained in:
chenxing
2026-09-08 15:14:07 +08:00
parent c77be8b5d8
commit 3990811ed5
3 changed files with 33 additions and 19 deletions
+1 -1
View File
@@ -198,7 +198,7 @@ onMounted(run);
<div class="wb-body"> <div class="wb-body">
<div class="wb-title"> <div class="wb-title">
<h3>我的学校</h3> <h3>我的学校</h3>
<p class="wb-sub">选择学校进入内容管理;学校与站点由平台管理员在「添加学校」中绑定创建。</p> <p class="wb-sub">选择学校进入内容管理;学校由平台管理员接入,院系子站可在「站点管理」自助创建。</p>
</div> </div>
<Spin v-if="loading" size="large" fix /> <Spin v-if="loading" size="large" fix />
+15 -10
View File
@@ -32,6 +32,10 @@ const router = useRouter();
/** 超管全局视图(工作台入口带入 scope=all) */ /** 超管全局视图(工作台入口带入 scope=all) */
const scopeAll = computed(() => route.query.scope === "all" && store.isSuperAdmin); const scopeAll = computed(() => route.query.scope === "all" && store.isSuperAdmin);
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管向导行为不变 */
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
const rows = ref<SiteVo[]>([]); const rows = ref<SiteVo[]>([]);
const loading = ref(false); const loading = ref(false);
const tableHeight = Utils.useTableHeight({ isOperat: true }); const tableHeight = Utils.useTableHeight({ isOperat: true });
@@ -214,8 +218,9 @@ async function save() {
id: form.id, id: form.id,
code: form.code, code: form.code,
parentSiteId: form.parentSiteId || 0, parentSiteId: form.parentSiteId || 0,
schId: form.schId, // B1:校级角色隐藏学校信息字段,留空由服务端继承主站(防伪造通道)
schName: form.schName || undefined, schId: isSchoolAdmin.value ? undefined : form.schId,
schName: isSchoolAdmin.value ? undefined : form.schName || undefined,
name: form.name, name: form.name,
theme: form.theme || undefined, theme: form.theme || undefined,
configJson: t ? mergeThemeParamsIntoConfigJson(form.configJson || "", t, themeParams) : form.configJson || undefined, configJson: t ? mergeThemeParamsIntoConfigJson(form.configJson || "", t, themeParams) : form.configJson || undefined,
@@ -285,10 +290,10 @@ watch(() => store.currentSchool?.schId, () => run());
<a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a> <a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a>
</Alert> </Alert>
</div> </div>
<div class="fix"> <div class="fix" v-if="canCreateSite">
<Button class="fl" type="primary" size="large" @click="openWizard"> <Button class="fl" type="primary" size="large" @click="openWizard">
<Icon type="ios-add" /> <Icon type="ios-add" />
新建站点(向导) {{ isSchoolAdmin ? "新建院系子站" : "新建站点(向导)" }}
</Button> </Button>
</div> </div>
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" /> <Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
@@ -303,15 +308,15 @@ watch(() => store.currentSchool?.schId, () => run());
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" /> <Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" />
</FormItem> </FormItem>
<FormItem label="上级站点"> <FormItem label="上级站点">
<Select v-model="form.parentSiteId" clearable placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px"> <Select v-model="form.parentSiteId" :clearable="!isSchoolAdmin" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option> <Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select> </Select>
<p class="form-tip">院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。</p> <p class="form-tip">院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}</p>
</FormItem> </FormItem>
<FormItem label="学校ID"> <FormItem v-if="store.isSuperAdmin" label="学校ID">
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" /> <InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
</FormItem> </FormItem>
<FormItem label="学校名称"> <FormItem v-if="store.isSuperAdmin" label="学校名称">
<Input v-model="form.schName" :maxlength="128" /> <Input v-model="form.schName" :maxlength="128" />
</FormItem> </FormItem>
<FormItem label="皮肤包"> <FormItem label="皮肤包">
@@ -340,8 +345,8 @@ watch(() => store.currentSchool?.schId, () => run());
<ThemePreviewFrame v-if="previewVisible && editTheme" :site-code="form.code" :theme="form.theme" :theme-params="editThemeParams" /> <ThemePreviewFrame v-if="previewVisible && editTheme" :site-code="form.code" :theme="form.theme" :theme-params="editThemeParams" />
</Modal> </Modal>
<!-- 新建站点三步向导 --> <!-- 新建站点三步向导(B1:校级锁定为本校主站下建院系子站) -->
<SiteWizard v-model:visible="wizardVisible" :sites="rows" @created="onWizardCreated" /> <SiteWizard v-model:visible="wizardVisible" :sites="rows" :lock-parent-id="isSchoolAdmin ? store.currentSchool?.mainSiteId : undefined" @created="onWizardCreated" />
</div> </div>
</div> </div>
</template> </template>
+17 -8
View File
@@ -17,6 +17,8 @@ import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
sites: SiteVo[]; sites: SiteVo[];
/** B1 建站权限拆分:校级锁定在本校主站下建院系子站(上级禁改、学校信息由服务端继承) */
lockParentId?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -51,7 +53,12 @@ const canPreview = computed(() => !!sourceSiteCode.value && !!themeName.value);
const previewTick = ref(0); const previewTick = ref(0);
const previewParams = ref<FormModelValue>({}); const previewParams = ref<FormModelValue>({});
const parentOptions = computed(() => props.sites.filter(s => !s.parentSiteId)); /** 锁定模式:上级仅本校主站;否则全部顶层站可选 */
const parentOptions = computed(() =>
props.lockParentId
? props.sites.filter(s => s.id === props.lockParentId && !s.parentSiteId)
: props.sites.filter(s => !s.parentSiteId)
);
function debouncePreview() { function debouncePreview() {
previewParams.value = { ...themeParams.value }; previewParams.value = { ...themeParams.value };
@@ -71,7 +78,7 @@ watch(
async v => { async v => {
if (!v) return; if (!v) return;
step.value = 1; step.value = 1;
Object.assign(form, { code: "", parentSiteId: undefined, schId: undefined, schName: "", name: "", status: 1, configJson: "" }); Object.assign(form, { code: "", parentSiteId: props.lockParentId, schId: undefined, schName: "", name: "", status: 1, configJson: "" });
sourceSiteCode.value = sourceSites.value[0]?.code || ""; sourceSiteCode.value = sourceSites.value[0]?.code || "";
if (!themes.value.length) await loadThemes(); if (!themes.value.length) await loadThemes();
themeName.value = "default"; themeName.value = "default";
@@ -128,8 +135,9 @@ async function createSite() {
.save({ .save({
code: form.code, code: form.code,
parentSiteId: form.parentSiteId || 0, parentSiteId: form.parentSiteId || 0,
schId: form.schId, // B1:校级锁定模式学校信息留空,服务端校验后继承主站(防伪造)
schName: form.schName || undefined, schId: props.lockParentId ? undefined : form.schId,
schName: props.lockParentId ? undefined : form.schName || undefined,
name: form.name, name: form.name,
theme: themeName.value, theme: themeName.value,
configJson, configJson,
@@ -148,7 +156,7 @@ async function createSite() {
<template> <template>
<Modal <Modal
:model-value="visible" :model-value="visible"
:title="`新建站点向导 · 第 ${step} / 3 步`" :title="`${props.lockParentId ? '新建院系子站' : '新建站点'}向导 · 第 ${step} / 3 步`"
:mask-closable="false" :mask-closable="false"
width="980px" width="980px"
@update:model-value="emit('update:visible', $event)" @update:model-value="emit('update:visible', $event)"
@@ -200,18 +208,19 @@ async function createSite() {
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" style="width: 320px" /> <Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" style="width: 320px" />
</FormItem> </FormItem>
<FormItem label="上级站点"> <FormItem label="上级站点">
<Select v-model="form.parentSiteId" clearable placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px"> <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> <Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select> </Select>
<div v-if="props.lockParentId" class="pbu-tip">校级角色仅可在本校主站下创建院系子站,学校信息自动继承主站。</div>
</FormItem> </FormItem>
<FormItem label="学校ID"> <FormItem v-if="!props.lockParentId" label="学校ID">
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" /> <InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
<!-- B-8:顶层站 schId 必填(校级角色按此匹配管理权限);院系子站留空自动继承主站 --> <!-- B-8:顶层站 schId 必填(校级角色按此匹配管理权限);院系子站留空自动继承主站 -->
<div class="pbu-tip"> <div class="pbu-tip">
顶层站点必填(否则建成即校级角色不可管理);选了上级站点的院系子站可留空,自动继承主站。 顶层站点必填(否则建成即校级角色不可管理);选了上级站点的院系子站可留空,自动继承主站。
</div> </div>
</FormItem> </FormItem>
<FormItem label="学校名称"> <FormItem v-if="!props.lockParentId" label="学校名称">
<Input v-model="form.schName" :maxlength="128" style="width: 320px" /> <Input v-model="form.schName" :maxlength="128" style="width: 320px" />
</FormItem> </FormItem>
<FormItem label="状态"> <FormItem label="状态">