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
+319
View File
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { Message } from "view-ui-plus";
import Utils from "@/utils/index";
import { siteApi, themeApi, themePreviewUrl } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { SiteVo, ThemeVo } from "@/types/cms";
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } 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";
import SiteWizard from "@/views/cms/SiteWizard.vue";
/**
* 站点管理(方案 §9.1 + §6.2 主题选择增强)。
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点(主站+院系子站);
* 超管可经工作台「全部站点总览」(?scope=all)查看全局列表。
* 新建 → 三步向导(选模板→微调预览→确认);已建站点 → 编辑弹窗内
* 主题卡片单选 + 参数微调表单,保存带 themeParams(服务端合并进 config_json,方案 §5.3)。
* config_json 结构化编辑见 SiteConfigForm(docs/09 §3.1):
* 已知键分组表单 + 扩展参数动态行,不再手写 JSON;theme_params 仍由 ThemeParamForm 自动合并。
*/
const store = useCmsStore();
const route = useRoute();
/** 超管全局视图(工作台入口带入 scope=all) */
const scopeAll = computed(() => route.query.scope === "all" && store.isSuperAdmin);
const rows = ref<SiteVo[]>([]);
const loading = ref(false);
const tableHeight = Utils.useTableHeight({ isOperat: true });
/** 主题元数据映射(列表皮肤列缩略名 + 编辑表单) */
const themes = ref<ThemeVo[]>([]);
const themeMeta = computed(() => {
const map: Record<string, ThemeVo> = {};
themes.value.forEach(t => (map[t.name] = t));
return map;
});
const columns = [
{ title: "站点编码", key: "code", width: 140 },
{ title: "站点名称", key: "name", minWidth: 200, tooltip: true },
{ title: "学校", key: "schName", width: 150, tooltip: true },
{
title: "所属主站",
key: "parentSiteId",
width: 150,
tooltip: true,
render: (h: any, p: any) => h("span", p.row.parentSiteId ? p.row.parentName || `ID ${p.row.parentSiteId}` : "顶层站点"),
},
{
title: "皮肤包",
key: "theme",
width: 150,
render: (h: any, p: any) => {
const t = themeMeta.value[p.row.theme || "default"];
return h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
t
? h("img", { src: t.screenshotUrl, style: "width:44px;height:28px;object-fit:cover;border-radius:3px;border:1px solid #eee", alt: "" })
: h("span", { style: "display:inline-block;width:44px;height:28px;background:#f0f2f5;border-radius:3px" }),
h("span", t ? `${t.title}(${p.row.theme})` : p.row.theme || "default"),
]);
},
},
{ title: "门户路径", key: "portalPath", minWidth: 200, tooltip: true, render: (h: any, p: any) => h("span", `/portal/${p.row.code}/`) },
{
title: "状态",
key: "status",
width: 90,
align: "center",
render: (h: any, p: any) =>
h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () =>
p.row.status === 1 ? "启用" : "停用"
),
},
{
title: "操作",
key: "action",
width: 150,
fixed: "right",
align: "center",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)),
],
},
];
/** 新建向导 */
const wizardVisible = ref(false);
/** 编辑弹窗 */
const modalVisible = ref(false);
const saving = ref(false);
const formRef = ref();
const configFormRef = ref();
const form = reactive({
id: undefined as number | undefined,
code: "",
parentSiteId: undefined as number | undefined,
schId: undefined as number | undefined,
schName: "",
name: "",
theme: "default",
configJson: "",
status: 1,
});
const editThemeParams = ref<FormModelValue>({});
const editTheme = computed(() => themeMeta.value[form.theme]);
/** 编辑内预览 */
const previewVisible = ref(false);
/** 可选主站:顶层站点且非自身(院系子站层级固定两层) */
const parentOptions = computed(() =>
rows.value.filter(r => !r.parentSiteId && r.id !== form.id)
);
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 run() {
loading.value = true;
try {
const list = (await siteApi.list().send()) || [];
// 学校上下文过滤(docs/09 §3.3):默认仅当前学校;超管 scope=all 时显示全部
rows.value = scopeAll.value
? list
: list.filter(s => !store.currentSchool || s.schId === store.currentSchool!.schId);
} finally {
loading.value = false;
}
}
async function loadThemes() {
try {
themes.value = (await themeApi.list().send()) || [];
} catch {
themes.value = [];
}
}
function openWizard() {
wizardVisible.value = true;
}
function onWizardCreated() {
run();
}
function openEdit(row: SiteVo) {
Object.assign(form, {
id: row.id,
code: row.code,
parentSiteId: row.parentSiteId || undefined,
schId: row.schId,
schName: row.schName || "",
name: row.name,
theme: themeMeta.value[row.theme || "default"] ? row.theme! : "default",
configJson: row.configJson || "",
status: row.status,
});
const t = editTheme.value;
editThemeParams.value = t ? themeParamsFromConfigJson(row.configJson, t) : {};
modalVisible.value = true;
}
function pickTheme(name: string) {
if (form.theme === name) return;
form.theme = name;
const t = editTheme.value;
editThemeParams.value = t ? formModelFromTheme(t, editThemeParams.value) : {};
}
async function save() {
const valid = await formRef.value?.validate();
if (!valid) return;
const configValid = await configFormRef.value?.validate();
if (!configValid) return;
saving.value = true;
try {
const t = editTheme.value;
const themeParams = t ? editThemeParams.value : {};
await siteApi
.save({
id: form.id,
code: form.code,
parentSiteId: form.parentSiteId || 0,
schId: form.schId,
schName: form.schName || undefined,
name: form.name,
theme: form.theme || undefined,
configJson: t ? mergeThemeParamsIntoConfigJson(form.configJson || "", t, themeParams) : form.configJson || undefined,
status: form.status,
})
.send();
Message.success("保存成功!");
modalVisible.value = false;
run();
} finally {
saving.value = false;
}
}
function openPortal(row: SiteVo) {
window.open(`${config.serviceHost}/portal/${row.code}/`, "_blank");
}
function openEditPreview() {
previewVisible.value = true;
}
/** 全局视图返回学校工作台 */
function goBackWorkbench() {
window.location.hash = "#/workbench";
}
onMounted(() => {
run();
loadThemes();
});
// 学校上下文切换即时重载(docs/09 §3.3)
watch(() => store.currentSchool?.schId, () => run());
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<div class="fix" v-if="scopeAll">
<Alert type="info" show-icon style="width: 100%">
全局站点视图:显示全部学校的站点(含各校主站与院系子站)。
<a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a>
</Alert>
</div>
<div class="fix">
<Button class="fl" type="primary" size="large" @click="openWizard">
<Icon type="ios-add" />
新建站点(向导)
</Button>
</div>
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
<!-- 编辑弹窗 -->
<Modal v-model="modalVisible" title="编辑站点" :mask-closable="false" width="720px">
<Form ref="formRef" :model="form" :rules="rules" :label-width="110">
<FormItem label="站点编码" prop="code">
<Input v-model="form.code" :disabled="!!form.id" placeholder="URL 路径用,如 demo" />
</FormItem>
<FormItem label="站点名称" prop="name">
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" />
</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>
<p class="form-tip">院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。</p>
</FormItem>
<FormItem label="学校ID">
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
</FormItem>
<FormItem label="学校名称">
<Input v-model="form.schName" :maxlength="128" />
</FormItem>
<FormItem label="皮肤包">
<ThemePicker :themes="themes" :model-value="form.theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openEditPreview">预览本皮肤</Button>
<ThemeParamForm :theme="editTheme" v-model="editThemeParams" />
<p class="form-tip">换肤/改参数保存后门户立即生效(缓存已自动失效)。</p>
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
<!-- 站点配置结构化表单(独立 Form 分区,v-model 直绑 configJson) -->
<SiteConfigForm ref="configFormRef" v-model="form.configJson" />
<template #footer>
<Button @click="modalVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
<!-- 编辑预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
<ThemePreviewFrame v-if="previewVisible && editTheme" :site-code="form.code" :theme="form.theme" :theme-params="editThemeParams" />
</Modal>
<!-- 新建站点三步向导 -->
<SiteWizard v-model:visible="wizardVisible" :sites="rows" @created="onWizardCreated" />
</div>
</div>
</template>
<style scoped lang="scss">
.form-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
}
.preview-btn {
margin: 8px 0 0;
}
</style>