372 lines
13 KiB
Vue
372 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import { Message } from "view-ui-plus";
|
||
import { schoolApi, siteApi, themeApi } from "@/api/module/cms";
|
||
import { useCmsStore } from "@/store";
|
||
import config from "@/assets/config/host.config";
|
||
import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms";
|
||
import { portalUrlOf } from "@/utils/sitePath";
|
||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
|
||
|
||
/**
|
||
* 学校工作台(一级页,docs/09 §3.3 方案A):全屏无壳。
|
||
* 学校卡片网格(校名/站点构成/文章规模/进入管理/门户预览);
|
||
* 超管:+添加学校(UserBase 学校选择器绑定 + 初始化主站,复用 /api/site/save)
|
||
* 与「全部站点总览」入口;校级单校用户自动直进本校管理壳。
|
||
*/
|
||
const router = useRouter();
|
||
const store = useCmsStore();
|
||
|
||
const cards = ref<SchoolCard[]>([]);
|
||
const loading = ref(false);
|
||
const themes = ref<ThemeVo[]>([]);
|
||
|
||
const isSuper = computed(() => store.isSuperAdmin);
|
||
|
||
async function run() {
|
||
loading.value = true;
|
||
try {
|
||
cards.value = (await schoolApi.list().send()) || [];
|
||
maybeAutoEnter();
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
/** 单校校级用户跳过工作台直达本校管理壳;超管常驻工作台(多校管理诉求) */
|
||
function maybeAutoEnter() {
|
||
if (store.isSuperAdmin) return;
|
||
if (cards.value.length === 1) {
|
||
enter(cards.value[0]);
|
||
}
|
||
}
|
||
|
||
function enter(card: SchoolCard) {
|
||
store.enterSchool(
|
||
{
|
||
schId: card.schId,
|
||
schName: card.schName,
|
||
mainSiteId: card.mainSiteId,
|
||
mainSiteCode: card.mainSiteCode,
|
||
mainSiteName: card.mainSiteName,
|
||
},
|
||
(store.user?.sites || []).filter(
|
||
(s: any) => s.id === card.mainSiteId || s.parentSiteId === card.mainSiteId
|
||
)
|
||
);
|
||
router.push(`/sch/${card.mainSiteCode}/article`);
|
||
}
|
||
|
||
function openPortal(card: SchoolCard) {
|
||
window.open(`${config.serviceHost}${portalUrlOf({ path: card.mainSitePath, code: card.mainSiteCode })}`, "_blank");
|
||
}
|
||
|
||
/** 超管全局站点视图(借道当前/第一个学校上下文 + scope=all) */
|
||
function goAllSites() {
|
||
const code = store.schoolCode || cards.value[0]?.mainSiteCode;
|
||
if (code) {
|
||
router.push({ path: `/sch/${code}/site`, query: { scope: "all" } });
|
||
}
|
||
}
|
||
|
||
/** 超管平台级皮肤包管理(不再挂左侧菜单,工作台直达) */
|
||
function goThemeManage() {
|
||
router.push("/theme-manage");
|
||
}
|
||
|
||
/* ===== 添加学校(超管):绑定已有学校 + 初始化主站 ===== */
|
||
const modalVisible = ref(false);
|
||
const saving = ref(false);
|
||
const options = ref<SchoolOption[]>([]);
|
||
const optionsLoading = ref(false);
|
||
const formRef = ref();
|
||
const form = ref({
|
||
schId: undefined as number | undefined,
|
||
schName: "",
|
||
code: "",
|
||
name: "",
|
||
theme: "default",
|
||
});
|
||
|
||
const rules = {
|
||
// 必须声明 type:"number":schId 是 UserBase 组 ID(数字),缺省按 string 校验会把已选中的数字 ID 判为不通过
|
||
schId: [{ required: true, type: "number", message: "请选择学校(来自用户中心)", trigger: "change" }],
|
||
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" }],
|
||
};
|
||
|
||
function openAdd() {
|
||
modalVisible.value = true;
|
||
form.value = { schId: undefined, schName: "", code: "", name: "", theme: "default" };
|
||
loadOptions();
|
||
if (!themes.value.length) loadThemes();
|
||
}
|
||
|
||
async function loadOptions() {
|
||
optionsLoading.value = true;
|
||
try {
|
||
options.value = (await schoolApi.options().send()) || [];
|
||
if (!options.value.length) {
|
||
Message.warning("未获取到学校列表(用户中心不可达或暂无学校数据)");
|
||
}
|
||
} finally {
|
||
optionsLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadThemes() {
|
||
try {
|
||
themes.value = (await themeApi.list().send()) || [];
|
||
} catch {
|
||
themes.value = [];
|
||
}
|
||
}
|
||
|
||
function onSchoolPick(id: number) {
|
||
const opt = options.value.find(o => o.id === id);
|
||
// 校名/编码/名称始终跟随当前选中学校带出,避免切换学校后残留上一所的默认值造成“已选好”的假象
|
||
form.value.schName = opt?.name || "";
|
||
if (opt) {
|
||
form.value.name = `${opt.name}门户网站`;
|
||
// R1 学校主站编码规则:1 + 学校ID左补零至4位(总长5位,如 id=13 → 10013);
|
||
// id≥10000 时 LPAD 不截断、编码自然超5位(当前学校量级不会触达)
|
||
form.value.code = `1${String(opt.id).padStart(4, "0")}`;
|
||
}
|
||
}
|
||
|
||
async function create() {
|
||
const valid = await formRef.value?.validate();
|
||
if (!valid) return;
|
||
saving.value = true;
|
||
try {
|
||
await siteApi
|
||
.save({
|
||
code: form.value.code,
|
||
name: form.value.name,
|
||
schId: form.value.schId,
|
||
schName: form.value.schName || undefined,
|
||
theme: form.value.theme || undefined,
|
||
parentSiteId: 0,
|
||
status: 1,
|
||
})
|
||
.send();
|
||
Message.success("学校主站创建成功,可在卡片「进入管理」开始配置内容!");
|
||
modalVisible.value = false;
|
||
run();
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(run);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="workbench">
|
||
<WorkbenchHeader title="云海CMS · 学校工作台" />
|
||
|
||
<!-- 操作工具行(Notice search-wrap 同款灰底条):按钮居左,账号区保持在深色 header 右侧 -->
|
||
<div class="wb-toolbar" v-if="isSuper">
|
||
<Button class="wb-btn" @click="goAllSites" :disabled="!cards.length">
|
||
<Icon type="ios-globe" />
|
||
全部站点总览
|
||
</Button>
|
||
<Button class="wb-btn" @click="goThemeManage">
|
||
<Icon type="ios-brush" />
|
||
皮肤包管理
|
||
</Button>
|
||
<Button type="primary" class="wb-btn" @click="openAdd">
|
||
<Icon type="ios-add" />
|
||
添加学校
|
||
</Button>
|
||
</div>
|
||
|
||
<div class="wb-body">
|
||
<div class="wb-title">
|
||
<h3>我的学校</h3>
|
||
<p class="wb-sub">选择学校进入内容管理;学校由平台管理员接入,院系子站可在「站点管理」自助创建。</p>
|
||
</div>
|
||
|
||
<Spin v-if="loading" size="large" fix />
|
||
<template v-else>
|
||
<div v-if="cards.length" class="card-grid">
|
||
<div v-for="card in cards" :key="card.mainSiteId" class="school-card" :class="{ disabled: card.status !== 1 }">
|
||
<div class="card-head">
|
||
<Icon type="ios-school" :size="26" />
|
||
<div class="card-name">
|
||
<h4>{{ card.schName || card.mainSiteName }}</h4>
|
||
<p>{{ card.mainSiteName }}({{ card.mainSiteCode }})</p>
|
||
</div>
|
||
<Tag v-if="card.status !== 1" color="default" class="fr">停用</Tag>
|
||
</div>
|
||
<div class="card-meta">
|
||
<span>{{ card.siteCount > 1 ? `主站 + ${card.siteCount - 1} 个院系子站` : "仅主站" }}</span>
|
||
<span>文章 {{ card.articleCount }} 篇</span>
|
||
</div>
|
||
<div class="card-actions">
|
||
<Button type="primary" long :disabled="card.status !== 1" @click="enter(card)">进入管理</Button>
|
||
<Button long class="portal-btn" @click="openPortal(card)">
|
||
<Icon type="ios-eye" />
|
||
门户预览
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-state">
|
||
<Icon type="ios-school" :size="48" />
|
||
<p v-if="isSuper">还没有学校 —— 点击上方「添加学校」绑定用户中心学校并创建门户主站。</p>
|
||
<p v-else>暂无可管理的学校站点,请联系平台管理员。</p>
|
||
<Button v-if="isSuper" type="primary" @click="openAdd">
|
||
<Icon type="ios-add" />
|
||
添加学校
|
||
</Button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 添加学校弹窗:学校选择器(UserBase oapi)+ 主站信息 -->
|
||
<Modal v-model="modalVisible" title="添加学校" :mask-closable="false" width="640px">
|
||
<Form ref="formRef" :model="form" :rules="rules" :label-width="110">
|
||
<FormItem label="选择学校" prop="schId">
|
||
<Select
|
||
v-model="form.schId"
|
||
filterable
|
||
:loading="optionsLoading"
|
||
placeholder="输入名称搜索用户中心学校"
|
||
style="width: 100%"
|
||
@on-change="onSchoolPick"
|
||
>
|
||
<Option v-for="opt in options" :key="opt.id" :value="opt.id">{{ opt.name }}</Option>
|
||
</Select>
|
||
<p class="form-tip">学校主数据来自用户中心(只绑定,不新建);校名自动带出,学校账号/组织仍在用户中心维护。</p>
|
||
</FormItem>
|
||
<FormItem label="主站编码" prop="code">
|
||
<Input v-model="form.code" placeholder="URL 路径用,如 demo" />
|
||
</FormItem>
|
||
<FormItem label="主站名称" prop="name">
|
||
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" />
|
||
</FormItem>
|
||
<FormItem label="皮肤包">
|
||
<ThemePicker :themes="themes" :model-value="form.theme" @update:model-value="(v: string) => (form.theme = v)" />
|
||
<p class="form-tip">建好之后可在校内「站点管理」微调主题参数与站点配置。</p>
|
||
</FormItem>
|
||
</Form>
|
||
<template #footer>
|
||
<Button @click="modalVisible = false">取消</Button>
|
||
<Button type="primary" :loading="saving" @click="create">创建学校主站</Button>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.workbench {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #f5f7fa;
|
||
}
|
||
/* 工具行:Notice front .search-wrap 同款(#f7f7f7 底 + 10px 16px 内边距 ≈ 52px 高),按钮居左 */
|
||
.wb-toolbar {
|
||
flex-shrink: 0;
|
||
background: #f7f7f7;
|
||
padding: 10px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.wb-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 32px 48px;
|
||
}
|
||
.wb-title {
|
||
margin-bottom: 24px;
|
||
h3 {
|
||
margin: 0 0 6px;
|
||
font-size: 20px;
|
||
color: #17233d;
|
||
}
|
||
.wb-sub {
|
||
color: #808695;
|
||
font-size: 13px;
|
||
margin: 0;
|
||
}
|
||
}
|
||
.card-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 20px;
|
||
max-width: 1200px;
|
||
}
|
||
.school-card {
|
||
background: #fff;
|
||
border: 1px solid #e8eaec;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
transition: box-shadow 0.2s, transform 0.2s;
|
||
&:hover {
|
||
box-shadow: 0 4px 16px rgba(31, 42, 58, 0.12);
|
||
transform: translateY(-2px);
|
||
}
|
||
&.disabled {
|
||
opacity: 0.6;
|
||
}
|
||
.card-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
color: #2d8cf0;
|
||
.card-name {
|
||
h4 {
|
||
margin: 0 0 2px;
|
||
color: #17233d;
|
||
font-size: 16px;
|
||
}
|
||
p {
|
||
margin: 0;
|
||
color: #808695;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
}
|
||
.card-meta {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin: 14px 0 18px;
|
||
color: #515a6e;
|
||
font-size: 13px;
|
||
}
|
||
.card-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
.portal-btn {
|
||
flex: 1;
|
||
}
|
||
}
|
||
.empty-state {
|
||
max-width: 1200px;
|
||
border: 1px dashed #dcdee2;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
padding: 64px 0;
|
||
text-align: center;
|
||
color: #808695;
|
||
p {
|
||
margin: 12px 0 20px;
|
||
}
|
||
}
|
||
.form-tip {
|
||
color: #999;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
margin-top: 4px;
|
||
}
|
||
</style>
|