init local code
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import { articleApi, categoryApi, portalBase, uploadUrl } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const editId = computed(() => {
|
||||
const raw = route.params.id || route.query.id;
|
||||
return raw ? parseInt(String(raw)) : undefined;
|
||||
});
|
||||
|
||||
const formRef = ref();
|
||||
const saving = ref(false);
|
||||
const editorLoading = ref(true);
|
||||
const uploadedIds = ref<number[]>([]);
|
||||
|
||||
const form = reactive({
|
||||
categoryId: undefined as number | undefined,
|
||||
title: "",
|
||||
summary: "",
|
||||
content: "",
|
||||
coverImage: "",
|
||||
authorName: "",
|
||||
source: "",
|
||||
topmost: 0,
|
||||
publishTime: "" as string | "",
|
||||
offlineTime: "" as string | "",
|
||||
syncWechat: false,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
title: [{ required: true, message: "标题不得为空", trigger: "blur" }],
|
||||
categoryId: [{ required: true, type: "number", message: "请选择所属栏目", trigger: "change" }],
|
||||
};
|
||||
|
||||
/** 栏目下拉(树拍平缩进) */
|
||||
const categoryOptions = ref<{ id: number; label: string }[]>([]);
|
||||
|
||||
function flattenCategories(tree: CategoryTreeNode[], depth = 0): { id: number; label: string }[] {
|
||||
const list: { id: number; label: string }[] = [];
|
||||
tree.forEach(node => {
|
||||
list.push({ id: node.id!, label: `${" ".repeat(depth)}${node.name}` });
|
||||
if (node.children?.length) list.push(...flattenCategories(node.children, depth + 1));
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
/* ===== wangEditor(Notice 已趟坑配置:无视频组、自定义图片上传) ===== */
|
||||
const editorRef = shallowRef();
|
||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const editorConfig = {
|
||||
placeholder: "请输入正文……",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("siteId", String(store.siteId));
|
||||
if (editId.value) fd.append("articleId", String(editId.value));
|
||||
try {
|
||||
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.code === 0 && res.data) {
|
||||
uploadedIds.value.push(res.data.id);
|
||||
insertFn(res.data.url, res.data.fileName || "", res.data.url);
|
||||
} else {
|
||||
Message.error(res.msg || "图片上传失败");
|
||||
}
|
||||
} catch (e) {
|
||||
Message.error("图片上传失败");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor;
|
||||
editorLoading.value = false;
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy();
|
||||
});
|
||||
|
||||
/* ===== 封面上传 ===== */
|
||||
function coverUploaded(res: any) {
|
||||
if (res.code === 0 && res.data) {
|
||||
uploadedIds.value.push(res.data.id);
|
||||
form.coverImage = res.data.url;
|
||||
} else {
|
||||
Message.error(res.msg || "封面上传失败");
|
||||
}
|
||||
}
|
||||
function coverError(_err: any) {
|
||||
Message.error("封面上传失败");
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!store.siteId) return;
|
||||
const tree = await categoryApi.tree(store.siteId).send();
|
||||
categoryOptions.value = flattenCategories(tree || []);
|
||||
if (!editId.value) return;
|
||||
const detail = await articleApi.get(store.siteId, editId.value).send();
|
||||
if (!detail) return;
|
||||
Object.assign(form, {
|
||||
categoryId: detail.categoryId,
|
||||
title: detail.title,
|
||||
summary: detail.summary || "",
|
||||
content: detail.content || "",
|
||||
coverImage: detail.coverImage || "",
|
||||
authorName: detail.authorName || "",
|
||||
source: detail.source || "",
|
||||
topmost: detail.topmost || 0,
|
||||
publishTime: detail.publishTime || "",
|
||||
offlineTime: detail.offlineTime || "",
|
||||
});
|
||||
}
|
||||
|
||||
/** 时间统一为后端 LocalDateTime 契约格式 yyyy-MM-dd HH:mm:ss
|
||||
* (DatePicker v-model 产出 Date 对象,JSON 序列化成 ISO UTC 串后端无法反序列化) */
|
||||
function fmtDateTime(v: string | Date | undefined): string | undefined {
|
||||
if (!v) return undefined;
|
||||
let d: Date;
|
||||
if (v instanceof Date) {
|
||||
d = v;
|
||||
} else if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(v)) {
|
||||
return v; // 已是契约格式(编辑回显)
|
||||
} else {
|
||||
d = new Date(v); // ISO 等其他串
|
||||
if (isNaN(d.getTime())) return undefined;
|
||||
}
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 组装保存入参 */
|
||||
function buildRequest() {
|
||||
return {
|
||||
id: editId.value,
|
||||
siteId: store.siteId!,
|
||||
categoryId: form.categoryId!,
|
||||
title: form.title,
|
||||
summary: form.summary || undefined,
|
||||
content: form.content,
|
||||
coverImage: form.coverImage || undefined,
|
||||
authorName: form.authorName || undefined,
|
||||
source: form.source || undefined,
|
||||
topmost: form.topmost,
|
||||
publishTime: fmtDateTime(form.publishTime),
|
||||
offlineTime: fmtDateTime(form.offlineTime),
|
||||
syncWechat: false,
|
||||
attachmentIds: uploadedIds.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function save(): Promise<number | undefined> {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return undefined;
|
||||
saving.value = true;
|
||||
try {
|
||||
return await articleApi.save(buildRequest()).send();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
const id = await save();
|
||||
if (id) {
|
||||
Message.success("保存成功!");
|
||||
const code = store.schoolCode;
|
||||
router.replace(code ? `/sch/${code}/article/edit/${id}` : "/workbench");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndPublish() {
|
||||
const id = await save();
|
||||
if (!id) return;
|
||||
await articleApi.publish(store.siteId!, id).send();
|
||||
Message.success("已提交发布!");
|
||||
const code = store.schoolCode;
|
||||
router.replace(code ? `/sch/${code}/article` : "/workbench");
|
||||
}
|
||||
|
||||
/** 预览:仅已发布文章在门户可见 */
|
||||
function preview() {
|
||||
if (!editId.value || !store.currentSite) return;
|
||||
window.open(`${portalBase(store.currentSite.code)}/a/${editId.value}`, "_blank");
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3):编辑页随当前站点切换重新拉取栏目与文章
|
||||
watch(() => store.siteId, () => load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="所属栏目" prop="categoryId">
|
||||
<Select v-model="form.categoryId" placeholder="请选择栏目" style="width: 320px">
|
||||
<Option v-for="opt in categoryOptions" :key="opt.id" :value="opt.id">
|
||||
{{ opt.label }}
|
||||
</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="标题" prop="title">
|
||||
<Input v-model="form.title" placeholder="文章标题" style="width: 640px" :maxlength="200" />
|
||||
</FormItem>
|
||||
<FormItem label="封面图">
|
||||
<Upload
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId"
|
||||
with-credentials
|
||||
:show-upload-list="false"
|
||||
:format="['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']"
|
||||
:on-success="coverUploaded"
|
||||
:on-error="coverError"
|
||||
>
|
||||
<Button icon="ios-cloud-upload-outline">上传封面</Button>
|
||||
</Upload>
|
||||
<img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
|
||||
</FormItem>
|
||||
<FormItem label="摘要">
|
||||
<Input
|
||||
v-model="form.summary"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="列表/SEO 用,留空自动截取正文"
|
||||
style="width: 640px"
|
||||
:maxlength="500"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="作者">
|
||||
<Input v-model="form.authorName" placeholder="默认取发布人" style="width: 240px" :maxlength="64" />
|
||||
</FormItem>
|
||||
<FormItem label="来源">
|
||||
<Input v-model="form.source" placeholder="转载出处(可空)" style="width: 240px" :maxlength="128" />
|
||||
</FormItem>
|
||||
<FormItem label="发布时间">
|
||||
<DatePicker
|
||||
type="datetime"
|
||||
v-model="form.publishTime"
|
||||
format="yyyy-MM-dd HH:mm:ss"
|
||||
placeholder="留空=发布时取当前时间"
|
||||
style="width: 240px"
|
||||
/>
|
||||
<span class="form-tip">选择未来时间即“定时发布”,到期由系统自动发布</span>
|
||||
</FormItem>
|
||||
<FormItem label="下架时间">
|
||||
<DatePicker
|
||||
type="datetime"
|
||||
v-model="form.offlineTime"
|
||||
format="yyyy-MM-dd HH:mm:ss"
|
||||
placeholder="可空"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="置顶">
|
||||
<Switch :model-value="form.topmost === 1" @on-change="(v: boolean) => (form.topmost = v ? 1 : 0)" />
|
||||
</FormItem>
|
||||
<FormItem label="正文">
|
||||
<div class="editor-wrap">
|
||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||
<Editor
|
||||
class="editor-content"
|
||||
v-model="form.content"
|
||||
:defaultConfig="editorConfig"
|
||||
mode="default"
|
||||
@onCreated="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<!-- 微信对接预留入口(方案 §7.4):本期禁用灰显,不产生任何请求 -->
|
||||
<FormItem label="同步公众号">
|
||||
<Tooltip max-width="280" content="微信对接未启用,规划见《微信平台对接方案》">
|
||||
<Switch v-model="form.syncWechat" disabled />
|
||||
</Tooltip>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button type="primary" :loading="saving" @click="saveAndPublish">保存并发布</Button>
|
||||
<Button style="margin-left: 8px" :loading="saving" @click="saveDraft">保存草稿</Button>
|
||||
<Button style="margin-left: 8px" :disabled="!editId" @click="preview">预览</Button>
|
||||
<Button style="margin-left: 8px" @click="router.back()">返回</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cover-preview {
|
||||
display: block;
|
||||
max-width: 320px;
|
||||
max-height: 160px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
.form-tip {
|
||||
margin-left: 12px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.editor-wrap {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
z-index: 100;
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #dcdee2;
|
||||
}
|
||||
.editor-content {
|
||||
// wangEditor v5 高度必须打在内层 .w-e-text-container(外层高度会被内部 100% 链路吞掉),
|
||||
// Notice NewNotice.vue 同款修复(D6-5);视口自适应、下限 480px,430≈壳层(56+56)+表单区高度
|
||||
:deep(.w-e-text-container) {
|
||||
height: max(480px, calc(100vh - 430px)) !important;
|
||||
}
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Tag } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { articleApi, categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<ArticlePageItem[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
const search = reactive({
|
||||
categoryId: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
/** 栏目下拉(树拍平缩进展示) */
|
||||
const categoryOptions = ref<{ id: number; label: string }[]>([]);
|
||||
|
||||
const statusDict = [
|
||||
{ value: 0, label: "草稿", color: "default" },
|
||||
{ value: 1, label: "已发布", color: "success" },
|
||||
{ value: 2, label: "已下架", color: "warning" },
|
||||
];
|
||||
const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知";
|
||||
const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default";
|
||||
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 260,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", null, [
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
style: { color: "#2d8cf0", cursor: "pointer", marginRight: "6px" },
|
||||
onClick: () => goEdit(p.row.id),
|
||||
},
|
||||
p.row.title
|
||||
),
|
||||
p.row.topmost === 1 ? h(Tag, { color: "error" }, () => "置顶") : null,
|
||||
]),
|
||||
},
|
||||
{ title: "栏目", key: "categoryName", width: 140, tooltip: true },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(Tag, { color: statusColor(p.row.status) }, () => statusText(p.row.status)),
|
||||
},
|
||||
{
|
||||
title: "发布时间",
|
||||
key: "publishTime",
|
||||
width: 170,
|
||||
render: (h: any, p: any) => h("span", p.row.publishTime || "-"),
|
||||
},
|
||||
{ title: "浏览量", key: "viewCount", width: 90, align: "center" },
|
||||
{ title: "创建时间", key: "createTime", width: 170 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 190,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const ops: any[] = [
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => goEdit(p.row.id)),
|
||||
];
|
||||
if (p.row.status !== 1) {
|
||||
ops.push(Utils.operatingHandle(h, "发布", "icon-apps", () => doPublish(p.row)));
|
||||
}
|
||||
if (p.row.status === 1) {
|
||||
ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row)));
|
||||
}
|
||||
ops.push(
|
||||
Utils.operatingHandle(
|
||||
h,
|
||||
p.row.topmost === 1 ? "取消置顶" : "置顶",
|
||||
"icon-saoba",
|
||||
() => doTopmost(p.row)
|
||||
),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row))
|
||||
);
|
||||
return ops;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function flattenCategories(tree: CategoryTreeNode[], depth = 0) {
|
||||
const list: { id: number; label: string }[] = [];
|
||||
tree.forEach(node => {
|
||||
list.push({ id: node.id!, label: `${" ".repeat(depth)}${node.name}` });
|
||||
if (node.children?.length) {
|
||||
list.push(...flattenCategories(node.children, depth + 1));
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
if (!store.siteId) return;
|
||||
const tree = await categoryApi.tree(store.siteId).send();
|
||||
categoryOptions.value = flattenCategories(tree || []);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await articleApi
|
||||
.page({
|
||||
siteId: store.siteId,
|
||||
categoryId: search.categoryId,
|
||||
status: search.status,
|
||||
keyword: search.keyword || undefined,
|
||||
page: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
.send();
|
||||
rows.value = page?.list || [];
|
||||
total.value = page?.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pageSizeUpdate(size: number) {
|
||||
pageSize.value = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
|
||||
function goEdit(id?: number) {
|
||||
const code = store.schoolCode;
|
||||
router.push(code ? `/sch/${code}/article/edit${id ? `/${id}` : ""}` : "/workbench");
|
||||
}
|
||||
|
||||
async function doPublish(row: ArticlePageItem) {
|
||||
await articleApi.publish(store.siteId!, row.id).send();
|
||||
Message.success("发布成功!");
|
||||
run();
|
||||
}
|
||||
|
||||
async function doOffline(row: ArticlePageItem) {
|
||||
await articleApi.offline(store.siteId!, row.id).send();
|
||||
Message.success("已下架");
|
||||
run();
|
||||
}
|
||||
|
||||
async function doTopmost(row: ArticlePageItem) {
|
||||
await articleApi.topmost(store.siteId!, row.id, row.topmost !== 1).send();
|
||||
Message.success(row.topmost === 1 ? "已取消置顶" : "已置顶");
|
||||
run();
|
||||
}
|
||||
|
||||
function doDelete(row: ArticlePageItem) {
|
||||
MyConfirm({
|
||||
title: "删除文章",
|
||||
msg: `确定删除「${row.title}」吗?`,
|
||||
info: "删除后正文图片将转为孤儿附件,可在附件管理中清理",
|
||||
async onOk() {
|
||||
await articleApi.delete({ id: row.id, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
if (rows.value.length <= 1 && pageNum.value > 1) pageNum.value -= 1;
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await run();
|
||||
});
|
||||
|
||||
// 站点/学校上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
|
||||
watch(() => store.siteId, async () => {
|
||||
await loadCategories();
|
||||
pageNum.value = 1;
|
||||
await run();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="goEdit()">
|
||||
<Icon type="ios-add" />
|
||||
新建文章
|
||||
</Button>
|
||||
</div>
|
||||
<div class="search-wrap fix">
|
||||
<Select
|
||||
v-model="search.categoryId"
|
||||
class="search-wrap_sel"
|
||||
clearable
|
||||
placeholder="全部栏目"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="opt in categoryOptions" :key="opt.id" :value="opt.id">
|
||||
{{ opt.label }}
|
||||
</Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="search.status"
|
||||
class="search-wrap_sel ml-12"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
search
|
||||
enter-button
|
||||
class="fr"
|
||||
placeholder="标题关键字"
|
||||
style="width: 220px"
|
||||
v-model="search.keyword"
|
||||
@on-search="pageNum = 1; run()"
|
||||
@on-clear="pageNum = 1; run()"
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无文章"
|
||||
/>
|
||||
<div class="page-panel">
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="(p: number) => { pageNum = p; run(); }"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-wrap .search-wrap_sel {
|
||||
width: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { attachmentApi, uploadUrl } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { AttachmentVo } from "@/types/cms";
|
||||
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<AttachmentVo[]>([]);
|
||||
const total = ref(0);
|
||||
const orphanCount = ref(0);
|
||||
const loading = ref(false);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
const orphanOnly = ref(false);
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
|
||||
|
||||
const columns = [
|
||||
{ title: "文件名", key: "fileName", minWidth: 220, tooltip: true },
|
||||
{
|
||||
title: "预览",
|
||||
key: "url",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
/\.(png|jpe?g|gif|webp|bmp)$/i.test(p.row.url)
|
||||
? h("img", { src: p.row.url, style: { maxHeight: "40px", maxWidth: "90px" } })
|
||||
: h("span", { style: { color: "#999" } }, "-"),
|
||||
},
|
||||
{ title: "大小", key: "fileSize", width: 100, render: (h: any, p: any) => h("span", Utils.getFileSize(p.row.fileSize || 0)) },
|
||||
{
|
||||
title: "关联文章",
|
||||
key: "articleId",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(
|
||||
Tag,
|
||||
{ color: p.row.orphan ? "warning" : "default" },
|
||||
() => (p.row.orphan ? "孤儿" : p.row.articleId)
|
||||
),
|
||||
},
|
||||
{ title: "上传时间", key: "createTime", width: 170 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 130,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, "复制链接", "icon-browse", () => copyUrl(p.row)),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const filter = reactive({ orphanOnly: false });
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await attachmentApi
|
||||
.page(store.siteId, filter.orphanOnly, pageNum.value, pageSize.value)
|
||||
.send();
|
||||
rows.value = page?.list || [];
|
||||
total.value = page?.total || 0;
|
||||
orphanCount.value = (page as any)?.orphanCount || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyUrl(row: AttachmentVo) {
|
||||
Utils.copyToClipboard(row.url);
|
||||
Message.success("链接已复制");
|
||||
}
|
||||
|
||||
function doDelete(row: AttachmentVo) {
|
||||
MyConfirm({
|
||||
title: "删除附件",
|
||||
msg: `确定删除「${row.fileName}」的记录吗?`,
|
||||
info: "仅删除数据库记录,物理文件由运维定期清理;若正文/封面仍在引用该链接将失效",
|
||||
async onOk() {
|
||||
await attachmentApi.delete({ id: row.id, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
if (rows.value.length <= 1 && pageNum.value > 1) pageNum.value -= 1;
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cleanOrphans() {
|
||||
MyConfirm({
|
||||
title: "清理孤儿附件",
|
||||
msg: `当前共有 ${orphanCount.value} 条孤儿附件记录(关联文章已删除),仅清理列表页中勾选的记录。`,
|
||||
info: "为避免误删被封面/轮播引用的文件,MVP 不提供一键全清",
|
||||
onOk() {
|
||||
filter.orphanOnly = true;
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function uploadDone(res: any) {
|
||||
if (res.code === 0) {
|
||||
Message.success("上传成功");
|
||||
run();
|
||||
} else {
|
||||
Message.error(res.msg || "上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3)
|
||||
watch(() => store.siteId, () => {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix">
|
||||
<Upload
|
||||
class="fl"
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId"
|
||||
with-credentials
|
||||
:show-upload-list="false"
|
||||
:on-success="uploadDone"
|
||||
:max-size="102400"
|
||||
:on-exceeded-size="() => Message.error('文件不能超过 100MB')"
|
||||
>
|
||||
<Button type="primary" size="large" icon="ios-cloud-upload-outline">上传附件</Button>
|
||||
</Upload>
|
||||
<Button class="fl" style="margin-left: 8px" size="large" @click="cleanOrphans">
|
||||
孤儿附件({{ orphanCount }})
|
||||
</Button>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<Checkbox v-model="filter.orphanOnly" @on-change="pageNum = 1; run()">仅看孤儿附件</Checkbox>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无附件"
|
||||
/>
|
||||
<div class="page-panel">
|
||||
<Page
|
||||
class="fr"
|
||||
:total="total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="(s: number) => { pageSize = s; pageNum = 1; run(); }"
|
||||
@on-change="(p: number) => { pageNum = p; run(); }"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-wrap {
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { bannerApi, uploadUrl } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { BannerVo } from "@/types/cms";
|
||||
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<BannerVo[]>([]);
|
||||
const loading = ref(false);
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true });
|
||||
|
||||
const linkTypeDict = [
|
||||
{ value: 1, label: "文章" },
|
||||
{ value: 2, label: "栏目" },
|
||||
{ value: 3, label: "外链" },
|
||||
];
|
||||
const linkTypeText = (v: number) => linkTypeDict.find(i => i.value === v)?.label || "-";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "图片",
|
||||
key: "imageUrl",
|
||||
width: 220,
|
||||
render: (h: any, p: any) =>
|
||||
h("img", { src: p.row.imageUrl, style: { maxHeight: "60px", maxWidth: "200px" } }),
|
||||
},
|
||||
{ title: "标题", key: "title", minWidth: 200, tooltip: true },
|
||||
{
|
||||
title: "跳转",
|
||||
key: "linkType",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", linkTypeText(p.row.linkType)),
|
||||
},
|
||||
{ title: "跳转目标", key: "linkValue", width: 180, tooltip: true },
|
||||
{ title: "排序", key: "sortNo", width: 80, align: "center" },
|
||||
{
|
||||
title: "上线时间",
|
||||
key: "startTime",
|
||||
width: 160,
|
||||
render: (h: any, p: any) => h("span", p.row.startTime || "立即"),
|
||||
},
|
||||
{
|
||||
title: "下线时间",
|
||||
key: "endTime",
|
||||
width: 160,
|
||||
render: (h: any, p: any) => h("span", p.row.endTime || "不限"),
|
||||
},
|
||||
{
|
||||
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: 120,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const formRef = ref();
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
title: "",
|
||||
imageUrl: "",
|
||||
linkType: 3,
|
||||
linkValue: "",
|
||||
sortNo: 0,
|
||||
startTime: "" as string | "",
|
||||
endTime: "" as string | "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
title: [{ required: true, message: "标题不得为空", trigger: "blur" }],
|
||||
linkType: [{ required: true, type: "number", message: "请选择跳转类型", trigger: "change" }],
|
||||
};
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await bannerApi.list(store.siteId, "home").send()) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
title: "",
|
||||
imageUrl: "",
|
||||
linkType: 3,
|
||||
linkValue: "",
|
||||
sortNo: 0,
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
status: 1,
|
||||
});
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: BannerVo) {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
imageUrl: row.imageUrl,
|
||||
linkType: row.linkType,
|
||||
linkValue: row.linkValue || "",
|
||||
sortNo: row.sortNo,
|
||||
startTime: row.startTime || "",
|
||||
endTime: row.endTime || "",
|
||||
status: row.status,
|
||||
});
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function bannerUploaded(res: any) {
|
||||
if (res.code === 0 && res.data) {
|
||||
form.imageUrl = res.data.url;
|
||||
} else {
|
||||
Message.error(res.msg || "图片上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return;
|
||||
if (!form.imageUrl) {
|
||||
Message.warning("请上传轮播图片");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await bannerApi
|
||||
.save({
|
||||
id: form.id,
|
||||
siteId: store.siteId!,
|
||||
position: "home",
|
||||
title: form.title,
|
||||
imageUrl: form.imageUrl,
|
||||
linkType: form.linkType,
|
||||
linkValue: form.linkValue || undefined,
|
||||
sortNo: form.sortNo,
|
||||
startTime: form.startTime || undefined,
|
||||
endTime: form.endTime || undefined,
|
||||
status: form.status,
|
||||
})
|
||||
.send();
|
||||
Message.success("保存成功!");
|
||||
modalVisible.value = false;
|
||||
run();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function doDelete(row: BannerVo) {
|
||||
MyConfirm({
|
||||
title: "删除轮播",
|
||||
msg: `确定删除「${row.title}」吗?`,
|
||||
async onOk() {
|
||||
await bannerApi.delete({ id: row.id!, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3)
|
||||
watch(() => store.siteId, () => run());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix">
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
新增轮播
|
||||
</Button>
|
||||
<span class="size-tip fr">轮播图设计尺寸建议 1920×560(皮肤包约定)</span>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无轮播" />
|
||||
|
||||
<Modal v-model="modalVisible" :title="form.id ? '编辑轮播' : '新增轮播'" :mask-closable="false" width="560px">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="标题" prop="title">
|
||||
<Input v-model="form.title" :maxlength="200" placeholder="alt 文案/无图兜底" />
|
||||
</FormItem>
|
||||
<FormItem label="轮播图片" required>
|
||||
<Upload
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId"
|
||||
with-credentials
|
||||
:show-upload-list="false"
|
||||
:format="['jpg', 'jpeg', 'png', 'gif', 'webp']"
|
||||
:on-success="bannerUploaded"
|
||||
>
|
||||
<Button icon="ios-cloud-upload-outline">上传图片</Button>
|
||||
</Upload>
|
||||
<img v-if="form.imageUrl" :src="form.imageUrl" class="img-preview" />
|
||||
</FormItem>
|
||||
<FormItem label="跳转类型" prop="linkType">
|
||||
<RadioGroup v-model="form.linkType">
|
||||
<Radio v-for="t in linkTypeDict" :key="t.value" :label="t.value">{{ t.label }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
v-if="form.linkType === 3"
|
||||
label="外链地址"
|
||||
prop="linkValue"
|
||||
:rules="[{ required: true, message: '外链地址不得为空', trigger: 'blur' }]"
|
||||
>
|
||||
<Input v-model="form.linkValue" :maxlength="500" placeholder="http(s):// 开头" />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
v-else
|
||||
label="跳转目标"
|
||||
prop="linkValue"
|
||||
:rules="[{ required: true, message: '请填写目标 ID', trigger: 'blur' }]"
|
||||
>
|
||||
<Input v-model="form.linkValue" :maxlength="64" :placeholder="form.linkType === 1 ? '文章 ID' : '栏目 ID'" />
|
||||
</FormItem>
|
||||
<FormItem label="排序">
|
||||
<InputNumber v-model="form.sortNo" :min="0" />
|
||||
</FormItem>
|
||||
<FormItem label="上线时间">
|
||||
<DatePicker type="datetime" v-model="form.startTime" format="yyyy-MM-dd HH:mm:ss" placeholder="空=立即上线" />
|
||||
</FormItem>
|
||||
<FormItem label="下线时间">
|
||||
<DatePicker type="datetime" v-model="form.endTime" format="yyyy-MM-dd HH:mm:ss" placeholder="空=不自动下线" />
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<RadioGroup v-model="form.status">
|
||||
<Radio :label="1">启用</Radio>
|
||||
<Radio :label="0">停用</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="modalVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.size-tip {
|
||||
color: #999;
|
||||
line-height: 40px;
|
||||
}
|
||||
.img-preview {
|
||||
display: block;
|
||||
max-width: 320px;
|
||||
max-height: 120px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, useTemplateRef, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
|
||||
import CategorySortModal from "@/components/CategorySortModal.vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import { categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除/搜索)+ ztree */
|
||||
const store = useCmsStore();
|
||||
|
||||
const refTreePanel = useTemplateRef("refTreePanel");
|
||||
const refFormData = useTemplateRef("refFormData");
|
||||
const refNodeNameInput = useTemplateRef("refNodeNameInput");
|
||||
|
||||
const curTreeNode = ref<any>({});
|
||||
const treeData = ref<any[]>([]);
|
||||
const selected = computed(() => (curTreeNode.value?.id ? curTreeNode.value : null));
|
||||
|
||||
const typeDict = [
|
||||
{ value: 1, label: "文章列表" },
|
||||
{ value: 2, label: "单页" },
|
||||
{ value: 3, label: "外链" },
|
||||
];
|
||||
|
||||
const sortModal = ref(false);
|
||||
const sortParentId = ref<number | null>(null);
|
||||
const sortParentName = ref("");
|
||||
const sortChildrenSource = ref<any[]>([]);
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
/** 编辑表单 */
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
parentId: 0 as number,
|
||||
name: "",
|
||||
type: 1,
|
||||
linkUrl: "",
|
||||
content: "",
|
||||
sortNo: 0,
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "栏目名称不得为空", trigger: "blur" }],
|
||||
type: [{ required: true, type: "number", message: "请选择类型", trigger: "change" }],
|
||||
};
|
||||
|
||||
/** 树数据源(CategoryTreePanel 拉取用;站点切换即时重载) */
|
||||
function loadTreeApi(): Promise<CategoryTreeNode[]> {
|
||||
return categoryApi.tree(store.siteId!).send() as Promise<CategoryTreeNode[]>;
|
||||
}
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
|
||||
watch(
|
||||
() => store.siteId,
|
||||
() => {
|
||||
curTreeNode.value = {};
|
||||
nextTick(() => refTreePanel.value?.loadTree());
|
||||
}
|
||||
);
|
||||
|
||||
function onTreeData(tree: any[]) {
|
||||
treeData.value = tree;
|
||||
}
|
||||
|
||||
function onTreeChange(node: any) {
|
||||
curTreeNode.value = node || {};
|
||||
}
|
||||
|
||||
/** 在全量树中找节点(子组排序取 children 用) */
|
||||
function findNode(list: any[], id: number): any {
|
||||
for (const n of list || []) {
|
||||
if (n.id === id) return n;
|
||||
if (n.children?.length) {
|
||||
const r = findNode(n.children, id);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function currentNode() {
|
||||
return selected.value || {};
|
||||
}
|
||||
|
||||
async function showAddNode() {
|
||||
const node = currentNode();
|
||||
refFormData.value?.resetFields();
|
||||
await nextTick();
|
||||
modalVisible.value = true;
|
||||
form.id = undefined;
|
||||
form.name = "";
|
||||
form.parentId = node?.id ?? 0;
|
||||
form.type = 1;
|
||||
form.linkUrl = "";
|
||||
form.content = "";
|
||||
form.sortNo = 0;
|
||||
form.seoTitle = "";
|
||||
form.seoDescription = "";
|
||||
form.status = 1;
|
||||
setTimeout(() => refNodeNameInput.value?.focus(), 300);
|
||||
}
|
||||
|
||||
function showEditNode() {
|
||||
const node = currentNode();
|
||||
if (!node?.id) {
|
||||
Message.warning("请先选择左侧栏目");
|
||||
return;
|
||||
}
|
||||
refFormData.value?.resetFields();
|
||||
modalVisible.value = true;
|
||||
Object.assign(form, {
|
||||
id: node.id,
|
||||
parentId: node.parentId ?? 0,
|
||||
name: node.name ?? "",
|
||||
type: node.type ?? 1,
|
||||
linkUrl: node.linkUrl || "",
|
||||
content: node.content || "",
|
||||
sortNo: node.sortNo ?? 0,
|
||||
seoTitle: node.seoTitle || "",
|
||||
seoDescription: node.seoDescription || "",
|
||||
status: node.status ?? 1,
|
||||
});
|
||||
nextTick().then(() => refNodeNameInput.value?.focus());
|
||||
}
|
||||
|
||||
function showSortNode() {
|
||||
const node = currentNode();
|
||||
if (!node?.id) {
|
||||
Message.warning("请先选择左侧栏目");
|
||||
return;
|
||||
}
|
||||
const found = findNode(treeData.value, node.id);
|
||||
sortChildrenSource.value = found?.children ?? [];
|
||||
sortParentId.value = node.id;
|
||||
sortParentName.value = node.name ?? "";
|
||||
sortModal.value = true;
|
||||
}
|
||||
|
||||
function showDeleteNode() {
|
||||
const node = currentNode();
|
||||
if (!node?.id) {
|
||||
Message.warning("请先选择左侧栏目");
|
||||
return;
|
||||
}
|
||||
MyConfirm({
|
||||
title: "删除栏目",
|
||||
msg: `确定删除「${node.name}」吗?`,
|
||||
info: "仅允许删除空栏目(无子栏目且无文章)",
|
||||
async onOk() {
|
||||
await categoryApi.delete({ id: node.id, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
curTreeNode.value = {};
|
||||
await refTreePanel.value?.refreshTree();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const valid = await refFormData.value?.validate();
|
||||
if (!valid) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await categoryApi
|
||||
.save({
|
||||
id: form.id,
|
||||
siteId: store.siteId!,
|
||||
parentId: form.parentId,
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
linkUrl: form.linkUrl || undefined,
|
||||
content: form.content || undefined,
|
||||
sortNo: form.sortNo,
|
||||
seoTitle: form.seoTitle || undefined,
|
||||
seoDescription: form.seoDescription || undefined,
|
||||
status: form.status,
|
||||
})
|
||||
.send();
|
||||
Message.success("保存成功!");
|
||||
modalVisible.value = false;
|
||||
await refTreePanel.value?.refreshTree(form.id ?? form.parentId);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSortSaved() {
|
||||
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tree-wrapper category-page">
|
||||
<!-- 左侧栏目树(userbase rule-tree 风格) -->
|
||||
<div class="tree-container category-tree-box">
|
||||
<ul class="opera-ul">
|
||||
<li class="add-node-li" @click="showAddNode">
|
||||
<Tooltip content="新增子栏目" placement="top" transfer>
|
||||
<SvgIcon name="tree-icon-1" />
|
||||
</Tooltip>
|
||||
</li>
|
||||
<Tooltip content="编辑栏目" placement="top" transfer>
|
||||
<li @click="showEditNode">
|
||||
<SvgIcon name="tree-icon-2" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="子栏目排序" placement="top" transfer>
|
||||
<li @click="showSortNode">
|
||||
<SvgIcon name="tree-icon-3" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="删除栏目" placement="top" transfer>
|
||||
<li @click="showDeleteNode">
|
||||
<SvgIcon name="tree-icon-5" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="搜索栏目" placement="top" transfer>
|
||||
<li class="search-node-li" @click="refTreePanel?.openSearch()">
|
||||
<Icon type="md-search" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
</ul>
|
||||
|
||||
<CategoryTreePanel
|
||||
ref="refTreePanel"
|
||||
v-model:curTreeNode="curTreeNode"
|
||||
:treeApi="loadTreeApi"
|
||||
:defaultSearchVisible="false"
|
||||
:showCloseButton="true"
|
||||
@on-change="onTreeChange"
|
||||
@on-tree-data="onTreeData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:选中栏目详情 -->
|
||||
<div class="main-container">
|
||||
<div class="info-panel" v-if="selected">
|
||||
<h3>{{ selected.name }}</h3>
|
||||
<p>类型:{{ typeDict.find(t => t.value === selected!.type)?.label }}</p>
|
||||
<p v-if="selected.type === 3">外链:{{ selected.linkUrl }}</p>
|
||||
<p v-if="selected.type === 2">单页内容:见门户预览</p>
|
||||
<p>排序号:{{ selected.sortNo }} · 状态:{{ selected.status === 1 ? "显示" : "隐藏" }}</p>
|
||||
<p v-if="selected.seoTitle">SEO 标题:{{ selected.seoTitle }}</p>
|
||||
<p v-if="selected.seoDescription">SEO 描述:{{ selected.seoDescription }}</p>
|
||||
</div>
|
||||
<div v-else class="empty-tip">请选择左侧栏目,或使用顶部图标新增子栏目</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<Modal
|
||||
v-model="modalVisible"
|
||||
:title="form.id ? '编辑栏目' : '新增子栏目'"
|
||||
:mask-closable="false"
|
||||
width="560px"
|
||||
>
|
||||
<Form ref="refFormData" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="上级栏目">
|
||||
<Input :value="form.parentId === 0 ? '根栏目' : '当前选中栏目下'" disabled />
|
||||
</FormItem>
|
||||
<FormItem label="栏目名称" prop="name">
|
||||
<Input ref="refNodeNameInput" v-model="form.name" :maxlength="64" placeholder="栏目名称" />
|
||||
</FormItem>
|
||||
<FormItem label="栏目类型" prop="type">
|
||||
<RadioGroup v-model="form.type">
|
||||
<Radio v-for="t in typeDict" :key="t.value" :label="t.value">{{ t.label }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 3" label="外链地址" prop="linkUrl">
|
||||
<Input v-model="form.linkUrl" :maxlength="500" placeholder="http(s):// 开头" />
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 2" label="单页内容">
|
||||
<Input v-model="form.content" type="textarea" :rows="8" placeholder="单页 HTML 内容(保存时自动做安全清洗)" />
|
||||
</FormItem>
|
||||
<FormItem label="排序">
|
||||
<InputNumber v-model="form.sortNo" :min="0" />
|
||||
</FormItem>
|
||||
<FormItem label="SEO 标题">
|
||||
<Input v-model="form.seoTitle" :maxlength="200" placeholder="空=用栏目名" />
|
||||
</FormItem>
|
||||
<FormItem label="SEO 描述">
|
||||
<Input v-model="form.seoDescription" type="textarea" :rows="2" :maxlength="500" />
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<RadioGroup v-model="form.status">
|
||||
<Radio :label="1">显示</Radio>
|
||||
<Radio :label="0">隐藏</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="modalVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 子栏目排序(userbase GroupSort 同款拖拽) -->
|
||||
<CategorySortModal
|
||||
v-model:visible="sortModal"
|
||||
:parent-id="sortParentId"
|
||||
:parent-name="sortParentName"
|
||||
:children="sortChildrenSource"
|
||||
@saved="onSortSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* UserBase group/index 同款工具栏样式 */
|
||||
.opera-ul {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 10px;
|
||||
|
||||
li {
|
||||
padding: 10px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.ivu-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-node-li,
|
||||
.search-node-li {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.category-tree-box {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.group-tree-search-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
p {
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { linkApi, uploadUrl } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { LinkVo } from "@/types/cms";
|
||||
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<LinkVo[]>([]);
|
||||
const loading = ref(false);
|
||||
const tableHeight = Utils.useTableHeight({ isOperat: true });
|
||||
|
||||
const columns = [
|
||||
{ title: "分组", key: "groupName", width: 140 },
|
||||
{
|
||||
title: "Logo",
|
||||
key: "logoUrl",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
p.row.logoUrl
|
||||
? h("img", { src: p.row.logoUrl, style: { maxHeight: "36px", maxWidth: "72px" } })
|
||||
: h("span", { style: { color: "#999" } }, "-"),
|
||||
},
|
||||
{ title: "名称", key: "name", minWidth: 200, tooltip: true },
|
||||
{ title: "链接地址", key: "linkUrl", minWidth: 260, tooltip: true },
|
||||
{ title: "排序", key: "sortNo", width: 80, align: "center" },
|
||||
{
|
||||
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: 120,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const formRef = ref();
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
groupName: "default",
|
||||
name: "",
|
||||
logoUrl: "",
|
||||
linkUrl: "",
|
||||
sortNo: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "链接名称不得为空", trigger: "blur" }],
|
||||
linkUrl: [{ required: true, message: "链接地址不得为空", trigger: "blur" }],
|
||||
};
|
||||
|
||||
/** 分组 Tab(从数据聚合) */
|
||||
const groups = computed(() => {
|
||||
const set = new Set<string>();
|
||||
rows.value.forEach(r => set.add(r.groupName));
|
||||
return Array.from(set);
|
||||
});
|
||||
const activeGroup = ref<string>("");
|
||||
|
||||
const visibleRows = computed(() =>
|
||||
activeGroup.value ? rows.value.filter(r => r.groupName === activeGroup.value) : rows.value
|
||||
);
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await linkApi.list(store.siteId).send()) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
groupName: activeGroup.value || "default",
|
||||
name: "",
|
||||
logoUrl: "",
|
||||
linkUrl: "",
|
||||
sortNo: 0,
|
||||
status: 1,
|
||||
});
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: LinkVo) {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
groupName: row.groupName,
|
||||
name: row.name,
|
||||
logoUrl: row.logoUrl || "",
|
||||
linkUrl: row.linkUrl,
|
||||
sortNo: row.sortNo,
|
||||
status: row.status,
|
||||
});
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function logoUploaded(res: any) {
|
||||
if (res.code === 0 && res.data) {
|
||||
form.logoUrl = res.data.url;
|
||||
} else {
|
||||
Message.error(res.msg || "Logo 上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await linkApi
|
||||
.save({
|
||||
id: form.id,
|
||||
siteId: store.siteId!,
|
||||
groupName: form.groupName || "default",
|
||||
name: form.name,
|
||||
logoUrl: form.logoUrl || undefined,
|
||||
linkUrl: form.linkUrl,
|
||||
sortNo: form.sortNo,
|
||||
status: form.status,
|
||||
})
|
||||
.send();
|
||||
Message.success("保存成功!");
|
||||
modalVisible.value = false;
|
||||
run();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function doDelete(row: LinkVo) {
|
||||
MyConfirm({
|
||||
title: "删除友链",
|
||||
msg: `确定删除「${row.name}」吗?`,
|
||||
async onOk() {
|
||||
await linkApi.delete({ id: row.id!, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3)
|
||||
watch(() => store.siteId, () => run());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix">
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
新增友链
|
||||
</Button>
|
||||
<Tabs v-if="groups.length" class="fr" :value="activeGroup" @on-click="(g: string) => (activeGroup = g)">
|
||||
<TabPane v-for="g in groups" :key="g" :name="g" :label="`${g}(${rows.filter(r => r.groupName === g).length})`" />
|
||||
</Tabs>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="visibleRows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无友链"
|
||||
/>
|
||||
|
||||
<Modal v-model="modalVisible" :title="form.id ? '编辑友链' : '新增友链'" :mask-closable="false" width="520px">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="分组">
|
||||
<Input v-model="form.groupName" :maxlength="64" placeholder="上级部门/兄弟院校/合作单位…" />
|
||||
</FormItem>
|
||||
<FormItem label="名称" prop="name">
|
||||
<Input v-model="form.name" :maxlength="128" />
|
||||
</FormItem>
|
||||
<FormItem label="Logo(可选)">
|
||||
<Upload
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId"
|
||||
with-credentials
|
||||
:show-upload-list="false"
|
||||
:format="['jpg', 'jpeg', 'png', 'gif', 'webp']"
|
||||
:on-success="logoUploaded"
|
||||
>
|
||||
<Button icon="ios-cloud-upload-outline">上传 Logo</Button>
|
||||
</Upload>
|
||||
<img v-if="form.logoUrl" :src="form.logoUrl" class="img-preview" />
|
||||
</FormItem>
|
||||
<FormItem label="链接地址" prop="linkUrl">
|
||||
<Input v-model="form.linkUrl" :maxlength="500" placeholder="http(s):// 开头" />
|
||||
</FormItem>
|
||||
<FormItem label="排序">
|
||||
<InputNumber v-model="form.sortNo" :min="0" />
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<RadioGroup v-model="form.status">
|
||||
<Radio :label="1">显示</Radio>
|
||||
<Radio :label="0">隐藏</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="modalVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.img-preview {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
max-height: 64px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,602 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { categoryApi, articleApi, bannerApi, linkApi, siteApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { CategoryTreeNode, ArticlePageItem, BannerVo, LinkVo, SiteVo } from "@/types/cms";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
/**
|
||||
* 门户预览(SPA 内简版门户):mock 模式下「预览门户」跳转到这里,
|
||||
* 用与管理端同一套接口拼出门户大概样子(导航/轮播/文章列表/友链/页脚)。
|
||||
* 真实环境仍以 Thymeleaf SSR 门户为准(/portal/{code}/)。
|
||||
*/
|
||||
const store = useCmsStore();
|
||||
|
||||
const categories = ref<CategoryTreeNode[]>([]);
|
||||
const articles = ref<ArticlePageItem[]>([]);
|
||||
const banners = ref<BannerVo[]>([]);
|
||||
const links = ref<LinkVo[]>([]);
|
||||
const sites = ref<SiteVo[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
/** 当前查看的文章(点列表进入详情态) */
|
||||
const currentArticle = ref<{ title: string; content: string; publishTime?: string; source?: string } | null>(null);
|
||||
|
||||
/** 轮播 */
|
||||
const bannerIndex = ref(0);
|
||||
let bannerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const siteName = computed(() => store.currentSite?.name || "站点门户");
|
||||
const topNav = computed(() => categories.value.filter(c => c.status === 1).slice(0, 8));
|
||||
/** 专业院系子站:与真门户同语义——顶层站取其子站、院系子站取兄弟站(含自身) */
|
||||
const colleges = computed(() => {
|
||||
const cur = sites.value.find(s => s.id === store.siteId);
|
||||
if (!cur) return [];
|
||||
const anchor = cur.parentSiteId || cur.id;
|
||||
return sites.value.filter(s => s.parentSiteId === anchor && s.status === 1);
|
||||
});
|
||||
|
||||
/** 切换到该院系的真实门户(mock 模式由 mock 中间件承接,真实模式为 SSR 门户) */
|
||||
function openCollege(code: string) {
|
||||
window.open(`${config.serviceHost}/portal/${code}/`, "_blank");
|
||||
}
|
||||
const linkGroups = computed(() => {
|
||||
const map = new Map<string, LinkVo[]>();
|
||||
links.value.forEach(l => {
|
||||
if (!map.has(l.groupName)) map.set(l.groupName, []);
|
||||
map.get(l.groupName)!.push(l);
|
||||
});
|
||||
return [...map.entries()];
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
currentArticle.value = null;
|
||||
try {
|
||||
const [tree, page, bannerList, linkList] = await Promise.all([
|
||||
categoryApi.tree(store.siteId).send() as Promise<CategoryTreeNode[]>,
|
||||
articleApi.page({ siteId: store.siteId!, status: 1, page: 1, pageSize: 12 }).send(),
|
||||
bannerApi.list(store.siteId!, "home").send() as Promise<BannerVo[]>,
|
||||
linkApi.list(store.siteId!).send() as Promise<LinkVo[]>,
|
||||
]);
|
||||
categories.value = tree || [];
|
||||
articles.value = page?.list || [];
|
||||
banners.value = (bannerList || []).filter(b => b.status === 1);
|
||||
links.value = linkList || [];
|
||||
bannerIndex.value = 0;
|
||||
startCarousel();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startCarousel() {
|
||||
stopCarousel();
|
||||
if (banners.value.length > 1) {
|
||||
bannerTimer = setInterval(() => {
|
||||
bannerIndex.value = (bannerIndex.value + 1) % banners.value.length;
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopCarousel() {
|
||||
if (bannerTimer) {
|
||||
clearInterval(bannerTimer);
|
||||
bannerTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function openArticle(item: ArticlePageItem) {
|
||||
const detail = await articleApi.get(store.siteId!, item.id).send();
|
||||
if (detail) {
|
||||
currentArticle.value = {
|
||||
title: detail.title,
|
||||
content: detail.content || "<p>(无正文)</p>",
|
||||
publishTime: detail.publishTime,
|
||||
source: detail.source,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
currentArticle.value = null;
|
||||
}
|
||||
|
||||
watch(() => store.siteId, () => load());
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
siteApi.list().send().then((list: SiteVo[]) => {
|
||||
sites.value = list || [];
|
||||
});
|
||||
});
|
||||
onBeforeUnmount(stopCarousel);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="portal-preview">
|
||||
<div class="portal-tip">门户预览(简版):正式门户由后端 Thymeleaf 渲染于 /portal/{code}/</div>
|
||||
|
||||
<!-- 门户头部 -->
|
||||
<header class="portal-header">
|
||||
<div class="site-title">
|
||||
<span class="logo-dot"></span>
|
||||
{{ siteName }}
|
||||
</div>
|
||||
<!-- 专业院系下拉(与真门户 header 同语义,点击打开该院系门户) -->
|
||||
<div class="site-colleges" v-if="colleges.length">
|
||||
<span class="colleges-toggle">专业院系 <i>▾</i></span>
|
||||
<ul class="colleges-menu">
|
||||
<li v-for="c in colleges" :key="c.id" :class="{ on: c.id === store.siteId }">
|
||||
<a href="javascript:;" @click="openCollege(c.code)" :title="c.name">{{ c.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<nav class="portal-nav">
|
||||
<!-- 与真门户 header 一致的二级下拉;简版无栏目路由,仅外链子栏目真实跳转 -->
|
||||
<div class="nav-item" v-for="c in topNav" :key="c.id">
|
||||
<a href="javascript:;" :title="c.type === 3 ? c.linkUrl : c.name">
|
||||
{{ c.name }}
|
||||
<span v-if="c.children?.length" class="sub-arrow">▾</span>
|
||||
</a>
|
||||
<ul class="sub-menu" v-if="c.children?.length">
|
||||
<li v-for="s in c.children" :key="s.id">
|
||||
<a
|
||||
:href="s.type === 3 ? s.linkUrl : 'javascript:;'"
|
||||
:target="s.type === 3 ? '_blank' : undefined"
|
||||
rel="noopener noreferrer"
|
||||
>{{ s.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="portal-body" v-if="!loading">
|
||||
<!-- 轮播 -->
|
||||
<div class="banner-box" v-if="banners.length && !currentArticle">
|
||||
<img :src="banners[bannerIndex]?.imageUrl" alt="" class="banner-main" />
|
||||
<div class="banner-caption">{{ banners[bannerIndex]?.title }}</div>
|
||||
<div class="banner-dots">
|
||||
<span
|
||||
v-for="(b, i) in banners"
|
||||
:key="i"
|
||||
:class="['dot', i === bannerIndex ? 'on' : '']"
|
||||
@click="bannerIndex = i"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章详情态 -->
|
||||
<div class="article-detail" v-if="currentArticle">
|
||||
<a class="back-link" href="javascript:;" @click="backToList">← 返回列表</a>
|
||||
<h1>{{ currentArticle.title }}</h1>
|
||||
<p class="meta">
|
||||
发布时间:{{ currentArticle.publishTime || "-" }}
|
||||
<template v-if="currentArticle.source"> 来源:{{ currentArticle.source }}</template>
|
||||
</p>
|
||||
<div class="article-content" v-html="currentArticle.content"></div>
|
||||
</div>
|
||||
|
||||
<!-- 列表态 -->
|
||||
<div class="portal-columns" v-else>
|
||||
<div class="main-col">
|
||||
<div class="col-head"><h2>文章列表</h2><span>仅展示「已发布」文章,置顶优先</span></div>
|
||||
<ul class="article-list">
|
||||
<li v-for="a in articles" :key="a.id">
|
||||
<a href="javascript:;" @click="openArticle(a)">
|
||||
<em v-if="a.topmost === 1">[置顶]</em>{{ a.title }}
|
||||
</a>
|
||||
<span class="date">{{ (a.publishTime || "").slice(0, 10) }}</span>
|
||||
</li>
|
||||
<li v-if="!articles.length" class="empty">该站点暂无已发布文章</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="side-col">
|
||||
<div class="col-head"><h2>栏目导航</h2></div>
|
||||
<ul class="cat-list">
|
||||
<li v-for="c in topNav" :key="c.id">
|
||||
{{ c.name }}
|
||||
<em>{{ c.children?.length ? `${c.children.length} 个子栏目` : { 1: "文章列表", 2: "单页", 3: "外链" }[c.type] }}</em>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页脚:友情链接 -->
|
||||
<footer class="portal-footer" v-if="linkGroups.length">
|
||||
<div v-for="[group, items] in linkGroups" :key="group" class="link-group">
|
||||
<span class="group-name">{{ group }}:</span>
|
||||
<a v-for="l in items" :key="l.id" :href="l.linkUrl" target="_blank" rel="noopener noreferrer">
|
||||
{{ l.name }}
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.portal-preview {
|
||||
min-height: 100%;
|
||||
background: #f5f6f8;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.portal-tip {
|
||||
background: #fff7e6;
|
||||
color: #ad6800;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.portal-header {
|
||||
background: #fff;
|
||||
border-bottom: 2px solid #2d8cf0;
|
||||
padding: 14px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.site-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1c2b45;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.logo-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
background: #2d8cf0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.site-colleges {
|
||||
position: relative;
|
||||
|
||||
.colleges-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid #e5e6eb;
|
||||
padding: 7px 14px;
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.colleges-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-top: 2px solid #2d8cf0;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
&:hover .colleges-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.colleges-menu li a {
|
||||
display: block;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: #f2f6fc;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
|
||||
.colleges-menu li.on a {
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-nav {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
padding: 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 150px;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-top: 2px solid #2d8cf0;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
|
||||
li a {
|
||||
display: block;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: #f2f6fc;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item:hover .sub-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
|
||||
.sub-arrow {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.portal-body {
|
||||
max-width: 1080px;
|
||||
margin: 16px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.banner-box {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.banner-main {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.banner-caption {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.55));
|
||||
color: #fff;
|
||||
padding: 26px 18px 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.banner-dots {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
|
||||
&.on {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.portal-columns {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.main-col {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.side-col {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.col-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
margin: 0;
|
||||
border-left: 4px solid #2d8cf0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.article-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 2px;
|
||||
border-bottom: 1px dashed #eee;
|
||||
|
||||
a {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
|
||||
em {
|
||||
color: #ed4014;
|
||||
font-style: normal;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
padding: 30px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cat-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
padding: 8px 2px;
|
||||
border-bottom: 1px dashed #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
|
||||
em {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article-detail {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px 32px;
|
||||
|
||||
.back-link {
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
line-height: 1.9;
|
||||
|
||||
:deep(p) {
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.portal-footer {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
|
||||
.link-group {
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
|
||||
.group-name {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2d8cf0;
|
||||
text-decoration: none;
|
||||
margin-right: 14px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,404 @@
|
||||
<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, portalBase } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms";
|
||||
import ThemePicker from "@/components/theme/ThemePicker.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);
|
||||
const userInfo = computed(() => ({
|
||||
name: store.session.baseUserName || "管理员",
|
||||
avatar: store.session.baseUserAvatar,
|
||||
}));
|
||||
|
||||
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(`${portalBase(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" } });
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 添加学校(超管):绑定已有学校 + 初始化主站 ===== */
|
||||
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 = {
|
||||
schId: [{ required: true, 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 (!form.value.name && opt) {
|
||||
form.value.name = `${opt.name}门户网站`;
|
||||
}
|
||||
if (!form.value.code && opt) {
|
||||
form.value.code = `sch-${opt.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const linkUserInfo = () => {
|
||||
window.location.href = config.baseHost + "/v/userinfo";
|
||||
};
|
||||
const logout = () => {
|
||||
const url = "/cas/logout?service=" + window.location.href;
|
||||
window.location.href = config.ssoHost + url;
|
||||
};
|
||||
|
||||
onMounted(run);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workbench">
|
||||
<header class="wb-header fix">
|
||||
<h4 class="fl align-middle">云海CMS · 学校工作台</h4>
|
||||
<div class="wb-header-aside fr">
|
||||
<Button v-if="isSuper" size="small" class="wb-btn" @click="goAllSites" :disabled="!cards.length">
|
||||
<Icon type="ios-globe" />
|
||||
全部站点总览
|
||||
</Button>
|
||||
<Button v-if="isSuper" size="small" type="primary" class="wb-btn" @click="openAdd">
|
||||
<Icon type="ios-add" />
|
||||
添加学校
|
||||
</Button>
|
||||
<span class="delimiter align-middle"></span>
|
||||
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle wb-avatar" @click="linkUserInfo" />
|
||||
<a class="name fl align-middle" href="javascript:void(0);" @click="linkUserInfo">{{ userInfo.name }}</a>
|
||||
<span class="delimiter align-middle"></span>
|
||||
<a href="javascript:;" @click="logout" title="退出登陆">
|
||||
<i class="iconfont icon-logout"></i>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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;
|
||||
}
|
||||
.wb-header {
|
||||
height: 56px;
|
||||
background: #1f2a3a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
h4 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
line-height: 56px;
|
||||
}
|
||||
.wb-header-aside {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
.name {
|
||||
color: #fff;
|
||||
}
|
||||
.delimiter {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
display: inline-block;
|
||||
}
|
||||
.wb-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.wb-btn {
|
||||
// 深色 header 上按钮保持默认浅色块
|
||||
}
|
||||
}
|
||||
}
|
||||
.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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import {
|
||||
SITE_CONFIG_FIELD_DEFS,
|
||||
SITE_CONFIG_GROUPS,
|
||||
SiteConfigExtraRow,
|
||||
SiteConfigFieldDef,
|
||||
SiteConfigFormModel,
|
||||
extraRowError,
|
||||
firstExtraError,
|
||||
parseSiteConfig,
|
||||
serializeSiteConfig,
|
||||
validateFieldValue,
|
||||
} from "@/utils/siteConfigForm";
|
||||
|
||||
/**
|
||||
* 站点配置结构化表单(docs/09 §3.1):v-model 直接绑定 configJson 字符串。
|
||||
* 内部 parse/serialize 双向映射;已知键分组表单 + 扩展参数动态行;
|
||||
* 保存前由父级调用 validate()(含扩展行兜底校验)。
|
||||
*/
|
||||
const props = defineProps<{ modelValue: string }>();
|
||||
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
|
||||
|
||||
const formRef = ref();
|
||||
const formModel = reactive<{ fields: Record<string, string>; extras: SiteConfigExtraRow[] }>({
|
||||
fields: {},
|
||||
extras: [],
|
||||
});
|
||||
let preserved: Record<string, unknown> = {};
|
||||
let lastEmitted: string | null = null;
|
||||
|
||||
function parseIn(v: string) {
|
||||
const m: SiteConfigFormModel = parseSiteConfig(v);
|
||||
Object.keys(formModel.fields).forEach(k => delete formModel.fields[k]);
|
||||
Object.assign(formModel.fields, m.fields);
|
||||
formModel.extras.splice(0, formModel.extras.length, ...m.extras.map(r => ({ ...r })));
|
||||
preserved = m.preserved;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
v => {
|
||||
// 自己 emit 出去的值回灌时不重解析,避免光标/输入被打断
|
||||
if (v !== lastEmitted) parseIn(v || "");
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function emitUp() {
|
||||
lastEmitted = serializeSiteConfig({
|
||||
fields: { ...formModel.fields },
|
||||
extras: formModel.extras.map(r => ({ ...r })),
|
||||
preserved,
|
||||
});
|
||||
emit("update:modelValue", lastEmitted);
|
||||
}
|
||||
|
||||
function addExtra() {
|
||||
formModel.extras.push({ key: "", value: "" });
|
||||
}
|
||||
|
||||
function removeExtra(i: number) {
|
||||
formModel.extras.splice(i, 1);
|
||||
emitUp();
|
||||
}
|
||||
|
||||
/** 保存前校验:内置字段 Form 规则 + 扩展行兜底;通过返回 true */
|
||||
async function validate(): Promise<boolean> {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return false;
|
||||
const extraMsg = firstExtraError(formModel.extras);
|
||||
if (extraMsg) {
|
||||
Message.error(`扩展参数有误:${extraMsg}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
defineExpose({ validate });
|
||||
|
||||
function rulesFor(def: SiteConfigFieldDef) {
|
||||
return [
|
||||
{
|
||||
validator: (_rule: unknown, value: string, cb: (err?: Error) => void) => {
|
||||
const msg = validateFieldValue(def, value);
|
||||
cb(msg ? new Error(msg) : undefined);
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function defsOf(group: string): SiteConfigFieldDef[] {
|
||||
return SITE_CONFIG_FIELD_DEFS.filter(d => d.group === group);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-config-form">
|
||||
<Form ref="formRef" :model="formModel" :label-width="130">
|
||||
<template v-for="g in SITE_CONFIG_GROUPS" :key="g.name">
|
||||
<div class="group-title">{{ g.title }}</div>
|
||||
<FormItem
|
||||
v-for="def in defsOf(g.name)"
|
||||
:key="def.key"
|
||||
:label="def.label"
|
||||
:prop="`fields.${def.key}`"
|
||||
:rules="rulesFor(def)"
|
||||
>
|
||||
<Input
|
||||
v-if="!def.multiline"
|
||||
v-model="formModel.fields[def.key]"
|
||||
:placeholder="def.placeholder"
|
||||
@on-change="emitUp"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="formModel.fields[def.key]"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="def.placeholder"
|
||||
@on-change="emitUp"
|
||||
/>
|
||||
<p v-if="def.tip" class="cfg-tip">{{ def.tip }}</p>
|
||||
</FormItem>
|
||||
</template>
|
||||
|
||||
<div class="group-title">扩展参数</div>
|
||||
<div v-for="(row, i) in formModel.extras" :key="i" class="extra-row">
|
||||
<Input v-model="row.key" placeholder="参数名" style="width: 200px" @on-change="emitUp" />
|
||||
<Input v-model="row.value" placeholder="参数值" style="flex: 1" @on-change="emitUp" />
|
||||
<Button type="text" class="extra-del" icon="md-trash" @click="removeExtra(i)" />
|
||||
<p v-if="extraRowError(formModel.extras, i)" class="cfg-error">
|
||||
{{ extraRowError(formModel.extras, i) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="dashed" long icon="md-add" class="extra-add" @click="addExtra">新增参数</Button>
|
||||
<p class="cfg-tip">
|
||||
面向后续扩展的预留配置项(键名仅字母/数字/下划线);系统托管参数(如 theme_params)不在此显示,保存时自动保留。
|
||||
</p>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.site-config-form {
|
||||
.group-title {
|
||||
font-weight: 600;
|
||||
color: #515a6e;
|
||||
font-size: 13px;
|
||||
margin: 8px 0 14px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
line-height: 1.2;
|
||||
&:not(:first-child) {
|
||||
margin-top: 22px;
|
||||
}
|
||||
}
|
||||
.cfg-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cfg-error {
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.extra-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
.extra-del {
|
||||
color: #999;
|
||||
&:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
.extra-add {
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user