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

372 lines
13 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, 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>