init local code
@@ -0,0 +1,8 @@
|
|||||||
|
# 正式环境 - base
|
||||||
|
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
||||||
|
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||||
|
# API 基址用相对 "/cms"(同源 + context-path):SPA 由 cms-app 自带(/cms/admin),
|
||||||
|
# base 域/Caddy 与学校域 vhost 都按路径反代 /cms → 同源恒成立;
|
||||||
|
# 写死绝对域名会导致 localhost 直连、换域名等场景全部"加载中"卡死(day2 修复)
|
||||||
|
VITE_SERVICE_HOST=/cms
|
||||||
|
VITE_IS_MOCK=false
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 开发环境 (dev):API 走 /api 同源相对路径(vite proxy 改写转发到本地后端 8072 的 /cms/api),
|
||||||
|
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
|
||||||
|
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
||||||
|
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||||
|
VITE_SERVICE_HOST=
|
||||||
|
VITE_IS_MOCK=false
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Mock 演示模式(npm run dev:mock):VITE_IS_MOCK=true 启用 vite mock 中间件,
|
||||||
|
# 拦截 /api、/portal、/mock-img、/mock-files,内存数据应答,无需后端/CAS/MySQL。
|
||||||
|
# 数据见 front/mock/data.ts;所有改动仅存在于内存,重启即还原。
|
||||||
|
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
||||||
|
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||||
|
VITE_SERVICE_HOST=
|
||||||
|
VITE_IS_MOCK=true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 生产环境(与 base 同源域名)
|
||||||
|
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
||||||
|
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||||
|
# 相对 "/cms"(同源 + context-path),理由见 .env.base 注释
|
||||||
|
VITE_SERVICE_HOST=/cms
|
||||||
|
VITE_IS_MOCK=false
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
logs/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
yarn.lock
|
||||||
|
.npmrc
|
||||||
|
.temp/
|
||||||
|
.DS_Store
|
||||||
|
src/auto-imports.d.ts
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ["airbnb", "prettier", "prettier/react"],
|
||||||
|
singleQuote: false, // 是否强制使用单引号
|
||||||
|
printWidth: 120, // 超过最大值换行
|
||||||
|
tabWidth: 4,
|
||||||
|
htmlWhitespaceSensitivity: "ignore",
|
||||||
|
semi: true, // 结尾是否使用分号
|
||||||
|
//disableLanguages: ["vue"], // 不格式化vue文件,vue文件的格式化单独设置
|
||||||
|
endOfLine: "auto",
|
||||||
|
arrowParens: "avoid", // 箭头函数参数括号 默认avoid 可选 avoid| always
|
||||||
|
};
|
||||||
|
//如需显示webpack目录,运行以下命令
|
||||||
|
// npm run eject
|
||||||
|
//如需配合使eslint生效,运行以下命令
|
||||||
|
//npm install --save-dev eslint-plugin-prettier
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from "@alova/wormhole";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
generator: [
|
||||||
|
{
|
||||||
|
input: "http://localhost:8072/cms/v2/api-docs",
|
||||||
|
platform: "swagger",
|
||||||
|
output: "src/api/gen",
|
||||||
|
type: "typescript",
|
||||||
|
global: "Apis",
|
||||||
|
splitImplementation: true,
|
||||||
|
implementation: "inline",
|
||||||
|
handleApi: (api: any) => {
|
||||||
|
if (api.deprecated) return false;
|
||||||
|
api.name = api.summary || api.name;
|
||||||
|
return api;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%- title %></title>
|
||||||
|
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
||||||
|
<script src="./libs/jquery.min.js"></script>
|
||||||
|
<script src="./libs/ztree/jquery.ztree.all.min.js"></script>
|
||||||
|
<%- injectScript %>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* Mock 内存数据库 + 种子数据(仅 dev mock 模式使用,vite.config 按需加载)。
|
||||||
|
* 数据形状与 com.yunhai.cms.model.vo 一一对应;时间串用 "yyyy-MM-dd HH:mm:ss"。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===== 行类型 ===== */
|
||||||
|
|
||||||
|
export interface MockSite {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id) */
|
||||||
|
parentSiteId: number;
|
||||||
|
schId: number;
|
||||||
|
schName: string;
|
||||||
|
name: string;
|
||||||
|
theme: string;
|
||||||
|
configJson: string;
|
||||||
|
status: number;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockCategory {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
parentId: number;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
linkUrl?: string;
|
||||||
|
template?: string;
|
||||||
|
content?: string;
|
||||||
|
sortNo: number;
|
||||||
|
seoTitle?: string;
|
||||||
|
seoDescription?: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockArticle {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
categoryId: number;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
coverImage?: string;
|
||||||
|
authorName: string;
|
||||||
|
source: string;
|
||||||
|
/** 0=草稿 1=已发布 2=已下架 */
|
||||||
|
status: number;
|
||||||
|
topmost: number;
|
||||||
|
publishTime?: string;
|
||||||
|
offlineTime?: string;
|
||||||
|
viewCount: number;
|
||||||
|
createTime: string;
|
||||||
|
createBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockAttachment {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
articleId?: number;
|
||||||
|
fileName: string;
|
||||||
|
url: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockBanner {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
position: string;
|
||||||
|
title: string;
|
||||||
|
imageUrl: string;
|
||||||
|
linkType: number;
|
||||||
|
linkValue?: string;
|
||||||
|
sortNo: number;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockLink {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
groupName: string;
|
||||||
|
name: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
linkUrl: string;
|
||||||
|
sortNo: number;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockDb {
|
||||||
|
sites: MockSite[];
|
||||||
|
categories: MockCategory[];
|
||||||
|
articles: MockArticle[];
|
||||||
|
attachments: MockAttachment[];
|
||||||
|
banners: MockBanner[];
|
||||||
|
links: MockLink[];
|
||||||
|
/** 上传文件缓存:url 尾段 → {buffer, mime} */
|
||||||
|
files: Map<string, { buffer: Buffer; mime: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 工具 ===== */
|
||||||
|
|
||||||
|
function fmt(d: Date): string {
|
||||||
|
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 daysAgo(n: number, h = 9, m = 30): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - n);
|
||||||
|
d.setHours(h, m, 0, 0);
|
||||||
|
return fmt(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 简单富文本正文(栏目/文章种子共用) */
|
||||||
|
function para(...ps: string[]): string {
|
||||||
|
return ps.map(t => `<p>${t}</p>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 种子数据 ===== */
|
||||||
|
|
||||||
|
export function seedDb(): MockDb {
|
||||||
|
const db: MockDb = {
|
||||||
|
sites: [],
|
||||||
|
categories: [],
|
||||||
|
articles: [],
|
||||||
|
attachments: [],
|
||||||
|
banners: [],
|
||||||
|
links: [],
|
||||||
|
files: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- 站点(demo 为主站 + jdx/xxgc 院系子站;fsz 独立顶层站无子站) ---- */
|
||||||
|
db.sites.push(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: "demo",
|
||||||
|
parentSiteId: 0,
|
||||||
|
schId: 1001,
|
||||||
|
schName: "云海大学",
|
||||||
|
name: "云海大学门户网站",
|
||||||
|
theme: "default",
|
||||||
|
configJson: JSON.stringify(
|
||||||
|
{
|
||||||
|
domain: "www.yunhai-demo.edu.cn",
|
||||||
|
public_base_url: "http://www.yunhai-demo.edu.cn",
|
||||||
|
logo: "/mock-img/logo-univ.svg",
|
||||||
|
copyright: "云海大学 · 版权所有 © 2026(Mock 数据)",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
status: 1,
|
||||||
|
createTime: daysAgo(90),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
code: "fsz",
|
||||||
|
parentSiteId: 0,
|
||||||
|
schId: 1002,
|
||||||
|
schName: "云海大学附属中学",
|
||||||
|
name: "附中信息门户",
|
||||||
|
theme: "blue-white",
|
||||||
|
configJson: JSON.stringify({ copyright: "云大附中 · Mock 数据" }),
|
||||||
|
status: 1,
|
||||||
|
createTime: daysAgo(60),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
code: "jdx",
|
||||||
|
parentSiteId: 1,
|
||||||
|
schId: 1001,
|
||||||
|
schName: "云海大学",
|
||||||
|
name: "机电学院",
|
||||||
|
theme: "default",
|
||||||
|
configJson: JSON.stringify({
|
||||||
|
public_base_url: "http://www.yunhai-demo.edu.cn/college/jdx",
|
||||||
|
copyright: "机电学院 · Mock 数据",
|
||||||
|
}),
|
||||||
|
status: 1,
|
||||||
|
createTime: daysAgo(50),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
code: "xxgc",
|
||||||
|
parentSiteId: 1,
|
||||||
|
schId: 1001,
|
||||||
|
schName: "云海大学",
|
||||||
|
name: "信息工程学院",
|
||||||
|
theme: "default",
|
||||||
|
configJson: JSON.stringify({
|
||||||
|
public_base_url: "http://www.yunhai-demo.edu.cn/college/xxgc",
|
||||||
|
copyright: "信息工程学院 · Mock 数据",
|
||||||
|
}),
|
||||||
|
status: 1,
|
||||||
|
createTime: daysAgo(45),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- 栏目(site1 大学门户) ---- */
|
||||||
|
const c1 = (o: Partial<MockCategory> & { id: number; name: string; type: number }) => ({
|
||||||
|
siteId: 1,
|
||||||
|
parentId: 0,
|
||||||
|
sortNo: 0,
|
||||||
|
status: 1,
|
||||||
|
...o,
|
||||||
|
} as MockCategory);
|
||||||
|
db.categories.push(
|
||||||
|
c1({ id: 1, name: "学校概况", type: 2, sortNo: 1, seoTitle: "云海大学简介", content: para("云海大学创建于 1958 年,是一所以工为主、多学科协调发展的综合性大学。", "学校现有全日制在校生 3.2 万余人,教职工 2800 余人,拥有两院院士 12 名。(此处为 Mock 演示数据)") }),
|
||||||
|
c1({ id: 2, name: "新闻中心", type: 1, sortNo: 2 }),
|
||||||
|
c1({ id: 3, name: "通知公告", type: 1, parentId: 2, sortNo: 1, seoTitle: "通知公告" }),
|
||||||
|
c1({ id: 4, name: "校园动态", type: 1, parentId: 2, sortNo: 2 }),
|
||||||
|
c1({ id: 5, name: "媒体报道", type: 1, parentId: 2, sortNo: 3 }),
|
||||||
|
c1({ id: 6, name: "教学科研", type: 1, sortNo: 3 }),
|
||||||
|
c1({ id: 7, name: "教学动态", type: 1, parentId: 6, sortNo: 1 }),
|
||||||
|
c1({ id: 8, name: "科研成果", type: 1, parentId: 6, sortNo: 2 }),
|
||||||
|
c1({ id: 9, name: "招生网", type: 3, sortNo: 4, linkUrl: "https://zs.example.edu.cn" }),
|
||||||
|
c1({ id: 10, name: "联系我们", type: 2, sortNo: 5, content: para("地址:云海市学府路 1 号", "邮编:100000", "电话:010-88888888(Mock)") })
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- 栏目(site2 附中门户,验证站点数据隔离) ---- */
|
||||||
|
const c2 = (o: Partial<MockCategory> & { id: number; name: string; type: number }) => ({
|
||||||
|
siteId: 2,
|
||||||
|
parentId: 0,
|
||||||
|
sortNo: 0,
|
||||||
|
status: 1,
|
||||||
|
...o,
|
||||||
|
} as MockCategory);
|
||||||
|
db.categories.push(
|
||||||
|
c2({ id: 21, name: "附中新闻", type: 1, sortNo: 1 }),
|
||||||
|
c2({ id: 22, name: "校园风采", type: 2, sortNo: 2, content: para("附中校园风光介绍(Mock)。") })
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- 栏目(site3/4 院系子站,门户 header 出"专业院系"下拉) ---- */
|
||||||
|
const c3 = (siteId: number) => (o: Partial<MockCategory> & { id: number; name: string; type: number }) => ({
|
||||||
|
siteId,
|
||||||
|
parentId: 0,
|
||||||
|
sortNo: 0,
|
||||||
|
status: 1,
|
||||||
|
...o,
|
||||||
|
} as MockCategory);
|
||||||
|
const c3j = c3(3);
|
||||||
|
const c3x = c3(4);
|
||||||
|
db.categories.push(
|
||||||
|
c3j({ id: 41, name: "学院概况", type: 2, sortNo: 1, seoTitle: "机电学院简介", content: para("机电学院设有机电一体化技术、电气自动化技术、机械制造及自动化、物联网应用技术等专业(Mock)。") }),
|
||||||
|
c3j({ id: 42, name: "学院新闻", type: 1, sortNo: 2 }),
|
||||||
|
c3j({ id: 43, name: "实习实训", type: 1, sortNo: 3 }),
|
||||||
|
c3x({ id: 44, name: "学院概况", type: 2, sortNo: 1, seoTitle: "信息工程学院简介", content: para("信息工程学院开设计算机应用技术、软件技术、大数据技术等专业(Mock)。") }),
|
||||||
|
c3x({ id: 45, name: "学院动态", type: 1, sortNo: 2 })
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- 文章 ---- */
|
||||||
|
let aid = 0;
|
||||||
|
const a = (o: Partial<MockArticle> & { categoryId: number; title: string }) => ({
|
||||||
|
id: ++aid,
|
||||||
|
siteId: 1,
|
||||||
|
summary: "",
|
||||||
|
content: "",
|
||||||
|
authorName: "张老师",
|
||||||
|
source: "",
|
||||||
|
status: 1,
|
||||||
|
topmost: 0,
|
||||||
|
viewCount: Math.floor(Math.random() * 2000) + 100,
|
||||||
|
createTime: daysAgo(aid),
|
||||||
|
createBy: "admin",
|
||||||
|
...o,
|
||||||
|
} as MockArticle);
|
||||||
|
const body = (t: string) =>
|
||||||
|
para(
|
||||||
|
`${t}——本文为 Mock 演示数据,用于前端界面预览。`,
|
||||||
|
"云海大学始终坚持立德树人根本任务,深入推进教育教学改革,人才培养质量稳步提升。",
|
||||||
|
`正文第二段:近日,${t}相关活动在校园内顺利举行,广大师生积极参与,反响热烈。`,
|
||||||
|
`<p><img src="/mock-img/art-${aid}.svg" alt="配图" style="max-width:100%"/></p>`,
|
||||||
|
"正文第三段:学校相关负责人表示,将持续完善各项工作机制,以更高标准推动事业发展。"
|
||||||
|
);
|
||||||
|
db.articles.push(
|
||||||
|
a({ categoryId: 3, title: "关于 2026 年秋季学期开学注册安排的通知", topmost: 1, publishTime: daysAgo(1), content: body("关于 2026 年秋季学期开学注册安排的通知"), summary: "现将 2026 年秋季学期开学注册有关事项通知如下……" }),
|
||||||
|
a({ categoryId: 3, title: "图书馆国庆节期间开放时间调整公告", publishTime: daysAgo(3), content: body("图书馆开放时间调整") }),
|
||||||
|
a({ categoryId: 3, title: "关于开展 2026 级新生入学教育系列活动的通知", publishTime: daysAgo(6), content: body("新生入学教育活动") }),
|
||||||
|
a({ categoryId: 3, title: "校园网络升级维护通知(草稿)", status: 0, content: body("校园网络升级维护"), authorName: "李老师" }),
|
||||||
|
a({ categoryId: 4, title: "我校成功举办第 38 届田径运动会", topmost: 1, coverImage: "/mock-img/cover-1.svg", publishTime: daysAgo(2), content: body("第 38 届田径运动会"), summary: "为期三天的校运会圆满落幕,共有 16 个学院代表队参赛。" }),
|
||||||
|
a({ categoryId: 4, title: "2026 年迎新晚会精彩回顾", coverImage: "/mock-img/cover-2.svg", publishTime: daysAgo(5), content: body("迎新晚会") }),
|
||||||
|
a({ categoryId: 4, title: "大学生创新创业大赛校内选拔赛落幕", coverImage: "/mock-img/cover-3.svg", publishTime: daysAgo(9), content: body("创新创业大赛") }),
|
||||||
|
a({ categoryId: 4, title: "志愿服务月活动启动(草稿)", status: 0, content: body("志愿服务月") }),
|
||||||
|
a({ categoryId: 4, title: "2025 年优秀学生表彰大会报道(已下架)", status: 2, publishTime: daysAgo(40), offlineTime: daysAgo(10), content: body("表彰大会") }),
|
||||||
|
a({ categoryId: 5, title: "《云海日报》:云海大学深耕产教融合一线观察", publishTime: daysAgo(4), source: "云海日报", content: body("产教融合") }),
|
||||||
|
a({ categoryId: 5, title: "市电视台专访我校智能制造实验室", publishTime: daysAgo(12), source: "云海电视台", content: body("智能制造实验室") }),
|
||||||
|
a({ categoryId: 7, title: "我校 12 门课程获评省级一流本科课程", publishTime: daysAgo(7), content: body("省级一流课程") }),
|
||||||
|
a({ categoryId: 7, title: "关于组织申报 2027 年教改项目的预通知", publishTime: daysAgo(15), content: body("教改项目申报") }),
|
||||||
|
a({ categoryId: 8, title: "材料学院团队在《Nature》子刊发表最新成果", topmost: 1, publishTime: daysAgo(2), content: body("《Nature》子刊成果"), summary: "该研究首次揭示了新型钙钛矿材料的界面稳定性机制。" }),
|
||||||
|
a({ categoryId: 8, title: "我校获国家重点研发计划项目立项 3 项", publishTime: daysAgo(18), content: body("重点研发计划立项") }),
|
||||||
|
a({ categoryId: 8, title: "人工智能学院发布大模型教育应用白皮书(草稿)", status: 0, content: body("大模型教育白皮书") })
|
||||||
|
);
|
||||||
|
/* 附中站点少量文章 */
|
||||||
|
db.articles.push(
|
||||||
|
a({ siteId: 2, categoryId: 21, title: "附中举行新学期升旗仪式", publishTime: daysAgo(2), content: body("升旗仪式") }),
|
||||||
|
a({ siteId: 2, categoryId: 21, title: "附中啦啦操队获市级比赛一等奖", publishTime: daysAgo(8), content: body("啦啦操一等奖") }),
|
||||||
|
a({ siteId: 2, categoryId: 21, title: "高三年级家长会通知(草稿)", status: 0, content: body("家长会通知") })
|
||||||
|
);
|
||||||
|
/* 院系子站文章 */
|
||||||
|
db.articles.push(
|
||||||
|
a({ siteId: 3, categoryId: 42, title: "机电学院学生在省级技能大赛中斩获一等奖", topmost: 1, publishTime: daysAgo(1), content: body("省级技能大赛一等奖"), summary: "机电一体化专业学生团队获省级技能大赛一等奖。" }),
|
||||||
|
a({ siteId: 3, categoryId: 42, title: "机电学院召开 2026 届毕业生就业推进会", publishTime: daysAgo(4), content: body("就业推进会") }),
|
||||||
|
a({ siteId: 3, categoryId: 43, title: "工业机器人实训室投入使用", publishTime: daysAgo(7), content: body("实训室投用") }),
|
||||||
|
a({ siteId: 3, categoryId: 42, title: "实训设备采购论证会(草稿)", status: 0, content: body("设备采购论证") }),
|
||||||
|
a({ siteId: 4, categoryId: 45, title: "信息工程学院举办程序设计校内选拔赛", publishTime: daysAgo(3), content: body("程序设计选拔赛") }),
|
||||||
|
a({ siteId: 4, categoryId: 45, title: "软件技术专业开展企业导师课", publishTime: daysAgo(6), content: body("企业导师课") })
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- 附件 ---- */
|
||||||
|
let atid = 0;
|
||||||
|
const at = (o: Partial<MockAttachment> & { fileName: string; url: string }) => ({
|
||||||
|
id: ++atid,
|
||||||
|
siteId: 1,
|
||||||
|
fileSize: Math.floor(Math.random() * 5 * 1024 * 1024) + 10240,
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
createTime: daysAgo(atid * 2 + 1),
|
||||||
|
...o,
|
||||||
|
} as MockAttachment);
|
||||||
|
db.attachments.push(
|
||||||
|
at({ fileName: "2026秋季学期校历.pdf", url: "/mock-files/2026校历.pdf", mimeType: "application/pdf", articleId: 1 }),
|
||||||
|
at({ fileName: "运动会秩序册.pdf", url: "/mock-files/运动会秩序册.pdf", mimeType: "application/pdf", articleId: 5 }),
|
||||||
|
at({ fileName: "迎新晚会节目单.docx", url: "/mock-files/迎新晚会节目单.docx", articleId: 6 }),
|
||||||
|
at({ fileName: "banner-旧图.png", url: "/mock-img/banner-old.svg", mimeType: "image/png" }),
|
||||||
|
at({ fileName: "未使用配图.jpg", url: "/mock-img/orphan.svg", mimeType: "image/jpeg" })
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- 轮播 ---- */
|
||||||
|
db.banners.push(
|
||||||
|
{ id: 1, siteId: 1, position: "home", title: "笃学慎思 明辨笃行", imageUrl: "/mock-img/banner-1.svg", linkType: 2, linkValue: "4", sortNo: 1, status: 1, startTime: daysAgo(30), endTime: undefined },
|
||||||
|
{ id: 2, siteId: 1, position: "home", title: "第 38 届田径运动会", imageUrl: "/mock-img/banner-2.svg", linkType: 1, linkValue: "5", sortNo: 2, status: 1, startTime: daysAgo(20) },
|
||||||
|
{ id: 3, siteId: 1, position: "home", title: "2026 年招生咨询", imageUrl: "/mock-img/banner-3.svg", linkType: 3, linkValue: "https://zs.example.edu.cn", sortNo: 3, status: 1, startTime: daysAgo(10) },
|
||||||
|
{ id: 4, siteId: 2, position: "home", title: "附中欢迎你", imageUrl: "/mock-img/banner-4.svg", linkType: 3, linkValue: "https://fsz.example.cn", sortNo: 1, status: 1 },
|
||||||
|
{ id: 5, siteId: 3, position: "home", title: "智能制造 匠心筑梦", imageUrl: "/mock-img/banner-2.svg", linkType: 2, linkValue: "42", sortNo: 1, status: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- 友情链接 ---- */
|
||||||
|
let lid = 0;
|
||||||
|
const l = (o: Partial<MockLink> & { name: string; linkUrl: string }) => ({
|
||||||
|
id: ++lid,
|
||||||
|
siteId: 1,
|
||||||
|
groupName: "常用服务",
|
||||||
|
sortNo: lid,
|
||||||
|
status: 1,
|
||||||
|
...o,
|
||||||
|
} as MockLink);
|
||||||
|
db.links.push(
|
||||||
|
l({ groupName: "上级部门", name: "教育部", linkUrl: "http://www.moe.gov.cn", logoUrl: "/mock-img/logo-1.svg" }),
|
||||||
|
l({ groupName: "上级部门", name: "省教育厅", linkUrl: "http://jyt.example.cn", logoUrl: "/mock-img/logo-2.svg" }),
|
||||||
|
l({ groupName: "兄弟院校", name: "云海理工大学", linkUrl: "https://www.yhlg.edu.cn", logoUrl: "/mock-img/logo-3.svg" }),
|
||||||
|
l({ groupName: "兄弟院校", name: "云海师范大学", linkUrl: "https://www.yhsf.edu.cn", logoUrl: "/mock-img/logo-4.svg" }),
|
||||||
|
l({ groupName: "兄弟院校", name: "云海医科大学", linkUrl: "https://www.yhyk.edu.cn", logoUrl: "/mock-img/logo-5.svg" }),
|
||||||
|
l({ name: "教务系统", linkUrl: "https://jw.yunhai-demo.edu.cn", logoUrl: "/mock-img/logo-6.svg" }),
|
||||||
|
l({ name: "图书馆", linkUrl: "https://lib.yunhai-demo.edu.cn", logoUrl: "/mock-img/logo-7.svg" }),
|
||||||
|
l({ name: "邮件系统", linkUrl: "https://mail.yunhai-demo.edu.cn", logoUrl: "/mock-img/logo-8.svg" }),
|
||||||
|
l({ siteId: 2, groupName: "附中链接", name: "教育云平台", linkUrl: "https://edu.example.cn" })
|
||||||
|
);
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 查询辅助 ===== */
|
||||||
|
|
||||||
|
/** 拍平表 → CategoryTreeNode 树 */
|
||||||
|
export function categoryTree(db: MockDb, siteId: number): any[] {
|
||||||
|
const list = db.categories
|
||||||
|
.filter(c => c.siteId === siteId)
|
||||||
|
.sort((x, y) => x.sortNo - y.sortNo);
|
||||||
|
const node = (parentId: number): any[] =>
|
||||||
|
list
|
||||||
|
.filter(c => c.parentId === parentId)
|
||||||
|
.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
parentId: c.parentId,
|
||||||
|
siteId: c.siteId,
|
||||||
|
name: c.name,
|
||||||
|
type: c.type,
|
||||||
|
linkUrl: c.linkUrl,
|
||||||
|
template: c.template,
|
||||||
|
content: c.content,
|
||||||
|
sortNo: c.sortNo,
|
||||||
|
seoTitle: c.seoTitle,
|
||||||
|
seoDescription: c.seoDescription,
|
||||||
|
status: c.status,
|
||||||
|
children: node(c.id),
|
||||||
|
}));
|
||||||
|
return node(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryName(db: MockDb, id: number): string {
|
||||||
|
return db.categories.find(c => c.id === id)?.name || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自增 id(各表统一走 999+,避免与种子 id 冲突混淆) */
|
||||||
|
export function nextId(rows: { id: number }[]): number {
|
||||||
|
return rows.reduce((m, r) => Math.max(m, r.id), 0) + 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,903 @@
|
|||||||
|
/**
|
||||||
|
* CMS dev mock 中间件(vite 插件,仅 mock 模式启用)。
|
||||||
|
*
|
||||||
|
* 在 vite 内部中间件(含 /api 代理)之前拦截请求,内存数据库直接应答,
|
||||||
|
* 使前端 SPA 无需后端/CAS/MySQL 即可完整跑通界面演示:
|
||||||
|
* - /api/** 管理端全部接口({code:0,msg,data} 包装,写操作真实改内存库)
|
||||||
|
* - /api/attachment/upload multipart 粗解析:提取文件字节缓存到 /mock-files/
|
||||||
|
* - /mock-img/:name 确定性 SVG 占位图(按名字 hash 配色,离线可用)
|
||||||
|
* - /mock-files/:name 回放上传的文件字节;未命中的种子路径回 SVG 占位
|
||||||
|
* - /portal/** 简版门户预览页(首页 + /a/{id} 文章详情)
|
||||||
|
*/
|
||||||
|
import type { Plugin } from "vite";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
|
import { MockDb, categoryTree, categoryName, nextId, seedDb } from "./data";
|
||||||
|
import { MockAttachment, MockBanner, MockLink, MockSite } from "./data";
|
||||||
|
|
||||||
|
const db: MockDb = seedDb();
|
||||||
|
|
||||||
|
|
||||||
|
/* ===== 主题 fixture(方案 §6.4:字段 schema 与后端 /api/theme/list 一致) ===== */
|
||||||
|
|
||||||
|
interface MockThemeParam {
|
||||||
|
key: string;
|
||||||
|
cssVar?: string;
|
||||||
|
label: string;
|
||||||
|
type: "color" | "image" | "select" | "text";
|
||||||
|
defaultValue?: string;
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockTheme {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
bannerSize: string;
|
||||||
|
screenshotUrl: string;
|
||||||
|
params: MockThemeParam[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEMES: MockTheme[] = [
|
||||||
|
{
|
||||||
|
name: "default",
|
||||||
|
title: "高校蓝(默认)",
|
||||||
|
description: "高校蓝主题:顶栏 + 通栏轮播布局,现行基准皮肤。",
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "yunhai",
|
||||||
|
bannerSize: "1920x560",
|
||||||
|
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||||
|
params: [
|
||||||
|
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#1a5cb0" },
|
||||||
|
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#12447f" },
|
||||||
|
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#f5f6f8" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "classic-red",
|
||||||
|
title: "红蓝经典",
|
||||||
|
description: "深红横幅头图 + 通栏轮播 + 宋体标题,适合中小学与党政风格。",
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "yunhai",
|
||||||
|
bannerSize: "1920x560",
|
||||||
|
screenshotUrl: "/api/theme/classic-red/asset/screenshot.png",
|
||||||
|
params: [
|
||||||
|
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#b01a2e" },
|
||||||
|
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#7f1224" },
|
||||||
|
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#f7f5f2" },
|
||||||
|
{
|
||||||
|
key: "headerStyle",
|
||||||
|
label: "头部样式",
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "banner",
|
||||||
|
options: [{ value: "banner", label: "横幅头图" }, { value: "plain", label: "简洁顶栏" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blue-white",
|
||||||
|
title: "蓝白简约",
|
||||||
|
description: "细线顶栏 + 卡片化列表 + 大留白,适合高校与职业院校。",
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "yunhai",
|
||||||
|
bannerSize: "1920x560",
|
||||||
|
screenshotUrl: "/api/theme/blue-white/asset/screenshot.png",
|
||||||
|
params: [
|
||||||
|
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#2f6bd8" },
|
||||||
|
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#1d4fae" },
|
||||||
|
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#f7f9fc" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "warm-kinder",
|
||||||
|
title: "幼儿园暖色",
|
||||||
|
description: "圆角卡片 + 暖色系 + 插画头图位,适合幼儿园与托育机构。",
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "yunhai",
|
||||||
|
bannerSize: "1920x560",
|
||||||
|
screenshotUrl: "/api/theme/warm-kinder/asset/screenshot.png",
|
||||||
|
params: [
|
||||||
|
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#e8722a" },
|
||||||
|
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#c25a1a" },
|
||||||
|
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#fff8f0" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function themeByName(name: string): MockTheme | undefined {
|
||||||
|
return THEMES.find(t => t.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 configJson(非法 JSON 按空对象,与后端 ExtJsonUtils.parse 同语义) */
|
||||||
|
function parseConfig(configJson: string): Record<string, any> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configJson);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按主题 schema 过滤微调值(与后端 ThemeInfo.filterValidValues 同语义,§5.3) */
|
||||||
|
function filterThemeParams(theme: MockTheme, raw: Record<string, unknown>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const colorRe = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
|
theme.params.forEach(p => {
|
||||||
|
const v = typeof raw[p.key] === "string" ? (raw[p.key] as string) : "";
|
||||||
|
if (v === "") return;
|
||||||
|
let valid = false;
|
||||||
|
if (p.type === "color") valid = colorRe.test(v);
|
||||||
|
else if (p.type === "image") valid = v.startsWith("/upload/");
|
||||||
|
else if (p.type === "select") valid = !!p.options?.some(o => o.value === v);
|
||||||
|
else if (p.type === "text") valid = v.length <= 256;
|
||||||
|
if (valid) out[p.key] = v;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 基础工具 ===== */
|
||||||
|
|
||||||
|
function ok(data: unknown) {
|
||||||
|
return { code: 0, msg: "success", data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, body: unknown, status = 200) {
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader("Content-Type", "application/json;charset=UTF-8");
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (c: Buffer) => chunks.push(c));
|
||||||
|
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on("error", () => resolve(Buffer.alloc(0)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T = any>(req: IncomingMessage): Promise<T> {
|
||||||
|
const buf = await readBody(req);
|
||||||
|
try {
|
||||||
|
return JSON.parse(buf.toString("utf8") || "{}") as T;
|
||||||
|
} catch {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function q(req: IncomingMessage, key: string): string | undefined {
|
||||||
|
return new URL(req.url!, "http://mock.local").searchParams.get(key) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: string | undefined, def?: number): number | undefined {
|
||||||
|
if (v === undefined || v === "") return def;
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return isNaN(n) ? def : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
const d = new Date();
|
||||||
|
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())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 栏目自身 + 全部子孙 id(与后端 categoryIds 过滤语义一致) */
|
||||||
|
function categoryWithChildren(siteId: number, categoryId: number): number[] {
|
||||||
|
const ids = [categoryId];
|
||||||
|
const walk = (pid: number) => {
|
||||||
|
db.categories
|
||||||
|
.filter(c => c.siteId === siteId && c.parentId === pid)
|
||||||
|
.forEach(c => {
|
||||||
|
ids.push(c.id);
|
||||||
|
walk(c.id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
walk(categoryId);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SVG 占位图(确定性配色) ===== */
|
||||||
|
|
||||||
|
function hashCode(s: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholderSvg(name: string): string {
|
||||||
|
const h = hashCode(name);
|
||||||
|
const hue = h % 360;
|
||||||
|
const hue2 = (hue + 40) % 360;
|
||||||
|
const label = name.replace(/\.(svg|png|jpg|jpeg|gif|webp|pdf|docx?|xlsx?|pptx?)$/i, "");
|
||||||
|
const short = label.length > 18 ? label.slice(0, 18) + "…" : label;
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360">
|
||||||
|
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(${hue},62%,58%)"/><stop offset="1" stop-color="hsl(${hue2},55%,42%)"/>
|
||||||
|
</linearGradient></defs>
|
||||||
|
<rect width="640" height="360" fill="url(#g)"/>
|
||||||
|
<circle cx="${80 + (h % 480)}" cy="${60 + ((h >> 3) % 240)}" r="120" fill="rgba(255,255,255,0.12)"/>
|
||||||
|
<circle cx="${640 - (80 + ((h >> 5) % 480))}" cy="${360 - (60 + ((h >> 7) % 240))}" r="90" fill="rgba(255,255,255,0.10)"/>
|
||||||
|
<text x="320" y="188" text-anchor="middle" font-family="'Microsoft YaHei',sans-serif" font-size="30" fill="rgba(255,255,255,0.95)">${short}</text>
|
||||||
|
<text x="320" y="230" text-anchor="middle" font-family="'Microsoft YaHei',sans-serif" font-size="16" fill="rgba(255,255,255,0.65)">Mock 占位图</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSvg(res: ServerResponse, name: string) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.end(placeholderSvg(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== multipart 粗解析(单文件场景足够) ===== */
|
||||||
|
|
||||||
|
function extractFile(body: Buffer): { name: string; mime: string; buffer: Buffer } | null {
|
||||||
|
const headEnd = body.indexOf("\r\n\r\n");
|
||||||
|
if (headEnd < 0) return null;
|
||||||
|
const head = body.slice(0, headEnd).toString("utf8");
|
||||||
|
const boundary = head.split("\r\n")[0];
|
||||||
|
if (!boundary.startsWith("--")) return null;
|
||||||
|
const nameMatch = head.match(/filename="([^"]*)"/);
|
||||||
|
const mimeMatch = head.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||||
|
const end = body.lastIndexOf(`\r\n${boundary}`);
|
||||||
|
if (end <= headEnd + 4) return null;
|
||||||
|
return {
|
||||||
|
name: nameMatch?.[1] ? decodeURIComponent(nameMatch[1]) : `upload-${Date.now()}`,
|
||||||
|
mime: mimeMatch?.[1]?.trim() || "application/octet-stream",
|
||||||
|
buffer: body.slice(headEnd + 4, end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 简版门户预览页 ===== */
|
||||||
|
|
||||||
|
function siteByCode(code: string): MockSite | undefined {
|
||||||
|
return db.sites.find(s => s.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function portalPage(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const path = new URL(req.url!, "http://mock.local").pathname;
|
||||||
|
// /portal/{code} 或 /portal/{code}/...(含 /a/{id})
|
||||||
|
const m = path.replace(/^\/portal\//, "").split("/");
|
||||||
|
const site = siteByCode(m[0]);
|
||||||
|
const nav = ["学校概况", "新闻中心", "教学科研", "招生网", "联系我们"]
|
||||||
|
.map(n => `<a href="javascript:void(0)">${n}</a>`)
|
||||||
|
.join("");
|
||||||
|
if (!site) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "text/html;charset=utf-8");
|
||||||
|
res.end(`<html><body style="font-family:'Microsoft YaHei';padding:40px"><h3>Mock 预览</h3><p>站点 code「${m[0]}」不存在,请先在站点管理创建。</p></body></html>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身),与真实门户 header 同语义
|
||||||
|
const anchorId = site.parentSiteId || site.id;
|
||||||
|
const colleges = db.sites.filter(s => s.parentSiteId === anchorId && s.status === 1);
|
||||||
|
const collegesHtml = colleges.length
|
||||||
|
? `<div class="colleges"><span class="colleges-toggle">专业院系 ▾</span><ul class="colleges-menu">
|
||||||
|
${colleges.map(c => `<li${c.code === site.code ? ' class="on"' : ""}><a href="/portal/${c.code}/">${c.name}</a></li>`).join("")}
|
||||||
|
</ul></div>`
|
||||||
|
: "";
|
||||||
|
const articleMatch = path.match(/\/a\/(\d+)/);
|
||||||
|
let main: string;
|
||||||
|
if (articleMatch) {
|
||||||
|
const art = db.articles.find(x => x.id === parseInt(articleMatch[1], 10));
|
||||||
|
main = art
|
||||||
|
? `<article class="card"><h1>${art.title}</h1>
|
||||||
|
<p class="meta">发布时间:${art.publishTime || "未发布"} 浏览量:${art.viewCount} 来源:${art.source || "本站"}(Mock 数据)</p>
|
||||||
|
<div class="content">${art.content || "<p>(无正文)</p>"}</div></article>`
|
||||||
|
: `<article class="card"><h1>文章不存在或未发布</h1><p>Mock 提示:仅「已发布」文章在门户可见。</p></article>`;
|
||||||
|
} else {
|
||||||
|
const banners = db.banners
|
||||||
|
.filter(b => b.siteId === site.id && b.position === "home" && b.status === 1)
|
||||||
|
.sort((x, y) => x.sortNo - y.sortNo);
|
||||||
|
const pubs = db.articles
|
||||||
|
.filter(x => x.siteId === site.id && x.status === 1)
|
||||||
|
.sort((x, y) => (y.topmost - x.topmost) || (y.publishTime || "").localeCompare(x.publishTime || ""));
|
||||||
|
main = `
|
||||||
|
<div class="banner"><img src="${banners[0]?.imageUrl || "/mock-img/banner-default.svg"}" alt="banner"/>
|
||||||
|
<div class="banner-list">${banners.map(b => `<img src="${b.imageUrl}" alt="${b.title}"/>`).join("")}</div>
|
||||||
|
</div>
|
||||||
|
<article class="card"><h2>文章列表(仅已发布,置顶优先)</h2><ul>
|
||||||
|
${pubs.map(a => `<li><a href="/portal/${site.code}/a/${a.id}">${a.topmost === 1 ? "【置顶】" : ""}${a.title}</a><span class="meta">${a.publishTime || ""}</span></li>`).join("") || "<li>暂无已发布文章</li>"}
|
||||||
|
</ul></article>`;
|
||||||
|
}
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "text/html;charset=utf-8");
|
||||||
|
// 防止 dev 缓存把 SPA index.html 与门户页互相串味
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.end(`<!doctype html><html lang="zh"><head><meta charset="utf-8"/><title>${site.name}(Mock 预览)</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:'Microsoft YaHei',sans-serif;margin:0;background:#f5f6f8;color:#333}
|
||||||
|
header{background:#fff;border-bottom:1px solid #e5e6eb;padding:0 32px;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||||
|
header h1{font-size:20px} header .tip{color:#999;font-size:13px}
|
||||||
|
.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}
|
||||||
|
.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,.12);z-index:110}
|
||||||
|
.colleges:hover .colleges-menu{display:block}
|
||||||
|
.colleges-menu li a{display:block;padding:8px 14px;font-size:14px;color:#333;white-space:nowrap;text-decoration:none}
|
||||||
|
.colleges-menu li a:hover{background:#f2f6fc;color:#2d8cf0}
|
||||||
|
.colleges-menu li.on a{color:#2d8cf0;font-weight:600}
|
||||||
|
nav{background:#2d8cf0;padding:0 32px} nav a{color:#fff;text-decoration:none;margin-right:24px;line-height:44px;font-size:15px}
|
||||||
|
.wrap{max-width:960px;margin:20px auto;padding:0 16px}
|
||||||
|
.card{background:#fff;border-radius:8px;padding:24px 28px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
||||||
|
.banner img{width:100%;border-radius:8px;display:block}
|
||||||
|
.banner-list{display:flex;gap:10px;margin-top:10px} .banner-list img{width:200px;border-radius:6px}
|
||||||
|
h1{font-size:22px} h2{font-size:18px} ul{padding-left:0;list-style:none} li{padding:8px 0;border-bottom:1px dashed #eee;display:flex;justify-content:space-between}
|
||||||
|
li a{color:#2d8cf0;text-decoration:none} .meta{color:#999;font-size:13px} .content p{text-indent:2em;line-height:1.9} .content img{max-width:100%}
|
||||||
|
</style></head><body>
|
||||||
|
<header><h1>${site.name}</h1><span class="tip">Mock 模式门户预览(数据为内存模拟,不影响真实后端)</span>${collegesHtml}</header>
|
||||||
|
<nav>${nav}</nav><div class="wrap">${main}</div></body></html>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 主题预览(简化渲染::root 变量驱动配色示意布局;完整效果以真实后端为准) ===== */
|
||||||
|
|
||||||
|
function sendThemePreview(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const u = new URL(req.url!, "http://mock.local");
|
||||||
|
const site = siteByCode(u.searchParams.get("siteCode") || "");
|
||||||
|
const themeName = u.searchParams.get("theme") || "default";
|
||||||
|
const theme = themeByName(themeName);
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (theme) {
|
||||||
|
theme.params.forEach(p => {
|
||||||
|
const v = u.searchParams.get("p_" + p.key);
|
||||||
|
if (v !== null && v !== "") params[p.key] = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
if (theme) {
|
||||||
|
theme.params.forEach(p => {
|
||||||
|
const v = params[p.key] || p.defaultValue || "";
|
||||||
|
if (p.cssVar && v) vars[p.cssVar] = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rootVars = Object.keys(vars).map(k => k + ":" + vars[k]).join(";");
|
||||||
|
const primary = vars["--primary"] || "#1a5cb0";
|
||||||
|
const primaryDark = vars["--primary-dark"] || "#12447f";
|
||||||
|
if (!site) {
|
||||||
|
sendHtml(res, "<html><body style='font-family:Microsoft YaHei;padding:40px'><h3>Mock 主题预览</h3><p>内容源站点不存在。预览需要 content site:请先创建并启用站点。</p></body></html>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const themeTitle = theme ? theme.title + "(" + theme.name + ")" : themeName;
|
||||||
|
const pubs = db.articles
|
||||||
|
.filter(x => x.siteId === site.id && x.status === 1)
|
||||||
|
.sort((x, y) => (y.topmost - x.topmost) || (y.publishTime || "").localeCompare(x.publishTime || ""));
|
||||||
|
const bannerSize = theme ? theme.bannerSize : "1920x560";
|
||||||
|
const listItems = pubs.map(a =>
|
||||||
|
"<li><a href='/portal/" + site.code + "/a/" + a.id + "'>" + (a.topmost === 1 ? "【置顶】" : "") + a.title
|
||||||
|
+ "</a><span class='meta'>" + (a.publishTime || "") + "</span></li>"
|
||||||
|
).join("") || "<li>暂无已发布文章</li>";
|
||||||
|
sendHtml(res, "<!doctype html><html lang='zh'><head><meta charset='utf-8'/>"
|
||||||
|
+ "<title>" + themeTitle + " · 预览(Mock)</title><style>"
|
||||||
|
+ ":root{--primary:" + primary + ";--primary-dark:" + primaryDark + ";" + rootVars + "}"
|
||||||
|
+ "body{font-family:Microsoft YaHei,sans-serif;margin:0;background:var(--bg,#f5f6f8);color:#333}"
|
||||||
|
+ "header{background:#fff;border-bottom:1px solid #e5e6eb;padding:0 32px;display:flex;align-items:center;justify-content:space-between;height:64px}"
|
||||||
|
+ "nav{background:var(--primary);padding:0 32px}nav a{color:#fff;text-decoration:none;margin-right:24px;line-height:44px;font-size:15px}"
|
||||||
|
+ ".wrap{max-width:960px;margin:20px auto;padding:0 16px}"
|
||||||
|
+ ".banner{background:linear-gradient(135deg,var(--primary),var(--primary-dark));border-radius:10px;height:180px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px}"
|
||||||
|
+ ".card{background:#fff;border-radius:8px;padding:20px 24px;margin:14px 0}"
|
||||||
|
+ "li{padding:8px 0;border-bottom:1px dashed #eee;display:flex;justify-content:space-between}"
|
||||||
|
+ "li a{color:var(--primary);text-decoration:none}.meta{color:#999;font-size:13px}"
|
||||||
|
+ ".tip-bar{background:#fff8e6;border:1px solid #ffe1a8;color:#8a6d3b;font-size:12px;padding:6px 12px;border-radius:6px;margin-top:10px}"
|
||||||
|
+ "</style></head><body>"
|
||||||
|
+ "<header><h1>" + site.name + "</h1><span style='color:#999;font-size:13px'>Mock 简化预览(内容源:" + site.code + ")</span></header>"
|
||||||
|
+ "<nav><a href='javascript:void(0)'>首页</a><a href='javascript:void(0)'>学校概况</a><a href='javascript:void(0)'>新闻中心</a><a href='javascript:void(0)'>教学科研</a></nav>"
|
||||||
|
+ "<div class='wrap'>"
|
||||||
|
+ "<div class='tip-bar'>正在预览皮肤:" + themeTitle + "(Mock 仅示意配色与布局;p_* 参数已应用,真实效果以连后端预览为准)</div>"
|
||||||
|
+ "<div class='banner'>通栏轮播示意(bannerSize " + bannerSize + ")</div>"
|
||||||
|
+ "<div class='card'><h2 style='margin:4px 0 8px'>文章列表(内容源 " + site.code + ",仅已发布)</h2><ul>" + listItems + "</ul></div>"
|
||||||
|
+ "</div></body></html>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendHtml(res: ServerResponse, html: string) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "text/html;charset=utf-8");
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.end(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== /api 路由 ===== */
|
||||||
|
|
||||||
|
|
||||||
|
async function handleApi(req: IncomingMessage, res: ServerResponse, path: string): Promise<boolean> {
|
||||||
|
const method = (req.method || "GET").toUpperCase();
|
||||||
|
|
||||||
|
/* ---- 主题(方案 §6.4 mock:list / 资产占位 / preview 简化渲染) ---- */
|
||||||
|
if (method === "GET" && path === "/api/theme/list") {
|
||||||
|
sendJson(res, ok(THEMES));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const assetMatch = path.match(/^\/api\/theme\/([a-z0-9-]+)\/asset\/(.+)$/);
|
||||||
|
if (method === "GET" && assetMatch) {
|
||||||
|
if (!themeByName(assetMatch[1])) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not found");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 截图占位:SVG(名字 hash 配色),足够卡片缩略图示意
|
||||||
|
sendSvg(res, assetMatch[1] + "-" + assetMatch[2]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/api/theme/preview") {
|
||||||
|
sendThemePreview(req, res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ---- 认证 ---- */
|
||||||
|
if (method === "GET" && path === "/api/auth/user") {
|
||||||
|
sendJson(res, ok({
|
||||||
|
account: "admin",
|
||||||
|
name: "系统管理员",
|
||||||
|
currentRole: { roleId: 1, roleName: "平台超级管理员", schId: 1001, schName: "云海大学" },
|
||||||
|
superAdmin: true,
|
||||||
|
sites: db.sites.filter(s => s.status === 1),
|
||||||
|
roleIds: [1],
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 平台代理域(Notice BaseApiController 同款契约) ---- */
|
||||||
|
if (method === "GET" && path === "/api/base/GetUserInfo") {
|
||||||
|
sendJson(res, ok({
|
||||||
|
id: 1,
|
||||||
|
account: "admin",
|
||||||
|
name: "系统管理员",
|
||||||
|
profilePicture: "",
|
||||||
|
currentRole: { roleId: 1, roleName: "平台超级管理员", schId: 1001, schName: "云海大学" },
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/api/base/GetUserMod") {
|
||||||
|
// 形状对齐基础平台真实返回:id/appId/modId/name/parentId/path/level/page/icon/orderNumber/usable/children/modules
|
||||||
|
const mod = (id: number, modId: number, name: string, page: string | null,
|
||||||
|
children: any[] = [], parentId = 0, orderNumber = 0) => ({
|
||||||
|
id,
|
||||||
|
appId: 800,
|
||||||
|
modId,
|
||||||
|
name,
|
||||||
|
parentId,
|
||||||
|
path: null,
|
||||||
|
level: 0,
|
||||||
|
page,
|
||||||
|
icon: null,
|
||||||
|
orderNumber,
|
||||||
|
usable: true,
|
||||||
|
children,
|
||||||
|
modules: children,
|
||||||
|
});
|
||||||
|
sendJson(res, ok([
|
||||||
|
// 「新建文章」已收敛为文章列表子页面(docs/09 §3.2),菜单树不再下发编辑入口
|
||||||
|
mod(2101, 1, "内容管理", null, [
|
||||||
|
mod(2102, 11, "文章列表", "/article", [], 2101, 2102),
|
||||||
|
]),
|
||||||
|
mod(2104, 2, "栏目管理", "/category"),
|
||||||
|
mod(2105, 3, "轮播管理", "/banner"),
|
||||||
|
mod(2106, 4, "友情链接", "/link"),
|
||||||
|
mod(2107, 5, "附件管理", "/attachment"),
|
||||||
|
mod(2108, 6, "站点管理", "/site"),
|
||||||
|
]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 站点 ---- */
|
||||||
|
if (method === "GET" && path === "/api/site/list") {
|
||||||
|
sendJson(res, ok(db.sites.map(s => ({
|
||||||
|
...s,
|
||||||
|
parentName: db.sites.find(p => p.id === s.parentSiteId)?.name,
|
||||||
|
}))));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 学校工作台(docs/09 §3.3,SchoolService 同款聚合契约) ---- */
|
||||||
|
if (method === "GET" && path === "/api/school/list") {
|
||||||
|
sendJson(res, ok(db.sites
|
||||||
|
.filter(s => !s.parentSiteId)
|
||||||
|
.sort((a, b) => a.id - b.id)
|
||||||
|
.map(main => {
|
||||||
|
const children = db.sites.filter(s => s.parentSiteId === main.id);
|
||||||
|
const all = [main, ...children];
|
||||||
|
return {
|
||||||
|
schId: main.schId,
|
||||||
|
schName: main.schName,
|
||||||
|
mainSiteId: main.id,
|
||||||
|
mainSiteCode: main.code,
|
||||||
|
mainSiteName: main.name,
|
||||||
|
theme: main.theme,
|
||||||
|
siteCount: all.length,
|
||||||
|
articleCount: all.reduce((n, s) => n + db.articles.filter(a => a.siteId === s.id).length, 0),
|
||||||
|
status: main.status,
|
||||||
|
};
|
||||||
|
})));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/api/school/options") {
|
||||||
|
// UserBase oapi getGroupTree(type=2 学校组) 的 mock 等价物
|
||||||
|
sendJson(res, ok([
|
||||||
|
{ id: 1001, name: "云海大学" },
|
||||||
|
{ id: 1002, name: "云海大学附属中学" },
|
||||||
|
{ id: 1003, name: "云海师范学院" },
|
||||||
|
{ id: 1004, name: "云海幼师高等专科学校" },
|
||||||
|
]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/site/save") {
|
||||||
|
const body: any = await readJson<Partial<MockSite>>(req);
|
||||||
|
// themeParams 服务端合并(方案 §5.3 同语义:仅替换 theme_params 键,保留其余键,丢弃未声明/非法值)
|
||||||
|
const themeForParams = themeByName(body.theme || "default");
|
||||||
|
if (themeForParams && body.themeParams && typeof body.themeParams === "object") {
|
||||||
|
const config = parseConfig(body.configJson || "{}");
|
||||||
|
const filtered = filterThemeParams(themeForParams, body.themeParams);
|
||||||
|
if (Object.keys(filtered).length === 0) delete config.theme_params;
|
||||||
|
else config.theme_params = filtered;
|
||||||
|
body.configJson = JSON.stringify(config);
|
||||||
|
}
|
||||||
|
delete body.themeParams; // 不落库(configJson 已合并)
|
||||||
|
if (body.id) {
|
||||||
|
const i = db.sites.findIndex(s => s.id === body.id);
|
||||||
|
if (i >= 0) db.sites[i] = { ...db.sites[i], ...body, parentSiteId: body.parentSiteId ?? 0 } as MockSite;
|
||||||
|
sendJson(res, ok(db.sites[i]));
|
||||||
|
} else {
|
||||||
|
const row: MockSite = {
|
||||||
|
id: nextId(db.sites),
|
||||||
|
code: body.code || `site${Date.now()}`,
|
||||||
|
parentSiteId: body.parentSiteId ?? 0,
|
||||||
|
schId: body.schId ?? 0,
|
||||||
|
schName: body.schName || "",
|
||||||
|
name: body.name || "未命名站点",
|
||||||
|
theme: body.theme || "default",
|
||||||
|
configJson: body.configJson || "{}",
|
||||||
|
status: body.status ?? 1,
|
||||||
|
createTime: now(),
|
||||||
|
};
|
||||||
|
db.sites.push(row);
|
||||||
|
sendJson(res, ok(row));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 栏目 ---- */
|
||||||
|
if (method === "GET" && path === "/api/category/tree") {
|
||||||
|
const siteId = num(q(req, "siteId"), 0)!;
|
||||||
|
sendJson(res, ok(categoryTree(db, siteId)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/category/save") {
|
||||||
|
const body = await readJson<any>(req);
|
||||||
|
if (body.id) {
|
||||||
|
const i = db.categories.findIndex(c => c.id === body.id);
|
||||||
|
if (i >= 0) db.categories[i] = { ...db.categories[i], ...body };
|
||||||
|
sendJson(res, ok(i >= 0 ? db.categories[i] : null));
|
||||||
|
} else {
|
||||||
|
const row = {
|
||||||
|
id: nextId(db.categories),
|
||||||
|
siteId: body.siteId,
|
||||||
|
parentId: body.parentId ?? 0,
|
||||||
|
name: body.name,
|
||||||
|
type: body.type ?? 1,
|
||||||
|
linkUrl: body.linkUrl,
|
||||||
|
template: body.template,
|
||||||
|
content: body.content,
|
||||||
|
sortNo: body.sortNo ?? 0,
|
||||||
|
seoTitle: body.seoTitle,
|
||||||
|
seoDescription: body.seoDescription,
|
||||||
|
status: body.status ?? 1,
|
||||||
|
};
|
||||||
|
db.categories.push(row);
|
||||||
|
sendJson(res, ok(row));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/category/delete") {
|
||||||
|
const { id } = await readJson<{ id: number }>(req);
|
||||||
|
const hasChildren = db.categories.some(c => c.parentId === id);
|
||||||
|
const hasArticles = db.articles.some(a => a.categoryId === id);
|
||||||
|
if (hasChildren || hasArticles) {
|
||||||
|
sendJson(res, { code: 4, msg: hasChildren ? "存在子栏目,不允许删除" : "栏目下存在文章,不允许删除", data: null });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
db.categories = db.categories.filter(c => c.id !== id);
|
||||||
|
sendJson(res, ok(null));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/category/sort") {
|
||||||
|
// UserBase SortChildren 同语义:ids 顺序即 sortNo 1..n
|
||||||
|
const body = await readJson<{ siteId: number; parentId: number; ids: number[] }>(req);
|
||||||
|
const children = db.categories
|
||||||
|
.filter(c => c.siteId === body.siteId && c.parentId === body.parentId)
|
||||||
|
.sort((x, y) => x.sortNo - y.sortNo);
|
||||||
|
const idSet = new Set(children.map(c => c.id));
|
||||||
|
if (new Set(body.ids).size !== body.ids.length || body.ids.some(id => !idSet.has(id))) {
|
||||||
|
sendJson(res, { code: 4, msg: "ids 中存在不属于该栏目的直接子栏目", data: null });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
body.ids.forEach((id, i) => {
|
||||||
|
const row = db.categories.find(c => c.id === id);
|
||||||
|
if (row) row.sortNo = i + 1;
|
||||||
|
});
|
||||||
|
sendJson(res, ok(null));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 文章 ---- */
|
||||||
|
if (method === "GET" && path === "/api/article/page") {
|
||||||
|
const siteId = num(q(req, "siteId"), 0)!;
|
||||||
|
const categoryId = num(q(req, "categoryId"));
|
||||||
|
const status = num(q(req, "status"));
|
||||||
|
const keyword = q(req, "keyword") || "";
|
||||||
|
const page = num(q(req, "page"), 1)!;
|
||||||
|
const pageSize = num(q(req, "pageSize"), 10)!;
|
||||||
|
const categoryIds = categoryId ? categoryWithChildren(siteId, categoryId) : null;
|
||||||
|
let rows = db.articles.filter(a => a.siteId === siteId);
|
||||||
|
if (categoryIds) rows = rows.filter(a => categoryIds.includes(a.categoryId));
|
||||||
|
if (status !== undefined) rows = rows.filter(a => a.status === status);
|
||||||
|
if (keyword) rows = rows.filter(a => a.title.includes(keyword) || a.summary.includes(keyword));
|
||||||
|
rows = [...rows].sort(
|
||||||
|
(x, y) => y.topmost - x.topmost || (y.createTime || "").localeCompare(x.createTime || "")
|
||||||
|
);
|
||||||
|
const total = rows.length;
|
||||||
|
const list = rows.slice((page - 1) * pageSize, page * pageSize).map(a => ({
|
||||||
|
...a,
|
||||||
|
categoryName: categoryName(db, a.categoryId),
|
||||||
|
}));
|
||||||
|
sendJson(res, ok({ total, list }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/api/article/get") {
|
||||||
|
const id = num(q(req, "id"), 0)!;
|
||||||
|
const art = db.articles.find(a => a.id === id);
|
||||||
|
sendJson(res, ok(art ? { ...art, extJson: "{}" } : null));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/article/save") {
|
||||||
|
const body = await readJson<any>(req);
|
||||||
|
let id: number;
|
||||||
|
if (body.id) {
|
||||||
|
const i = db.articles.findIndex(a => a.id === body.id);
|
||||||
|
if (i < 0) {
|
||||||
|
sendJson(res, { code: 4, msg: "文章不存在", data: null });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
db.articles[i] = { ...db.articles[i], ...body, id: body.id };
|
||||||
|
id = body.id;
|
||||||
|
} else {
|
||||||
|
const row = {
|
||||||
|
id: nextId(db.articles),
|
||||||
|
offlineTime: undefined,
|
||||||
|
viewCount: 0,
|
||||||
|
createTime: now(),
|
||||||
|
createBy: "admin",
|
||||||
|
status: 0,
|
||||||
|
topmost: 0,
|
||||||
|
summary: "",
|
||||||
|
content: "",
|
||||||
|
authorName: "",
|
||||||
|
source: "",
|
||||||
|
...body,
|
||||||
|
};
|
||||||
|
db.articles.push(row);
|
||||||
|
id = row.id;
|
||||||
|
}
|
||||||
|
// 本次编辑上传的附件挂到该文章(与后端 attachmentIds 绑定语义一致)
|
||||||
|
if (Array.isArray(body.attachmentIds) && body.attachmentIds.length) {
|
||||||
|
db.attachments.forEach(t => {
|
||||||
|
if (body.attachmentIds.includes(t.id)) t.articleId = id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendJson(res, ok(id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const opMatch = path.match(/^\/api\/article\/(publish|offline|topmost)\/(\d+)$/);
|
||||||
|
if (method === "POST" && opMatch) {
|
||||||
|
const id = parseInt(opMatch[2], 10);
|
||||||
|
const art = db.articles.find(a => a.id === id);
|
||||||
|
if (!art) {
|
||||||
|
sendJson(res, { code: 4, msg: "文章不存在", data: null });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (opMatch[1] === "publish") {
|
||||||
|
art.status = 1;
|
||||||
|
art.publishTime = now();
|
||||||
|
} else if (opMatch[1] === "offline") {
|
||||||
|
art.status = 2;
|
||||||
|
art.offlineTime = now();
|
||||||
|
} else {
|
||||||
|
art.topmost = q(req, "enable") === "false" ? 0 : 1;
|
||||||
|
}
|
||||||
|
sendJson(res, ok(null));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/article/delete") {
|
||||||
|
const { id } = await readJson<{ id: number }>(req);
|
||||||
|
db.articles = db.articles.filter(a => a.id !== id);
|
||||||
|
// 正文附件孤儿化(与后端语义一致)
|
||||||
|
db.attachments.forEach(t => {
|
||||||
|
if (t.articleId === id) t.articleId = undefined;
|
||||||
|
});
|
||||||
|
sendJson(res, ok(null));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 附件 ---- */
|
||||||
|
if (method === "GET" && path === "/api/attachment/page") {
|
||||||
|
const siteId = num(q(req, "siteId"), 0)!;
|
||||||
|
const orphanOnly = q(req, "orphanOnly") === "true";
|
||||||
|
const page = num(q(req, "page"), 1)!;
|
||||||
|
const pageSize = num(q(req, "pageSize"), 10)!;
|
||||||
|
let rows = db.attachments.filter(t => t.siteId === siteId);
|
||||||
|
if (orphanOnly) rows = rows.filter(t => !t.articleId);
|
||||||
|
const total = rows.length;
|
||||||
|
const list = rows
|
||||||
|
.slice((page - 1) * pageSize, page * pageSize)
|
||||||
|
.map((t: MockAttachment) => ({ ...t, orphan: !t.articleId }));
|
||||||
|
sendJson(res, ok({ total, list, orphanCount: db.attachments.filter(t => t.siteId === siteId && !t.articleId).length }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/attachment/upload") {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const file = extractFile(body);
|
||||||
|
if (!file) {
|
||||||
|
sendJson(res, { code: 4, msg: "Mock 上传解析失败", data: null });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const safeName = file.name.replace(/[\\/:*?"<>|]/g, "_");
|
||||||
|
const url = `/mock-files/${Date.now()}-${encodeURIComponent(safeName)}`;
|
||||||
|
db.files.set(url, { buffer: file.buffer, mime: file.mime, name: safeName });
|
||||||
|
const siteId = num(q(req, "siteId"), 0) ?? num(body.toString("latin1").match(/name="siteId"\r\n\r\n(\d+)/)?.[1], 0)!;
|
||||||
|
const articleId = num(q(req, "articleId")) ?? num(body.toString("latin1").match(/name="articleId"\r\n\r\n(\d+)/)?.[1], undefined);
|
||||||
|
const row: MockAttachment = {
|
||||||
|
id: nextId(db.attachments),
|
||||||
|
siteId,
|
||||||
|
articleId,
|
||||||
|
fileName: safeName,
|
||||||
|
url,
|
||||||
|
fileSize: file.buffer.length,
|
||||||
|
mimeType: file.mime,
|
||||||
|
createTime: now(),
|
||||||
|
};
|
||||||
|
db.attachments.push(row);
|
||||||
|
sendJson(res, ok({ ...row, orphan: !row.articleId }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/attachment/delete") {
|
||||||
|
const { id } = await readJson<{ id: number }>(req);
|
||||||
|
db.attachments = db.attachments.filter(t => t.id !== id);
|
||||||
|
sendJson(res, ok(null));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 轮播 ---- */
|
||||||
|
if (method === "GET" && path === "/api/banner/list") {
|
||||||
|
const siteId = num(q(req, "siteId"), 0)!;
|
||||||
|
const position = q(req, "position");
|
||||||
|
let rows = db.banners.filter(b => b.siteId === siteId);
|
||||||
|
if (position) rows = rows.filter(b => b.position === position);
|
||||||
|
rows = [...rows].sort((x, y) => x.sortNo - y.sortNo || x.id - y.id);
|
||||||
|
sendJson(res, ok(rows));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/banner/save") {
|
||||||
|
const body = await readJson<Partial<MockBanner>>(req);
|
||||||
|
if (body.id) {
|
||||||
|
const i = db.banners.findIndex(b => b.id === body.id);
|
||||||
|
if (i >= 0) db.banners[i] = { ...db.banners[i], ...body } as MockBanner;
|
||||||
|
sendJson(res, ok(db.banners[i]));
|
||||||
|
} else {
|
||||||
|
const row: MockBanner = {
|
||||||
|
id: nextId(db.banners),
|
||||||
|
siteId: body.siteId!,
|
||||||
|
position: body.position || "home",
|
||||||
|
title: body.title || "",
|
||||||
|
imageUrl: body.imageUrl || "",
|
||||||
|
linkType: body.linkType ?? 3,
|
||||||
|
linkValue: body.linkValue,
|
||||||
|
sortNo: body.sortNo ?? 0,
|
||||||
|
startTime: body.startTime,
|
||||||
|
endTime: body.endTime,
|
||||||
|
status: body.status ?? 1,
|
||||||
|
};
|
||||||
|
db.banners.push(row);
|
||||||
|
sendJson(res, ok(row));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/banner/delete") {
|
||||||
|
const { id } = await readJson<{ id: number }>(req);
|
||||||
|
db.banners = db.banners.filter(b => b.id !== id);
|
||||||
|
sendJson(res, ok(null));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 友情链接 ---- */
|
||||||
|
if (method === "GET" && path === "/api/link/list") {
|
||||||
|
const siteId = num(q(req, "siteId"), 0)!;
|
||||||
|
const rows = db.links
|
||||||
|
.filter(l => l.siteId === siteId)
|
||||||
|
.sort((x, y) => x.groupName.localeCompare(y.groupName) || x.sortNo - y.sortNo || x.id - y.id);
|
||||||
|
sendJson(res, ok(rows));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/link/save") {
|
||||||
|
const body = await readJson<Partial<MockLink>>(req);
|
||||||
|
if (body.id) {
|
||||||
|
const i = db.links.findIndex(l => l.id === body.id);
|
||||||
|
if (i >= 0) db.links[i] = { ...db.links[i], ...body } as MockLink;
|
||||||
|
sendJson(res, ok(db.links[i]));
|
||||||
|
} else {
|
||||||
|
const row: MockLink = {
|
||||||
|
id: nextId(db.links),
|
||||||
|
siteId: body.siteId!,
|
||||||
|
groupName: body.groupName || "default",
|
||||||
|
name: body.name || "",
|
||||||
|
logoUrl: body.logoUrl,
|
||||||
|
linkUrl: body.linkUrl || "",
|
||||||
|
sortNo: body.sortNo ?? 0,
|
||||||
|
status: body.status ?? 1,
|
||||||
|
};
|
||||||
|
db.links.push(row);
|
||||||
|
sendJson(res, ok(row));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/link/delete") {
|
||||||
|
const { id } = await readJson<{ id: number }>(req);
|
||||||
|
db.links = db.links.filter(l => l.id !== id);
|
||||||
|
sendJson(res, ok(null));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== vite 插件 ===== */
|
||||||
|
|
||||||
|
export function cmsMockPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "cms-dev-mock",
|
||||||
|
apply: "serve",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
void handleMock(req, res, next);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMock(req: IncomingMessage, res: ServerResponse, next: () => void) {
|
||||||
|
const url = req.url || "/";
|
||||||
|
const path = new URL(url, "http://mock.local").pathname;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (path === "/api/attachment/upload") {
|
||||||
|
await handleApi(req, res, path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/api/")) {
|
||||||
|
if (await handleApi(req, res, path)) return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/mock-img/")) {
|
||||||
|
sendSvg(res, decodeURIComponent(path.slice("/mock-img/".length)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/mock-files/")) {
|
||||||
|
const hit = db.files.get(path);
|
||||||
|
if (hit) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", hit.mime);
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(hit.name)}`);
|
||||||
|
res.end(hit.buffer);
|
||||||
|
} else {
|
||||||
|
// 种子数据指向的占位文件:SVG 兜底(含文件名文字)
|
||||||
|
sendSvg(res, decodeURIComponent(path.slice("/mock-files/".length)));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === "/portal" || path.startsWith("/portal/")) {
|
||||||
|
portalPage(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
sendJson(res, { code: 500, msg: `Mock error: ${e?.message}`, data: null }, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* 生成后端种子静态资源:D:/bsch/cms/upload/seed/
|
||||||
|
* - SVG 场景插画(校园主题分层构图:天空渐变/山峦/建筑剪影/树木/水面;无占位文字)
|
||||||
|
* - 最小合法 PDF(附件演示可真实打开)
|
||||||
|
* 轮播 1920×560 对齐主题包设计尺寸约定(themes 下的 theme.json bannerSize)。
|
||||||
|
* 用法:node seed-assets.cjs
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const OUT = "D:/bsch/cms/upload/seed";
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
function hashCode(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确定性伪随机(同名资源每次生成完全一致,保证截图可复现) */
|
||||||
|
function rng(seed) {
|
||||||
|
let s = seed || 1;
|
||||||
|
return () => {
|
||||||
|
s = (s * 1664525 + 1013904223) | 0;
|
||||||
|
return ((s >>> 8) & 0xffff) / 0xffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一栋建筑剪影(含窗户点阵) */
|
||||||
|
function building(x, baseY, w, h, fill, winFill, r) {
|
||||||
|
const cols = Math.max(2, Math.floor(w / 26));
|
||||||
|
const rows = Math.max(2, Math.floor(h / 30));
|
||||||
|
let wins = "";
|
||||||
|
for (let i = 0; i < cols; i++) {
|
||||||
|
for (let j = 0; j < rows; j++) {
|
||||||
|
if (r() < 0.25) continue; // 部分窗户熄灭,更像真实夜景/黄昏
|
||||||
|
wins += `<rect x="${(x + 8 + i * ((w - 16) / cols)).toFixed(1)}" y="${(baseY - h + 10 + j * ((h - 18) / rows)).toFixed(1)}" width="${(((w - 16) / cols) - 6).toFixed(1)}" height="${(((h - 18) / rows) - 8).toFixed(1)}" fill="${winFill}"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<rect x="${x}" y="${baseY - h}" width="${w}" height="${h}" rx="2" fill="${fill}"/>${wins}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一棵树(圆形树冠 + 矩形树干) */
|
||||||
|
function tree(x, baseY, scale, leaf, trunk) {
|
||||||
|
const r = 16 * scale;
|
||||||
|
return `<rect x="${x - 2.5 * scale}" y="${baseY - 22 * scale}" width="${5 * scale}" height="${22 * scale}" fill="${trunk}"/>
|
||||||
|
<circle cx="${x}" cy="${baseY - 30 * scale}" r="${r}" fill="${leaf}"/>
|
||||||
|
<circle cx="${x - 11 * scale}" cy="${baseY - 24 * scale}" r="${r * 0.72}" fill="${leaf}"/>
|
||||||
|
<circle cx="${x + 11 * scale}" cy="${baseY - 25 * scale}" r="${r * 0.78}" fill="${leaf}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 山峦剪影(两座) */
|
||||||
|
function mountains(w, baseY, c1, c2, r) {
|
||||||
|
const x1 = w * (0.05 + r() * 0.2);
|
||||||
|
const x2 = w * (0.55 + r() * 0.25);
|
||||||
|
const m1 = `M ${x1} ${baseY} L ${x1 + w * 0.22} ${baseY - 90 - r() * 70} L ${x1 + w * 0.46} ${baseY} Z`;
|
||||||
|
const m2 = `M ${x2} ${baseY} L ${x2 + w * 0.2} ${baseY - 70 - r() * 60} L ${x2 + w * 0.42} ${baseY} Z`;
|
||||||
|
return `<path d="${m1}" fill="${c1}"/><path d="${m2}" fill="${c2}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 云(椭圆组) */
|
||||||
|
function cloud(cx, cy, scale, fill) {
|
||||||
|
return `<g fill="${fill}">
|
||||||
|
<ellipse cx="${cx}" cy="${cy}" rx="${38 * scale}" ry="${14 * scale}"/>
|
||||||
|
<ellipse cx="${cx - 24 * scale}" cy="${cy + 4 * scale}" rx="${26 * scale}" ry="${10 * scale}"/>
|
||||||
|
<ellipse cx="${cx + 26 * scale}" cy="${cy + 3 * scale}" rx="${28 * scale}" ry="${11 * scale}"/>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整校园场景(黄昏/清晨/白天三种氛围可选) */
|
||||||
|
function campusScene(name, w, h, palette, opts = {}) {
|
||||||
|
const seed = hashCode(name);
|
||||||
|
const r = rng(seed);
|
||||||
|
const p = {
|
||||||
|
sky1: palette[0], sky2: palette[1],
|
||||||
|
sun: palette[2] || "#fff",
|
||||||
|
far: palette[3] || "rgba(255,255,255,0.28)",
|
||||||
|
near: palette[4] || "rgba(20,40,70,0.35)",
|
||||||
|
ground: palette[5] || "#2c4a6e",
|
||||||
|
water: palette[6] || null,
|
||||||
|
building: palette[7] || "rgba(16,34,60,0.85)",
|
||||||
|
win: palette[8] || "rgba(255,224,150,0.9)",
|
||||||
|
leaf: palette[9] || "rgba(46,94,60,0.9)",
|
||||||
|
trunk: palette[10] || "rgba(66,48,30,0.9)",
|
||||||
|
cloud: palette[11] || "rgba(255,255,255,0.5)",
|
||||||
|
};
|
||||||
|
const horizon = h * (opts.horizon || 0.72);
|
||||||
|
let out = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky-${seed}" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="${p.sky1}"/><stop offset="1" stop-color="${p.sky2}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="water-${seed}" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="${p.sky2}" stop-opacity="0.9"/><stop offset="1" stop-color="${p.ground}" stop-opacity="0.95"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="${w}" height="${h}" fill="url(#sky-${seed})"/>`;
|
||||||
|
// 太阳/月亮
|
||||||
|
out += `<circle cx="${(w * (0.15 + r() * 0.7)).toFixed(0)}" cy="${(horizon * (0.35 + r() * 0.3)).toFixed(0)}" r="${(h * 0.07).toFixed(0)}" fill="${p.sun}" opacity="0.9"/>`;
|
||||||
|
// 云
|
||||||
|
out += cloud(w * (0.12 + r() * 0.25), h * (0.12 + r() * 0.15), 0.9 + r() * 0.5, p.cloud);
|
||||||
|
out += cloud(w * (0.6 + r() * 0.3), h * (0.08 + r() * 0.12), 0.7 + r() * 0.4, p.cloud);
|
||||||
|
// 远山
|
||||||
|
out += mountains(w, horizon, p.far, p.far, r);
|
||||||
|
// 教学楼主群(画面中偏右)
|
||||||
|
const groundY = opts.water ? horizon + (h - horizon) * 0.42 : horizon;
|
||||||
|
const bx = w * (opts.buildingX || 0.42);
|
||||||
|
out += building(bx, groundY, w * 0.11, (h - groundY) * (1.15 + r() * 0.5), p.building, p.win, r);
|
||||||
|
out += building(bx + w * 0.115, groundY, w * 0.075, (h - groundY) * (0.85 + r() * 0.3), p.building, p.win, r);
|
||||||
|
out += building(bx - w * 0.062, groundY, w * 0.058, (h - groundY) * (0.7 + r() * 0.25), p.building, p.win, r);
|
||||||
|
// 钟楼(细高塔 + 尖顶)
|
||||||
|
const tx = bx + w * 0.2;
|
||||||
|
const th = (h - groundY) * (1.5 + r() * 0.3);
|
||||||
|
out += `<rect x="${tx}" y="${groundY - th}" width="${w * 0.028}" height="${th}" fill="${p.building}"/>
|
||||||
|
<rect x="${tx + w * 0.004}" y="${groundY - th + 10}" width="${w * 0.02}" height="${w * 0.02}" fill="${p.win}"/>
|
||||||
|
<path d="M ${tx - w * 0.004} ${groundY - th} L ${tx + w * 0.014} ${groundY - th - h * 0.05} L ${tx + w * 0.032} ${groundY - th} Z" fill="${p.building}"/>`;
|
||||||
|
// 地面
|
||||||
|
if (opts.water) {
|
||||||
|
out += `<rect x="0" y="${horizon}" width="${w}" height="${groundY - horizon}" fill="url(#water-${seed})"/>`;
|
||||||
|
out += `<rect x="0" y="${groundY}" width="${w}" height="${h - groundY}" fill="${p.ground}"/>`;
|
||||||
|
// 水面倒影横线
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
out += `<rect x="${(w * (0.1 + r() * 0.7)).toFixed(0)}" y="${(horizon + (groundY - horizon) * (0.2 + i * 0.16)).toFixed(0)}" width="${(w * (0.06 + r() * 0.2)).toFixed(0)}" height="2" fill="rgba(255,255,255,0.25)"/>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out += `<rect x="0" y="${horizon}" width="${w}" height="${h - horizon}" fill="${p.ground}"/>`;
|
||||||
|
}
|
||||||
|
// 行道树
|
||||||
|
const treeY = opts.water ? groundY + (h - groundY) * 0.22 : horizon + (h - horizon) * 0.18;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const tx2 = w * (0.03 + i * 0.165 + r() * 0.04);
|
||||||
|
if (tx2 > bx - w * 0.08 && tx2 < tx + w * 0.05 && !opts.water) continue; // 避开建筑
|
||||||
|
out += tree(tx2, treeY, 1 + r() * 0.5, p.leaf, p.trunk);
|
||||||
|
}
|
||||||
|
// 前景草坪弧线
|
||||||
|
out += `<path d="M 0 ${h} Q ${w / 2} ${h - h * 0.08} ${w} ${h} Z" fill="rgba(0,0,0,0.12)"/>`;
|
||||||
|
out += `</svg>`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校徽(圆形徽章:书本 + 星 + 环形留白,透明背景适配页头) */
|
||||||
|
function emblem() {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
|
||||||
|
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2a6fc9"/><stop offset="1" stop-color="#12447f"/>
|
||||||
|
</linearGradient></defs>
|
||||||
|
<circle cx="48" cy="48" r="46" fill="url(#lg)"/>
|
||||||
|
<circle cx="48" cy="48" r="38.5" fill="none" stroke="#f5c542" stroke-width="1.6"/>
|
||||||
|
<circle cx="48" cy="48" r="41.5" fill="none" stroke="#ffffff" stroke-width="0.8" opacity="0.6"/>
|
||||||
|
<path d="M 26 58 Q 48 50 70 58 L 70 36 Q 48 28 26 36 Z" fill="#ffffff"/>
|
||||||
|
<path d="M 48 50 L 48 28" stroke="#2a6fc9" stroke-width="1.6"/>
|
||||||
|
<path d="M 48 14 L 50.6 21 L 58 21.4 L 52.2 26 L 54.2 33 L 48 29 L 41.8 33 L 43.8 26 L 38 21.4 L 45.4 21 Z" fill="#f5c542"/>
|
||||||
|
<rect x="24" y="62" width="48" height="3.4" rx="1.7" fill="#f5c542"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 轮播(1920×560,对齐 theme.json bannerSize 约定)---- */
|
||||||
|
fs.writeFileSync(path.join(OUT, "banner-1.svg"),
|
||||||
|
campusScene("banner-1", 1920, 560, ["#1c3f68", "#7fb2e5", "#fff2c9", "rgba(255,255,255,0.22)", "rgba(20,45,80,0.30)", "#27517f"], { water: true, buildingX: 0.40 }));
|
||||||
|
fs.writeFileSync(path.join(OUT, "banner-2.svg"),
|
||||||
|
campusScene("banner-2", 1920, 560, ["#0e4f4a", "#8fd0b0", "#fdeec1", "rgba(255,255,255,0.20)", "rgba(10,60,50,0.32)", "#1f6b52", "#2f8a68", "rgba(8,52,44,0.88)", "rgba(255,240,190,0.92)", "rgba(24,92,64,0.95)", "rgba(52,40,24,0.95)", "rgba(255,255,255,0.42)"], { water: true, buildingX: 0.46 }));
|
||||||
|
fs.writeFileSync(path.join(OUT, "banner-3.svg"),
|
||||||
|
campusScene("banner-3", 1920, 560, ["#3a1f5d", "#e08a6d", "#ffd9a0", "rgba(255,214,170,0.30)", "rgba(52,24,70,0.38)", "#4a2a63", "#6b3d84", "rgba(30,14,48,0.9)", "rgba(255,226,170,0.95)", "rgba(96,60,110,0.9)", "rgba(50,32,24,0.9)", "rgba(255,235,210,0.4)"], { horizon: 0.78, buildingX: 0.36 }));
|
||||||
|
fs.writeFileSync(path.join(OUT, "banner-4.svg"),
|
||||||
|
campusScene("banner-4", 1920, 560, ["#141c3a", "#4f7fd9", "#cfe0ff", "rgba(190,210,255,0.20)", "rgba(12,20,48,0.42)", "#1b2a52"], { horizon: 0.75, buildingX: 0.44 }));
|
||||||
|
|
||||||
|
/* ---- 文章封面(640×360)---- */
|
||||||
|
fs.writeFileSync(path.join(OUT, "cover-运动会.svg"),
|
||||||
|
campusScene("cover-sports", 640, 360, ["#0d3b66", "#69a8d8", "#fff3c4", "rgba(255,255,255,0.24)", "rgba(10,40,70,0.34)", "#1c5080", "#2e6da4", "rgba(10,34,62,0.9)", "rgba(255,230,160,0.92)", "rgba(30,90,70,0.95)", "rgba(50,38,22,0.95)", "rgba(255,255,255,0.5)"], { water: true }));
|
||||||
|
fs.writeFileSync(path.join(OUT, "cover-迎新晚会.svg"),
|
||||||
|
campusScene("cover-gala", 640, 360, ["#2a0e4e", "#c25b8a", "#ffe3a0", "rgba(255,200,220,0.26)", "rgba(40,12,64,0.4)", "#3c1a5c", "#57297a", "rgba(26,8,44,0.92)", "rgba(255,224,170,0.95)", "rgba(120,60,130,0.9)", "rgba(48,30,20,0.9)", "rgba(255,230,240,0.4)"], { horizon: 0.8, buildingX: 0.42 }));
|
||||||
|
fs.writeFileSync(path.join(OUT, "cover-创新创业.svg"),
|
||||||
|
campusScene("cover-inno", 640, 360, ["#03363d", "#4fd1c5", "#eafff9", "rgba(220,255,250,0.22)", "rgba(4,50,56,0.36)", "#065a63", "#0b7d85", "rgba(4,42,48,0.9)", "rgba(210,255,245,0.92)", "rgba(10,92,80,0.9)", "rgba(40,40,22,0.9)", "rgba(235,255,252,0.4)"], { water: true }));
|
||||||
|
|
||||||
|
/* ---- 正文配图(640×360)---- */
|
||||||
|
["art-1", "art-2", "art-3", "art-4", "art-5", "art-6"].forEach((n, i) => {
|
||||||
|
const palettes = [
|
||||||
|
["#1c3f68", "#7fb2e5", "#fff2c9", "rgba(255,255,255,0.22)", "rgba(20,45,80,0.30)", "#27517f"],
|
||||||
|
["#5d2019", "#e8a06a", "#fff0d0", "rgba(255,220,190,0.26)", "rgba(80,28,20,0.34)", "#7a3020", null, "rgba(70,24,16,0.9)", "rgba(255,235,200,0.92)", "rgba(160,70,40,0.9)", "rgba(56,36,20,0.9)", "rgba(255,240,220,0.42)"],
|
||||||
|
["#23306b", "#9fb4f0", "#f4f6ff", "rgba(230,235,255,0.24)", "rgba(24,32,86,0.32)", "#303f8c", "#4653a3", "rgba(18,24,66,0.9)", "rgba(235,240,255,0.92)", "rgba(50,70,140,0.9)", "rgba(44,36,26,0.9)", "rgba(245,248,255,0.45)"],
|
||||||
|
["#0e4f4a", "#8fd0b0", "#fdeec1", "rgba(255,255,255,0.20)", "rgba(10,60,50,0.32)", "#1f6b52", "#2f8a68", "rgba(8,52,44,0.88)", "rgba(255,240,190,0.92)", "rgba(24,92,64,0.95)", "rgba(52,40,24,0.95)", "rgba(255,255,255,0.42)"],
|
||||||
|
["#4a2a63", "#d9a0c8", "#ffe9f4", "rgba(255,230,245,0.24)", "rgba(60,32,82,0.34)", "#5c3675", "#754a8e", "rgba(44,22,60,0.9)", "rgba(255,235,245,0.92)", "rgba(110,70,120,0.9)", "rgba(50,32,22,0.9)", "rgba(255,245,250,0.42)"],
|
||||||
|
["#3d2b1f", "#d9b38c", "#fff6e3", "rgba(255,240,215,0.24)", "rgba(60,42,28,0.36)", "#5a4030", "#71523c", "rgba(52,36,24,0.9)", "rgba(255,240,210,0.92)", "rgba(120,90,50,0.9)", "rgba(46,32,18,0.9)", "rgba(255,250,235,0.42)"],
|
||||||
|
];
|
||||||
|
fs.writeFileSync(path.join(OUT, n + ".svg"), campusScene(n, 640, 360, palettes[i], { water: i % 2 === 0 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- 校徽 + 友链 logo ---- */
|
||||||
|
fs.writeFileSync(path.join(OUT, "logo-univ.svg"), emblem());
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
const hues = [210, 160, 20, 270, 120, 340, 40, 190];
|
||||||
|
const hue = hues[i - 1];
|
||||||
|
fs.writeFileSync(path.join(OUT, `logo-${i}.svg`), `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
|
||||||
|
<rect x="4" y="4" width="88" height="88" rx="18" fill="hsl(${hue},52%,46%)"/>
|
||||||
|
<rect x="4" y="4" width="88" height="88" rx="18" fill="none" stroke="rgba(255,255,255,0.35)" stroke-width="2"/>
|
||||||
|
<circle cx="48" cy="38" r="15" fill="rgba(255,255,255,0.9)"/>
|
||||||
|
<path d="M 24 74 Q 48 46 72 74 Z" fill="rgba(255,255,255,0.75)"/>
|
||||||
|
</svg>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 最小 PDF(附件) ---- */
|
||||||
|
function miniPdf(title) {
|
||||||
|
const text = `BT /F1 18 Tf 60 700 Td (${title}) Tj ET`;
|
||||||
|
const content = `1 0 0 1 0 0 cm\n${text}`;
|
||||||
|
const objs = [
|
||||||
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||||
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
|
||||||
|
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
|
||||||
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||||
|
];
|
||||||
|
let pdf = "%PDF-1.4\n";
|
||||||
|
const offsets = [];
|
||||||
|
objs.forEach((o, i) => {
|
||||||
|
offsets.push(pdf.length);
|
||||||
|
pdf += `${i + 1} 0 obj\n${o}\nendobj\n`;
|
||||||
|
});
|
||||||
|
const xref = pdf.length;
|
||||||
|
pdf += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
|
||||||
|
offsets.forEach(off => {
|
||||||
|
pdf += String(off).padStart(10, "0") + " 00000 n \n";
|
||||||
|
});
|
||||||
|
pdf += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
|
||||||
|
return Buffer.from(pdf, "latin1");
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(OUT, "2026校历.pdf"), miniPdf("2026 QiulixiaoLi (seed)"));
|
||||||
|
fs.writeFileSync(path.join(OUT, "运动会秩序册.pdf"), miniPdf("YunDongHui ZhiXuCe (seed)"));
|
||||||
|
fs.writeFileSync(path.join(OUT, "迎新晚会节目单.pdf"), miniPdf("YingXin WanHui (seed)"));
|
||||||
|
|
||||||
|
console.log("seed assets written to", OUT, ":", fs.readdirSync(OUT).length, "files");
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 门户主题 favicon 兜底资产生成器(4 套皮肤各一份)
|
||||||
|
* - 输出 templates/themes/<theme>/assets/favicon.svg + favicon.png(128×128)
|
||||||
|
* - head.html 在 site.config['favicon'] 未配置时输出 url.asset('favicon.png')(见 fragments/head.html)
|
||||||
|
* 图形语言对齐种子 logo-8 系(圆点+书脊),主色取各主题 theme.json 的 primary 参数默认值;
|
||||||
|
* 小尺寸下高对比扁平化:无边框、无环形留白。
|
||||||
|
* 用法:node theme-favicons.cjs (在 front/ 下执行,依赖 ./node_modules/ws 与系统 Edge)
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const http = require("http");
|
||||||
|
const WebSocket = require("ws");
|
||||||
|
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const ROOT = path.join(__dirname, "..", "..", "service", "src", "main", "resources", "templates", "themes");
|
||||||
|
const THEMES = [
|
||||||
|
{ dir: "default", primary: "#1a5cb0" },
|
||||||
|
{ dir: "classic-red", primary: "#b01a2e" },
|
||||||
|
{ dir: "blue-white", primary: "#2f6bd8" },
|
||||||
|
{ dir: "warm-kinder", primary: "#e8722a" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** #rrggbb -> [r,g,b] */
|
||||||
|
function rgb(hex) {
|
||||||
|
return [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
|
||||||
|
}
|
||||||
|
function mix(hex, target, t) {
|
||||||
|
const c = rgb(hex).map((v, i) => Math.round(v + (target[i] - v) * t));
|
||||||
|
return "#" + c.map(v => v.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
const light = hex => mix(hex, [255, 255, 255], 0.18);
|
||||||
|
const dark = hex => mix(hex, [0, 0, 0], 0.30);
|
||||||
|
|
||||||
|
function faviconSvg(primary) {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
|
<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="${light(primary)}"/><stop offset="1" stop-color="${dark(primary)}"/>
|
||||||
|
</linearGradient></defs>
|
||||||
|
<rect width="64" height="64" fill="url(#bg)"/>
|
||||||
|
<circle cx="32" cy="25" r="10" fill="#ffffff"/>
|
||||||
|
<path d="M 16 49.3 Q 32 30.7 48 49.3 Z" fill="#ffffff" opacity="0.92"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- CDP 栅格化(Edge headless,128×128 视图内 img 渲染 svg 矢量,天然高清) ---- */
|
||||||
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
function getJson(port, p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get({ host: "127.0.0.1", port, path: p }, res => {
|
||||||
|
let d = "";
|
||||||
|
res.on("data", c => d += c);
|
||||||
|
res.on("end", () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
|
||||||
|
}).on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function waitFor(fn, timeout, label) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
for (;;) {
|
||||||
|
try { if (await fn()) return; } catch (e) { /* retry */ }
|
||||||
|
if (Date.now() - t0 > timeout) throw new Error("timeout: " + label);
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function rasterizeSvg(svgPath, pngPath) {
|
||||||
|
const PORT = 9400 + Math.floor(Math.random() * 300);
|
||||||
|
const profile = path.join(require("os").tmpdir(), "fav-" + process.pid + "-" + Math.floor(Math.random() * 1e6));
|
||||||
|
const htmlPath = path.join(profile, "icon.html");
|
||||||
|
fs.mkdirSync(profile, { recursive: true });
|
||||||
|
fs.writeFileSync(htmlPath,
|
||||||
|
`<html><head><style>html,body{margin:0;padding:0;background:#fff}</style></head>
|
||||||
|
<body><img src="file:///${svgPath.replace(/\\/g, "/")}" width="128" height="128"></body></html>`);
|
||||||
|
const proc = spawn(EDGE, [
|
||||||
|
"--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
|
||||||
|
`--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
|
||||||
|
"--window-size=128,128", "--no-first-run", "--no-default-browser-check", "about:blank",
|
||||||
|
], { stdio: "ignore" });
|
||||||
|
try {
|
||||||
|
await waitFor(async () => { try { await getJson(PORT, "/json/version"); return true; } catch (e) { return false; } }, 20000, "devtools");
|
||||||
|
const pages = await getJson(PORT, "/json");
|
||||||
|
const page = pages.find(p => p.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
|
||||||
|
await new Promise(r => ws.on("open", r));
|
||||||
|
const cdp = {
|
||||||
|
id: 0,
|
||||||
|
pending: new Map(),
|
||||||
|
send(method, params = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = ++this.id;
|
||||||
|
this.pending.set(id, { resolve, reject });
|
||||||
|
ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ws.on("message", m => {
|
||||||
|
const msg = JSON.parse(m);
|
||||||
|
if (msg.id && cdp.pending.has(msg.id)) {
|
||||||
|
const { resolve, reject } = cdp.pending.get(msg.id);
|
||||||
|
cdp.pending.delete(msg.id);
|
||||||
|
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await cdp.send("Page.enable");
|
||||||
|
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 128, height: 128, deviceScaleFactor: 1, mobile: false });
|
||||||
|
const loaded = new Promise(r => {
|
||||||
|
const on = m => { const d = JSON.parse(m); if (d.method === "Page.loadEventFired") { ws.off("message", on); r(); } };
|
||||||
|
ws.on("message", on);
|
||||||
|
});
|
||||||
|
await cdp.send("Page.navigate", { url: "file:///" + htmlPath.replace(/\\/g, "/") });
|
||||||
|
await loaded;
|
||||||
|
await sleep(500);
|
||||||
|
const shot = await cdp.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
|
||||||
|
fs.writeFileSync(pngPath, Buffer.from(shot.data, "base64"));
|
||||||
|
ws.close();
|
||||||
|
} finally {
|
||||||
|
proc.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const t of THEMES) {
|
||||||
|
const dir = path.join(ROOT, t.dir, "assets");
|
||||||
|
fs.writeFileSync(path.join(dir, "favicon.svg"), faviconSvg(t.primary));
|
||||||
|
await rasterizeSvg(path.join(dir, "favicon.svg"), path.join(dir, "favicon.png"));
|
||||||
|
console.log("wrote", t.dir, "favicon.svg + favicon.png");
|
||||||
|
}
|
||||||
|
console.log("done");
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "cms",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"nodeversion": "20.19.4",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --mode dev",
|
||||||
|
"dev:mock": "vite --mode mock",
|
||||||
|
"build": "vite build --mode base",
|
||||||
|
"build:base": "vite build --mode base",
|
||||||
|
"build:prod": "vite build --mode prod",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"api:gen": "alova gen -f"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
|
"alova": "^3.4.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"pinia": "^2.1.4",
|
||||||
|
"sass": "^1.64.1",
|
||||||
|
"view-ui-plus": "^1.3.16",
|
||||||
|
"vue": "^3.4.19",
|
||||||
|
"vue-router": "4",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@alova/wormhole": "^1.5.0",
|
||||||
|
"@types/node": "^20.4.4",
|
||||||
|
"@unocss/preset-rem-to-px": "^66.6.0",
|
||||||
|
"@unocss/preset-uno": "^66.6.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"unocss": "^66.6.0",
|
||||||
|
"unplugin-auto-import": "0.18.3",
|
||||||
|
"vite": "^5.1.4",
|
||||||
|
"vite-plugin-html": "^3.2.2",
|
||||||
|
"vite-plugin-svg-icons": "^2.0.1",
|
||||||
|
"vitest": "^2.1.9",
|
||||||
|
"vue-tsc": "^1.8.27"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 933 B |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,378 @@
|
|||||||
|
/*-------------------------------------
|
||||||
|
zTree Style
|
||||||
|
|
||||||
|
version: 3.4
|
||||||
|
author: Hunter.z
|
||||||
|
email: hunter.z@263.net
|
||||||
|
website: http://code.google.com/p/jquerytree/
|
||||||
|
|
||||||
|
-------------------------------------*/
|
||||||
|
.ztree * {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: Verdana, Arial, Helvetica, AppleGothic, sans-serif
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree {
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px;
|
||||||
|
color: #333
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
line-height: 17px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
outline: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 18px
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li ul.line {
|
||||||
|
background: url(./img/line_conn.png) 0 0 repeat-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li a {
|
||||||
|
padding-right: 3px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 21px;
|
||||||
|
color: #333;
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
vertical-align: top;
|
||||||
|
display: inline-block
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li a:hover {
|
||||||
|
text-decoration: underline
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li a.curSelectedNode {
|
||||||
|
padding-top: 0px;
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
color: black;
|
||||||
|
height: 21px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li a.curSelectedNode_Edit {
|
||||||
|
padding-top: 0px;
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
color: black;
|
||||||
|
height: 21px;
|
||||||
|
border: 1px #666 solid;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li a.tmpTargetNode_inner {
|
||||||
|
padding-top: 0px;
|
||||||
|
background-color: #aaa;
|
||||||
|
color: white;
|
||||||
|
height: 21px;
|
||||||
|
border: 1px #666 solid;
|
||||||
|
opacity: 0.8;
|
||||||
|
filter: alpha(opacity=80)
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li a.tmpTargetNode_prev {}
|
||||||
|
|
||||||
|
.ztree li a.tmpTargetNode_next {}
|
||||||
|
|
||||||
|
.ztree li a input.rename {
|
||||||
|
height: 14px;
|
||||||
|
width: 80px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px #585956 solid;
|
||||||
|
*border: 0px
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span {
|
||||||
|
line-height: 21px;
|
||||||
|
margin-right: 2px
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button {
|
||||||
|
line-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
border: 0 none;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
background-color: transparent;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: scroll;
|
||||||
|
background-image: url("./img/bootstrap.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
margin: 0 2px;
|
||||||
|
cursor: auto
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_false_full {
|
||||||
|
background-position: -5px -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_false_full_focus {
|
||||||
|
background-position: -5px -26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_false_part {
|
||||||
|
background-position: -5px -48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_false_part_focus {
|
||||||
|
background-position: -5px -68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_false_disable {
|
||||||
|
background-position: -5px -89px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_true_full {
|
||||||
|
background-position: -26px -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_true_full_focus {
|
||||||
|
background-position: -26px -26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_true_part {
|
||||||
|
background-position: -26px -48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_true_part_focus {
|
||||||
|
background-position: -26px -68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.checkbox_true_disable {
|
||||||
|
background-position: -26px -89px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_false_full {
|
||||||
|
background-position: -47px -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_false_full_focus {
|
||||||
|
background-position: -47px -26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_false_part {
|
||||||
|
background-position: -47px -47px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_false_part_focus {
|
||||||
|
background-position: -47px -68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_false_disable {
|
||||||
|
background-position: -47px -89px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_true_full {
|
||||||
|
background-position: -68px -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_true_full_focus {
|
||||||
|
background-position: -68px -26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_true_part {
|
||||||
|
background-position: -68px -47px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_true_part_focus {
|
||||||
|
background-position: -68px -68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.chk.radio_true_disable {
|
||||||
|
background-position: -68px -89px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.switch {
|
||||||
|
width: 21px;
|
||||||
|
height: 21px
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.root_open {
|
||||||
|
background-position: -105px -63px
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.root_close {
|
||||||
|
background-position: -126px -63px
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.roots_open {
|
||||||
|
background-position: -105px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.roots_close {
|
||||||
|
background-position: -126px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.center_open {
|
||||||
|
background-position: -105px -21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.center_close {
|
||||||
|
background-position: -126px -21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.bottom_open {
|
||||||
|
background-position: -105px -42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.bottom_close {
|
||||||
|
background-position: -126px -42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.noline_open {
|
||||||
|
background-position: -105px -84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.noline_close {
|
||||||
|
background-position: -126px -84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.root_docu {
|
||||||
|
background-position: -105px -63px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.roots_docu {
|
||||||
|
background-position: -84px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.center_docu {
|
||||||
|
background-position: -84px -21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.bottom_docu {
|
||||||
|
background-position: -84px -42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.noline_docu {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.ico_open {
|
||||||
|
margin-right: 2px;
|
||||||
|
background-position: -147px -21px;
|
||||||
|
vertical-align: top;
|
||||||
|
*vertical-align: middle
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.ico_close {
|
||||||
|
margin-right: 2px;
|
||||||
|
margin-right: 2px;
|
||||||
|
background-position: -147px 0;
|
||||||
|
vertical-align: top;
|
||||||
|
*vertical-align: middle
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.ico_docu {
|
||||||
|
margin-right: 2px;
|
||||||
|
background-position: -147px -43px;
|
||||||
|
vertical-align: top;
|
||||||
|
*vertical-align: middle
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.edit {
|
||||||
|
margin-left: 2px;
|
||||||
|
margin-right: -1px;
|
||||||
|
background-position: -189px -21px;
|
||||||
|
vertical-align: top;
|
||||||
|
*vertical-align: middle
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.edit:hover {
|
||||||
|
background-position: -168px -21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.remove {
|
||||||
|
margin-left: 2px;
|
||||||
|
margin-right: -1px;
|
||||||
|
background-position: -189px -42px;
|
||||||
|
vertical-align: top;
|
||||||
|
*vertical-align: middle
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.remove:hover {
|
||||||
|
background-position: -168px -42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.add {
|
||||||
|
margin-left: 2px;
|
||||||
|
margin-right: -1px;
|
||||||
|
background-position: -189px 0;
|
||||||
|
vertical-align: top;
|
||||||
|
*vertical-align: middle
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button.add:hover {
|
||||||
|
background-position: -168px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.tmpTargetzTree {
|
||||||
|
background-color: #FFE6B0;
|
||||||
|
opacity: 0.8;
|
||||||
|
filter: alpha(opacity=80)
|
||||||
|
}
|
||||||
|
|
||||||
|
span.tmpzTreeMove_arrow {
|
||||||
|
width: 16px;
|
||||||
|
height: 21px;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0;
|
||||||
|
margin: 2px 0 0 1px;
|
||||||
|
border: 0 none;
|
||||||
|
position: absolute;
|
||||||
|
background-color: transparent;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: scroll;
|
||||||
|
background-position: -168px -84px;
|
||||||
|
background-image: url("./img/bootstrap.png");
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.tmpzTreeMove_prev {
|
||||||
|
background: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ul.ztree.zTreeDragUL {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #cfcfcf;
|
||||||
|
border: 1px #00B83F dotted;
|
||||||
|
opacity: 0.8;
|
||||||
|
filter: alpha(opacity=80)
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztreeMask {
|
||||||
|
z-index: 10000;
|
||||||
|
background-color: #cfcfcf;
|
||||||
|
opacity: 0.0;
|
||||||
|
filter: alpha(opacity=0);
|
||||||
|
position: absolute
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
|
import { authApi, baseApi } from "@/api/module/cms";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
import { AuthUserInfo } from "@/types/cms";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用根(docs/09 §3.3 双层壳):本组件只负责启动引导——登录态、
|
||||||
|
* 学校/站点上下文恢复(localStorage)、布局数据装配进 store.session。
|
||||||
|
* 页面骨架:/workbench 学校工作台(一级)+ /sch/:schCode 学校管理壳 SchoolLayout(二级)。
|
||||||
|
*/
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useCmsStore();
|
||||||
|
const isLogin = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// 1. CMS 登录态(可管理站点 + 超管标记)与平台用户信息并行取
|
||||||
|
const [cmsUser, baseUser, modList] = await Promise.all([
|
||||||
|
authApi.getUser().send() as Promise<AuthUserInfo>,
|
||||||
|
baseApi.getUserInfo().send().catch(() => null),
|
||||||
|
baseApi.getUserMod().send() as Promise<any[]>,
|
||||||
|
]);
|
||||||
|
store.setUser(cmsUser);
|
||||||
|
|
||||||
|
// 2. 布局数据(Notice layout 契约):有子菜单时不算「单模块独占」模式
|
||||||
|
const hasChildren = (modList || []).some((m: any) => m.children && m.children.length > 0);
|
||||||
|
store.setSession({
|
||||||
|
baseUserName: baseUser?.name || cmsUser?.name || cmsUser?.account || "管理员",
|
||||||
|
baseUserAvatar: baseUser?.profilePicture || "",
|
||||||
|
modList: modList || [],
|
||||||
|
isAlone: (modList || []).length <= 1 && !hasChildren,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 上下文恢复:学校 → 站点(enterSchool 会校验记忆站点是否仍属本校)
|
||||||
|
const sites = cmsUser?.sites || [];
|
||||||
|
const school = store.restoreSchool();
|
||||||
|
if (school) {
|
||||||
|
const main =
|
||||||
|
sites.find(s => s.id === school.mainSiteId) ||
|
||||||
|
sites.find(s => s.code === school.mainSiteCode);
|
||||||
|
if (main) {
|
||||||
|
store.restoreSite();
|
||||||
|
store.enterSchool(
|
||||||
|
{
|
||||||
|
schId: main.schId,
|
||||||
|
schName: main.schName,
|
||||||
|
mainSiteId: main.id,
|
||||||
|
mainSiteCode: main.code,
|
||||||
|
mainSiteName: main.name,
|
||||||
|
},
|
||||||
|
sites.filter(s => s.id === main.id || s.parentSiteId === main.id)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 记住的学校已不可管理:清上下文(站点一并清,避免串校数据)
|
||||||
|
store.setCurrentSchool(null);
|
||||||
|
store.setCurrentSite(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.restoreSite();
|
||||||
|
}
|
||||||
|
|
||||||
|
isLogin.value = true;
|
||||||
|
} catch (error: any) {
|
||||||
|
// CAS 未登录由 alova 层 302 跳转(挂起不 resolve),走到这里的是真实失败
|
||||||
|
console.error("加载用户信息失败", error);
|
||||||
|
if (error?.message !== "redirect") {
|
||||||
|
Message.error("载入登录信息失败,请检查后端服务是否正常!");
|
||||||
|
}
|
||||||
|
// 降级:进入空工作台(可重试),不再渲染管理壳
|
||||||
|
isLogin.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view v-if="isLogin" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { Method } from "alova";
|
||||||
|
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||||
|
import conf from "@/assets/config/host.config";
|
||||||
|
import { IObj } from "@/types/Basics";
|
||||||
|
import {
|
||||||
|
ArticleEditVo,
|
||||||
|
ArticlePageItem,
|
||||||
|
ArticlePageQuery,
|
||||||
|
ArticleSaveRequest,
|
||||||
|
AttachmentVo,
|
||||||
|
AuthUserInfo,
|
||||||
|
BannerVo,
|
||||||
|
CategoryTreeNode,
|
||||||
|
DeleteRequest,
|
||||||
|
LinkVo,
|
||||||
|
PageResult,
|
||||||
|
SchoolCard,
|
||||||
|
SchoolOption,
|
||||||
|
SiteVo,
|
||||||
|
ThemeVo,
|
||||||
|
} from "@/types/cms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CMS 管理端 API(手写 class 风格,同 Notice front 约定)。
|
||||||
|
* baseURL = conf.serviceHost(dev 为空串走 vite 代理;生产 = 源 + /cms 上下文)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AUTH = "/api/auth";
|
||||||
|
const BASE = "/api/base";
|
||||||
|
const SCHOOL = "/api/school";
|
||||||
|
const SITE = "/api/site";
|
||||||
|
const CATEGORY = "/api/category";
|
||||||
|
const ARTICLE = "/api/article";
|
||||||
|
const ATTACHMENT = "/api/attachment";
|
||||||
|
const BANNER = "/api/banner";
|
||||||
|
const LINK = "/api/link";
|
||||||
|
const THEME = "/api/theme";
|
||||||
|
|
||||||
|
/** GET(query 参数) */
|
||||||
|
function get<T>(url: string, params?: IObj): Method {
|
||||||
|
return alovaInstance.Get<T>(url, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST JSON(@RequestBody) */
|
||||||
|
function postJSON<T>(url: string, data?: unknown): Method {
|
||||||
|
return alovaInstance.Post<T>(url, data, {
|
||||||
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传地址(wangEditor customUpload / Upload 组件 action 直链,不经 alova) */
|
||||||
|
export function uploadUrl(): string {
|
||||||
|
return `${conf.isMock ? "" : conf.serviceHost}/api/attachment/upload`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门户预览基地址({serviceHost}/portal/{siteCode}) */
|
||||||
|
export function portalBase(siteCode: string): string {
|
||||||
|
return `${conf.isMock ? "" : conf.serviceHost}/portal/${siteCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthApi {
|
||||||
|
/** 登录态:用户 + 可管理站点 + 是否超管 */
|
||||||
|
getUser() {
|
||||||
|
return get<AuthUserInfo>(`${AUTH}/user`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台代理域(Notice BaseApiController 同款契约) */
|
||||||
|
class BaseApi {
|
||||||
|
/** 当前登录用户信息(CAS 会话注入,头像/姓名用于 layout) */
|
||||||
|
getUserInfo() {
|
||||||
|
return get<any>(`${BASE}/GetUserInfo`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用户已授权的模块列表(左侧菜单树) */
|
||||||
|
getUserMod() {
|
||||||
|
return get<any[]>(`${BASE}/GetUserMod`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SchoolApi {
|
||||||
|
/** 学校工作台列表(学校卡片 = 顶层站点聚合,docs/09 §3.3) */
|
||||||
|
list() {
|
||||||
|
return get<SchoolCard[]>(`${SCHOOL}/list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UserBase 学校选项(添加学校绑定用,仅超管;平台不可达为空数组) */
|
||||||
|
options() {
|
||||||
|
return get<SchoolOption[]>(`${SCHOOL}/options`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SiteApi {
|
||||||
|
list() {
|
||||||
|
return get<SiteVo[]>(`${SITE}/list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 入参对齐 SiteSaveRequest(themeParams 服务端合并进 config_json.theme_params,方案 §5.3) */
|
||||||
|
save(data: Partial<SiteVo> & { themeParams?: Record<string, string> }) {
|
||||||
|
return postJSON<SiteVo>(`${SITE}/save`, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CategoryApi {
|
||||||
|
tree(siteId: number) {
|
||||||
|
return get<CategoryTreeNode[]>(`${CATEGORY}/tree`, { siteId });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(data: Partial<CategoryTreeNode>) {
|
||||||
|
return postJSON<CategoryTreeNode>(`${CATEGORY}/save`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(data: DeleteRequest) {
|
||||||
|
return postJSON<void>(`${CATEGORY}/delete`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子栏目排序(UserBase SortChildren 同语义:按传入顺序落库 sortNo=1..n) */
|
||||||
|
sort(data: { siteId: number; parentId: number; ids: number[] }) {
|
||||||
|
return postJSON<void>(`${CATEGORY}/sort`, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArticleApi {
|
||||||
|
page(query: ArticlePageQuery) {
|
||||||
|
return get<PageResult<ArticlePageItem>>(`${ARTICLE}/page`, { ...query });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑回显 */
|
||||||
|
get(siteId: number, id: number) {
|
||||||
|
return get<ArticleEditVo>(`${ARTICLE}/get`, { siteId, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存草稿(content 服务端 jsoup 清洗) */
|
||||||
|
save(data: ArticleSaveRequest) {
|
||||||
|
return postJSON<number>(`${ARTICLE}/save`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发布(publishTime 为未来时间即定时发布) */
|
||||||
|
publish(siteId: number, id: number) {
|
||||||
|
return postJSON<void>(`${ARTICLE}/publish/${id}`, undefined, {
|
||||||
|
params: { siteId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
offline(siteId: number, id: number) {
|
||||||
|
return postJSON<void>(`${ARTICLE}/offline/${id}`, undefined, {
|
||||||
|
params: { siteId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
topmost(siteId: number, id: number, enable: boolean) {
|
||||||
|
return postJSON<void>(`${ARTICLE}/topmost/${id}`, undefined, {
|
||||||
|
params: { siteId, enable },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(data: DeleteRequest) {
|
||||||
|
return postJSON<void>(`${ARTICLE}/delete`, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentApi {
|
||||||
|
page(siteId: number, orphanOnly: boolean, page: number, pageSize: number) {
|
||||||
|
return get<PageResult<AttachmentVo> & { orphanCount: number }>(`${ATTACHMENT}/page`, {
|
||||||
|
siteId,
|
||||||
|
orphanOnly,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(data: DeleteRequest) {
|
||||||
|
return postJSON<void>(`${ATTACHMENT}/delete`, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BannerApi {
|
||||||
|
list(siteId: number, position?: string) {
|
||||||
|
return get<BannerVo[]>(`${BANNER}/list`, { siteId, position });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(data: Partial<BannerVo>) {
|
||||||
|
return postJSON<BannerVo>(`${BANNER}/save`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(data: DeleteRequest) {
|
||||||
|
return postJSON<void>(`${BANNER}/delete`, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LinkApi {
|
||||||
|
list(siteId: number) {
|
||||||
|
return get<LinkVo[]>(`${LINK}/list`, { siteId });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(data: Partial<LinkVo>) {
|
||||||
|
return postJSON<LinkVo>(`${LINK}/save`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(data: DeleteRequest) {
|
||||||
|
return postJSON<void>(`${LINK}/delete`, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThemeApi {
|
||||||
|
/** 可用主题列表(含 params schema,建站向导/站点编辑共用,方案 §5.2) */
|
||||||
|
list() {
|
||||||
|
return get<ThemeVo[]>(`${THEME}/list`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀) */
|
||||||
|
export function themePreviewUrl(siteCode: string, theme: string, themeParams: Record<string, string> = {}): string {
|
||||||
|
const params = new URLSearchParams({ siteCode, theme, page: "home" });
|
||||||
|
Object.keys(themeParams).forEach(k => params.set(`p_${k}`, themeParams[k]));
|
||||||
|
return `${conf.isMock ? "" : conf.serviceHost}/api/theme/preview?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = new AuthApi();
|
||||||
|
export const baseApi = new BaseApi();
|
||||||
|
export const schoolApi = new SchoolApi();
|
||||||
|
export const siteApi = new SiteApi();
|
||||||
|
export const categoryApi = new CategoryApi();
|
||||||
|
export const articleApi = new ArticleApi();
|
||||||
|
export const attachmentApi = new AttachmentApi();
|
||||||
|
export const bannerApi = new BannerApi();
|
||||||
|
export const linkApi = new LinkApi();
|
||||||
|
export const themeApi = new ThemeApi();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
authApi,
|
||||||
|
baseApi,
|
||||||
|
schoolApi,
|
||||||
|
siteApi,
|
||||||
|
categoryApi,
|
||||||
|
articleApi,
|
||||||
|
attachmentApi,
|
||||||
|
bannerApi,
|
||||||
|
linkApi,
|
||||||
|
themeApi,
|
||||||
|
uploadUrl,
|
||||||
|
portalBase,
|
||||||
|
themePreviewUrl,
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseConf } from "@/types/Basics";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Vite 环境变量管理配置(Notice front 同模式)
|
||||||
|
* 环境变量定义在项目根目录的 .env.dev / .env.base / .env.prod 中
|
||||||
|
* serviceHost = 源 + 后端上下文路径(如 http://base.yunhaixingtong.com/cms),
|
||||||
|
* dev 模式留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头)
|
||||||
|
*/
|
||||||
|
const env = import.meta.env;
|
||||||
|
|
||||||
|
let conf: BaseConf = {
|
||||||
|
baseHost: env.VITE_BASE_HOST,
|
||||||
|
ssoHost: env.VITE_SSO_HOST,
|
||||||
|
serviceHost: env.VITE_IS_MOCK === "true" ? "" : env.VITE_SERVICE_HOST,
|
||||||
|
isMock: env.VITE_IS_MOCK === "true",
|
||||||
|
isDebug: env.DEV,
|
||||||
|
};
|
||||||
|
|
||||||
|
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
|
||||||
|
var ishttps = "https:" == document.location.protocol ? true : false;
|
||||||
|
if (ishttps) {
|
||||||
|
Object.keys(conf).forEach(element => {
|
||||||
|
const val = (conf as any)[element];
|
||||||
|
if (typeof val === "string" && val.indexOf("http://") > -1) {
|
||||||
|
(conf as any)[element] = val.replace("http://", "https://");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default conf;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M41.65 64.5995C42.942 65.8915 45.05 66.2995 46.75 65.4495C49.742 64.1575 52.292 62.8995 54.842 60.7575C56.134 59.4655 56.984 57.7995 56.542 55.6575C56.1 53.1075 56.542 50.1155 57.834 47.5655C59.126 45.0155 61.642 43.3155 64.226 42.4655C65.926 42.0235 67.184 40.3235 67.626 38.6575C68.068 35.6655 68.068 32.2655 67.626 29.3075C67.184 27.6075 65.926 25.9075 64.226 25.4655C61.676 24.6155 59.126 22.9155 57.834 20.3655C56.542 17.8155 56.134 14.8235 56.542 12.2735C56.984 10.5735 56.1 8.43153 54.842 7.17353C52.292 5.06553 49.742 3.77353 46.75 2.48153C45.05 1.63153 42.942 2.03953 41.65 3.33153C37.4 7.24153 30.6 7.24153 26.35 3.39953C25.058 2.10753 22.95 1.69953 21.25 2.54953C18.292 3.80753 15.708 5.09953 13.158 7.24153C11.458 8.53353 11.016 10.2335 11.458 12.3415C12.75 17.8495 9.35 23.7995 3.842 25.4995C1.7 25.9415 0.442 27.6415 0.442 29.3415C0 31.0415 0 32.2995 0 33.9995C0 35.6995 0 36.9915 0.442 38.6915C0.442 40.3915 1.734 42.0915 3.842 42.4995C6.392 43.3495 8.942 45.0495 10.234 47.5995C11.526 50.1495 11.934 53.1415 11.492 55.6915C11.05 57.3915 11.934 59.4995 13.192 60.7915C15.742 62.9335 18.292 64.1915 21.284 65.4835C22.984 66.3335 25.126 65.9255 26.384 64.6335C30.6 60.3495 37.4 60.3495 41.65 64.5995Z" fill="#919BF2"/>
|
||||||
|
<path d="M24.208 34C24.208 35.2859 24.4613 36.5592 24.9534 37.7472C25.4455 38.9353 26.1667 40.0147 27.076 40.924C27.9853 41.8333 29.0647 42.5545 30.2528 43.0466C31.4408 43.5387 32.7141 43.792 34 43.792C35.2859 43.792 36.5592 43.5387 37.7472 43.0466C38.9353 42.5545 40.0147 41.8333 40.924 40.924C41.8333 40.0147 42.5545 38.9353 43.0466 37.7472C43.5387 36.5592 43.792 35.2859 43.792 34C43.792 32.7141 43.5387 31.4408 43.0466 30.2528C42.5545 29.0648 41.8333 27.9853 40.924 27.076C40.0147 26.1667 38.9353 25.4455 37.7472 24.9534C36.5592 24.4613 35.2859 24.208 34 24.208C32.7141 24.208 31.4408 24.4613 30.2528 24.9534C29.0647 25.4455 27.9853 26.1667 27.076 27.076C26.1667 27.9853 25.4455 29.0648 24.9534 30.2528C24.4613 31.4408 24.208 32.7141 24.208 34Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7245_84837)">
|
||||||
|
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7245_84837">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7245_84837)">
|
||||||
|
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7245_84837">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7245_84837)">
|
||||||
|
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7245_84837">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7245_84837)">
|
||||||
|
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7245_84837">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7245_84837)">
|
||||||
|
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7245_84837">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7186_97095)">
|
||||||
|
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M44.1242 40.672C45.2522 41.032 46.2482 41.452 47.1122 41.932L46.4282 42.976C45.4922 42.424 44.5142 41.98 43.4942 41.644L44.1242 40.672ZM51.1982 40.402C51.0062 40.93 50.8502 41.314 50.7302 41.554H57.9842V42.328C57.7682 43.228 57.4802 44.104 57.1202 44.956L55.8782 44.578C56.1662 44.05 56.4242 43.42 56.6522 42.688H53.5922C53.5202 43.12 53.4122 43.516 53.2682 43.876C54.2042 45.256 56.0582 46.21 58.8302 46.738L58.3622 47.89C55.7222 47.35 53.8502 46.366 52.7462 44.938C52.5182 45.286 52.2782 45.592 52.0262 45.856C51.1262 46.696 49.6802 47.35 47.6882 47.818L47.2022 46.666C48.9782 46.282 50.2562 45.754 51.0362 45.082C51.6962 44.47 52.1342 43.672 52.3502 42.688H50.0462C49.5422 43.336 48.9482 43.93 48.2642 44.47L47.5082 43.462C48.6722 42.61 49.4762 41.536 49.9202 40.24L51.1982 40.402ZM56.7602 48.304V53.65H55.4642V49.51H46.4822V53.794H45.1862V48.304H56.7602ZM51.7562 50.302C51.5042 52.582 50.9582 54.118 50.1182 54.91C49.1702 55.894 47.0822 56.512 43.8542 56.764L43.3502 55.612C46.2182 55.444 48.1082 54.952 49.0202 54.136C49.8362 53.356 50.3162 52.042 50.4602 50.194L51.7562 50.302ZM47.3822 44.938C46.4822 45.874 45.3662 46.756 44.0342 47.584L43.1702 46.576C44.5262 45.808 45.6782 44.968 46.6262 44.056L47.3822 44.938ZM52.1702 53.596C54.4982 54.208 56.6282 54.946 58.5602 55.81L57.8582 56.89C55.7822 55.906 53.6642 55.138 51.5042 54.586L52.1702 53.596Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7186_97095">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7186_97102)">
|
||||||
|
<path d="M52.5096 4.53333H43.4429V2.26667C43.4429 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2941 0 23.0429 0C21.7917 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70481 4.53333 2.64294 8.5952 2.64294 13.6V54.4C2.64294 59.4048 6.70481 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.5189 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.2429 48.8512 16.2429 47.6C16.2429 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9653 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.2429 35.2512 16.2429 34C16.2429 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9653 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.2429 21.6512 16.2429 20.4C16.2429 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9653 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M46.2482 45.172H47.4902V47.962H49.3082V49.15H47.4902V51.67C48.1742 51.526 48.8222 51.382 49.4342 51.238V52.444L47.4902 52.894V56.818H46.2482V53.128C45.2402 53.308 44.1362 53.47 42.9362 53.614L42.7742 52.354C44.2382 52.186 45.3962 52.03 46.2482 51.886V49.15H43.2602L42.9902 48.016C43.5062 46.888 43.9862 45.484 44.4302 43.804H42.8282V42.562H44.7362C44.9402 41.65 45.0962 40.894 45.2042 40.294L46.4282 40.492L45.9962 42.562H49.2542V43.804H45.6902C45.2702 45.424 44.8142 46.81 44.3222 47.962H46.2482V45.172ZM49.8302 42.58H53.6822V40.366H54.9062V42.58H57.3002C56.9042 42.04 56.4182 41.464 55.8422 40.852L56.7062 40.312C57.3662 41.008 57.8762 41.608 58.2362 42.112L57.5702 42.58H59.2082V43.804H54.9062V45.46H58.5962V55.234C58.5962 56.218 58.1462 56.71 57.2462 56.71H56.0042L55.6802 55.486L56.8322 55.54C57.2042 55.54 57.3902 55.354 57.3902 54.982V52.84H54.9062V56.674H53.6822V52.84H51.3242V56.782H50.1182V45.46H53.6822V43.804H49.8302V42.58ZM53.6822 46.612H51.3242V48.646H53.6822V46.612ZM51.3242 49.762V51.724H53.6822V49.762H51.3242ZM57.3902 51.724V49.762H54.9062V51.724H57.3902ZM57.3902 48.646V46.612H54.9062V48.646H57.3902Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7186_97102">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7186_97108)">
|
||||||
|
<path d="M52.5096 4.53333H43.4429V2.26667C43.4429 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2941 0 23.0429 0C21.7917 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70481 4.53333 2.64294 8.5952 2.64294 13.6V54.4C2.64294 59.4048 6.70481 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.5189 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.2429 48.8512 16.2429 47.6C16.2429 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9653 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.2429 35.2512 16.2429 34C16.2429 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9653 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.2429 21.6512 16.2429 20.4C16.2429 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9653 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M55.2302 55.288C56.7302 55.288 58.0802 55.264 59.2802 55.216L59.0642 56.512H55.5182C51.2102 56.512 48.5162 56.278 47.4362 55.81C46.5002 55.414 45.6542 54.688 44.8982 53.632C44.5742 54.784 44.1242 55.846 43.5482 56.818L42.6122 55.99C43.5722 54.358 44.1002 52.516 44.1962 50.464L45.3482 50.608C45.3122 51.256 45.2522 51.826 45.1682 52.318C45.6362 53.05 46.1522 53.626 46.7162 54.046V49.672H43.1522V48.484H50.7842V49.672H47.8682V51.58H50.3342V52.732H47.8682V54.694C47.9762 54.73 48.0662 54.766 48.1382 54.802C49.1822 55.126 51.5462 55.288 55.2302 55.288ZM43.9622 40.996H50.1002V47.278H43.9622V40.996ZM48.8402 46.198V44.668H45.2222V46.198H48.8402ZM45.2222 43.66H48.8402V42.076H45.2222V43.66ZM51.0362 41.014H58.8122V42.202H55.5722C55.5122 42.73 55.4342 43.21 55.3382 43.642H58.3082V50.914H57.1022V44.758H52.7462V50.914H51.5942V43.642H54.1682C54.2642 43.234 54.3422 42.754 54.4022 42.202H51.0362V41.014ZM56.2202 50.968C57.3362 51.832 58.2722 52.696 59.0282 53.56L58.1102 54.46C57.4142 53.596 56.4962 52.708 55.3562 51.796L56.2202 50.968ZM54.3302 45.604H55.5182V48.142C55.4822 49.834 55.1522 51.202 54.5282 52.246C53.9282 53.182 52.9322 53.926 51.5402 54.478L50.8382 53.362C52.1222 52.894 53.0282 52.252 53.5562 51.436C54.0362 50.572 54.2942 49.474 54.3302 48.142V45.604Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7186_97108">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7186_97114)">
|
||||||
|
<path d="M52.5096 4.53333H43.4429V2.26667C43.4429 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2941 0 23.0429 0C21.7917 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70481 4.53333 2.64294 8.5952 2.64294 13.6V54.4C2.64294 59.4048 6.70481 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.5189 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.2429 48.8512 16.2429 47.6C16.2429 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9653 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.2429 35.2512 16.2429 34C16.2429 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9653 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.2429 21.6512 16.2429 20.4C16.2429 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9653 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M50.4602 48.826H48.7682V47.62H53.4302V48.826H51.7382V53.704C52.5902 53.512 53.3702 53.314 54.0782 53.11V54.316C52.5182 54.76 50.8322 55.138 49.0202 55.45L48.6962 54.226C49.3082 54.154 49.8962 54.064 50.4602 53.956V48.826ZM57.2822 56.53C56.6582 56.53 56.1122 56.062 55.6442 55.126C55.1042 54.046 54.7082 52.576 54.4562 50.716C54.2642 49.192 54.1322 47.218 54.0602 44.794H48.3542V43.534H54.0242C54.0002 42.838 53.9882 41.74 53.9882 40.24H55.2482C55.2482 41.752 55.2602 42.85 55.2842 43.534H58.8662V44.794H55.3022C55.3862 47.83 55.5782 50.218 55.8782 51.958C56.2742 53.998 56.7422 55.054 57.2822 55.126C57.6542 55.054 58.0202 53.95 58.3802 51.814L59.4062 52.642C58.8182 55.234 58.1102 56.53 57.2822 56.53ZM56.7962 40.222C57.5522 41.062 58.1522 41.818 58.5962 42.49L57.6962 43.12C57.2762 42.472 56.6702 41.692 55.8782 40.78L56.7962 40.222ZM44.9162 40.474C46.0442 41.374 47.0102 42.298 47.8142 43.246L46.9322 44.146C46.2002 43.246 45.2282 42.316 44.0162 41.356L44.9162 40.474ZM42.7562 45.802H46.5362V53.65C46.9682 53.23 47.5202 52.654 48.1922 51.922L48.5342 53.29C47.5262 54.382 46.4762 55.336 45.3842 56.152L44.8802 55C45.1442 54.772 45.2762 54.526 45.2762 54.262V47.044H42.7562V45.802Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7186_97114">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7186_97120)">
|
||||||
|
<path d="M52.5096 4.53333H43.4429V2.26667C43.4429 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2941 0 23.0429 0C21.7917 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70481 4.53333 2.64294 8.5952 2.64294 13.6V54.4C2.64294 59.4048 6.70481 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.5189 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.2429 48.8512 16.2429 47.6C16.2429 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9653 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.2429 35.2512 16.2429 34C16.2429 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9653 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.2429 21.6512 16.2429 20.4C16.2429 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9653 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
|
||||||
|
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
|
||||||
|
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
|
||||||
|
<path d="M42.9181 45.784H48.0661C48.2221 45.196 48.4081 44.452 48.6241 43.552H44.1421V42.346H48.8941C49.0021 41.854 49.1521 41.128 49.3441 40.168L50.6761 40.276L50.2621 42.346H58.0201V43.552H49.9921C49.8241 44.296 49.6441 45.04 49.4521 45.784H59.0821V47.044H49.1101C48.9421 47.62 48.6841 48.412 48.3361 49.42H56.9041V50.536C56.0161 51.904 54.6601 53.242 52.8361 54.55C53.8321 54.91 54.8701 55.348 55.9501 55.864L55.2301 56.998C52.5661 55.678 49.7821 54.628 46.8781 53.848L47.5621 52.84C48.7621 53.164 50.0581 53.566 51.4501 54.046C53.0941 52.966 54.4021 51.826 55.3741 50.626H46.4821C46.9501 49.366 47.3581 48.172 47.7061 47.044H42.9181V45.784Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7186_97120">
|
||||||
|
<rect width="68" height="68" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg
|
||||||
|
t="1615860353141"
|
||||||
|
class="icon"
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
p-id="6624"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0 102.4h307.2v307.2H0zM0 614.4h307.2v307.2H0zM409.6 102.4h614.4v102.4H409.6zM409.6 307.2h614.4v102.4H409.6zM409.6 614.4h614.4v102.4H409.6zM409.6 819.2h614.4v102.4H409.6z"
|
||||||
|
p-id="6625"
|
||||||
|
fill="#656667"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 914 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M832 192H544l-64-64H192c-35.2 0-64 28.8-64 64v576c0 35.2 28.8 64 64 64h640c35.2 0 64-28.8 64-64V256c0-35.2-28.8-64-64-64z m0 576H192V192h266.7l64 64H832v512z" fill="#999999"/>
|
||||||
|
<path d="M256 320h512v64H256z m0 128h384v64H256z m0 128h256v64H256z" fill="#cccccc"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 380 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1" y="1" width="30" height="30" rx="3" fill="white" stroke="#DCDEE0" stroke-width="2" stroke-dasharray="4 4"/>
|
||||||
|
<g clip-path="url(#clip0_125_1092)">
|
||||||
|
<path d="M24 8H8V24H24V8Z" fill="white" fill-opacity="0.01"/>
|
||||||
|
<path d="M16.0209 11.3333L16.0087 20.6666" stroke="#666666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.3333 16H20.6667" stroke="#666666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_125_1092">
|
||||||
|
<rect width="16" height="16" fill="white" transform="translate(8 8)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 695 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1" y="1" width="30" height="30" rx="3" fill="white" stroke="#DCDEE0" stroke-width="2"/>
|
||||||
|
<g clip-path="url(#clip0_125_1099)">
|
||||||
|
<path d="M24 8H8V24H24V8Z" fill="white" fill-opacity="0.01"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.6667 13H21.3333L20.3333 22.6667H11.6667L10.6667 13Z" stroke="#F15B43" stroke-width="1.125" stroke-linejoin="round"/>
|
||||||
|
<path d="M14.6667 16.3333V19.6667" stroke="#F15B43" stroke-width="1.125" stroke-linecap="round"/>
|
||||||
|
<path d="M17.3333 16.3333V19.6658" stroke="#F15B43" stroke-width="1.125" stroke-linecap="round"/>
|
||||||
|
<path d="M12 13L17.4414 9L20 13" stroke="#F15B43" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_125_1099">
|
||||||
|
<rect width="16" height="16" fill="white" transform="translate(8 8)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 919 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M482.214544 543.934407l-0.099952-59.970717 59.970718-0.099952 0.099951 59.970718-59.970717 0.099951z m119.941435-0.199902l-0.099952-59.970718 59.970718-0.099951 0.099951 59.970717-59.970717 0.099952z m119.941434-0.199903l-0.099951-59.970717 59.970718-0.099951 0.099951 59.970717-59.970718 0.099951z m119.941435-0.299853l-0.099951-59.970718 59.970717-0.099951 0.099952 59.970717-59.970718 0.099952z m119.941435-0.199903l-0.099951-59.970717 59.970717-0.099951 0.099951 59.970717-59.970717 0.099951z" />
|
||||||
|
<path d="M482.914202 1024l-0.099951-59.970717 59.970717-0.099952 0.099951 59.970718-59.970717 0.099951z m-0.199902-119.941435l-0.099952-59.970717 59.970718-0.099951 0.099951 59.970717-59.970717 0.099951z m-0.199903-119.941435l-0.099951-59.970717 59.970717-0.099951 0.099952 59.970717-59.970718 0.099951z m-0.199902-119.941435l-0.099951-59.970717 59.970717-0.099951 0.099951 59.970717-59.970717 0.099951z m-0.099951-119.941434l-0.099952-59.970718 59.970718-0.099951 0.099951 59.970717-59.970717 0.099952z m-0.199903-119.941435l-0.099951-59.970718 59.970718-0.099951 0.099951 59.970718-59.970718 0.099951z m-0.199902-119.941435l-0.099951-59.970718 59.970717-0.099951 0.099951 59.970718-59.970717 0.099951z m-0.199902-119.941435l-0.099952-59.970717 59.970718-0.099952 0.099951 59.970718-59.970717 0.099951z m-0.199903-119.941435l-0.099951-59.970717 59.970717-0.099951 0.099952 59.970717-59.970718 0.099951z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7225_67996)">
|
||||||
|
<path d="M18 0H0V18H18V0Z" fill="white" fill-opacity="0.01"/>
|
||||||
|
<path d="M14.625 2.25H3.375C2.75368 2.25 2.25 2.75368 2.25 3.375V14.625C2.25 15.2463 2.75368 15.75 3.375 15.75H14.625C15.2463 15.75 15.75 15.2463 15.75 14.625V3.375C15.75 2.75368 15.2463 2.25 14.625 2.25Z" stroke="#5B6871" stroke-width="1.125" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 6V12" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 9H12" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7225_67996">
|
||||||
|
<rect width="18" height="18" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 793 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 0H0V18H18V0Z" fill="white" fill-opacity="0.01"/>
|
||||||
|
<path d="M2.625 15.75H16.125" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.125 10.02V12.75H6.86895L14.625 4.99054L11.8857 2.25L4.125 10.02Z" stroke="#5B6871" stroke-width="1.125" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 424 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7225_67984)">
|
||||||
|
<path d="M18 0H0V18H18V0Z" fill="white" fill-opacity="0.01"/>
|
||||||
|
<path d="M7 3V15" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 7L7 3" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11 15V3" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11 15L15 11" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7225_67984">
|
||||||
|
<rect width="18" height="18" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 740 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg t="1784779423851" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6101" width="24" height="24"><path d="M1016.32 494.08l-143.872-143.36c-9.728-9.728-26.112-9.728-35.84 0-9.728 9.728-9.728 26.112 0 35.84L936.96 486.4h-399.36V87.04l99.84 100.352c9.728 9.728 26.112 9.728 35.84 0 9.728-9.728 9.728-26.112 0-35.84l-143.36-143.872c-9.728-9.728-26.112-9.728-35.84 0l-143.36 143.872c-9.728 9.728-9.728 26.112 0 35.84 9.728 9.728 26.112 9.728 35.84 0L486.4 87.04v399.36H87.04l100.352-99.84c9.728-9.728 9.728-26.112 0-35.84-9.728-9.728-26.112-9.728-35.84 0l-143.872 143.36c-9.728 9.728-9.728 26.112 0 35.84l143.872 143.36c9.728 9.728 26.112 9.728 35.84 0 9.728-9.728 9.728-26.112 0-35.84L87.04 537.6h399.36v399.36l-99.84-100.352c-9.728-9.728-26.112-9.728-35.84 0-9.728 9.728-9.728 26.112 0 35.84l143.36 143.872c9.728 9.728 26.112 9.728 35.84 0l143.36-143.872c9.728-9.728 9.728-26.112 0-35.84-9.728-9.728-26.112-9.728-35.84 0L537.6 936.96v-399.36h399.36l-100.352 99.84c-9.728 9.728-9.728 26.112 0 35.84 9.728 9.728 26.112 9.728 35.84 0l143.872-143.36c10.24-9.728 10.24-26.112 0-35.84z" p-id="6102"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7225_67990)">
|
||||||
|
<path d="M18 0H0V18H18V0Z" fill="white" fill-opacity="0.01"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 5.625H15L13.875 16.5H4.125L3 5.625Z" stroke="#5B6871" stroke-width="1.125" stroke-linejoin="round"/>
|
||||||
|
<path d="M7.50073 9.37598V13.1261" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round"/>
|
||||||
|
<path d="M10.5009 9.375V13.1241" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round"/>
|
||||||
|
<path d="M4.5 5.62496L10.6216 1.125L13.5 5.625" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7225_67990">
|
||||||
|
<rect width="18" height="18" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 795 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg t="1784812882139" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3484" width="24" height="24"><path d="M446.112323 177.545051c137.567677 0.219798 252.612525 104.59798 266.162424 241.493333 13.562828 136.895354-78.778182 261.818182-213.617777 289.008485-134.852525 27.203232-268.386263-52.156768-308.945455-183.608889s25.018182-272.252121 151.738182-325.779394A267.235556 267.235556 0 0 1 446.112323 177.545051m0-62.060607c-182.794343 0-330.989899 148.195556-330.989899 330.989899s148.195556 330.989899 330.989899 330.989899 330.989899-148.195556 330.989899-330.989899-148.195556-330.989899-330.989899-330.989899z m431.321212 793.341415a30.849293 30.849293 0 0 1-21.94101-9.102223l-157.220202-157.220202c-11.752727-12.179394-11.584646-31.534545 0.37495-43.50707 11.972525-11.972525 31.327677-12.140606 43.494141-0.37495l157.220202 157.220202a31.036768 31.036768 0 0 1 6.723232 33.810101 31.004444 31.004444 0 0 1-28.651313 19.174142z m0 0" p-id="3485"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1014 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_161_3866" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.3333 0H10.6667V10.6667H0V13.3333H10.6667V24H13.3333V13.3333H24V10.6667H13.3333V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_161_3866)">
|
||||||
|
<rect x="-4" y="-4" width="32" height="32" fill="#646566"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 476 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_161_2681" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="3" y="3" width="12" height="12">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.7203 3.21967L14.7809 4.28033L10.061 9.00025L14.7809 13.7202L13.7203 14.7809L9.00034 10.0609L4.28039 14.7809L3.21973 13.7202L7.93968 9.00025L3.21975 4.28033L4.28041 3.21967L9.00034 7.93959L13.7203 3.21967Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_161_2681)">
|
||||||
|
<rect width="18" height="18" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 560 B |
@@ -0,0 +1,17 @@
|
|||||||
|
*{
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
html,body,#app{
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body{
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
min-width: 1200px;
|
||||||
|
}
|
||||||
|
a{
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* http://meyerweb.com/eric/tools/css/reset/ v2.0 | 20110126 License: none (public domain)*/
|
||||||
|
div, span, applet, object, iframe,h1, h2, h3, h4, h5, h6, p, blockquote, pre,a, abbr, acronym, address, big, cite, code,del, dfn, em, img, ins, kbd, q, s, samp,small, strike, strong, sub, sup, tt, var,b, u, i, center,dl, dt, dd, ol, ul, li,fieldset, form, label, legend,table, caption, tbody, tfoot, thead, tr, th, td,article, aside, canvas, details, embed,figure, figcaption, footer, header, hgroup,menu, nav, output, ruby, section, summary,time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; vertical-align: baseline;}/* HTML5 display-role reset for older browsers */article, aside, details, figcaption, figure,footer, header, hgroup, menu, nav, section { display: block;}ol, ul { list-style: none;}blockquote, q { quotes: none;}blockquote:before, blockquote:after,q:before, q:after { content: ''; content: none;}table { border-collapse: collapse; border-spacing: 0;}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/*滚动条样式 */
|
||||||
|
|
||||||
|
/*第一种样式,所有的滚动条,都默认这个样子*/
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*正常情况下滑块的样式*/
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, .05);
|
||||||
|
border-radius: 10px;
|
||||||
|
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*鼠标悬浮在该类指向的控件上时滑块的样式*/
|
||||||
|
*:hover::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, .2);
|
||||||
|
border-radius: 10px;
|
||||||
|
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*鼠标悬浮在滑块上时滑块的样式*/
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(0, 0, 0, .3);
|
||||||
|
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*正常时候的主干部分*/
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
border-radius: 10px;
|
||||||
|
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*鼠标悬浮在滚动条上的主干部分*/
|
||||||
|
*::-webkit-scrollbar-track:hover {
|
||||||
|
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .4);
|
||||||
|
background-color: rgba(0, 0, 0, .01);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*第一种样式结束*/
|
||||||
|
|
||||||
|
|
||||||
|
/*第二种样式*/
|
||||||
|
|
||||||
|
.scroll2::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*正常时候的主干部分*/
|
||||||
|
.scroll2::-webkit-scrollbar-track {
|
||||||
|
border-radius: 10px;
|
||||||
|
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*正常情况下滑块的样式*/
|
||||||
|
.scroll2::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, .05);
|
||||||
|
border-radius: 10px;
|
||||||
|
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*鼠标悬浮在该类指向的控件上时滑块的样式*/
|
||||||
|
.scroll2:hover::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, .2);
|
||||||
|
border-radius: 10px;
|
||||||
|
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*鼠标悬浮在滚动条上的主干部分*/
|
||||||
|
.scroll2::-webkit-scrollbar-track:hover {
|
||||||
|
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*鼠标悬浮在滑块上时滑块的样式*/
|
||||||
|
.scroll2::-webkit-scrollbar-thumb:hover {
|
||||||
|
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*第二种样式结束*/
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
.my-table {
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 1px solid #c7c7c7;
|
||||||
|
border-right: 1px solid #c7c7c7;
|
||||||
|
border-collapse: separate;
|
||||||
|
|
||||||
|
.td-title {
|
||||||
|
background: #fafafa;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #111;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #fafafa;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border-top: 1px solid #c7c7c7;
|
||||||
|
border-left: 1px solid #c7c7c7;
|
||||||
|
margin-left: -1px;
|
||||||
|
margin-top: -1px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.rule-tree {
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 280px !important;
|
||||||
|
.head-ul {
|
||||||
|
background: #f6f8f9;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
height: 48px;
|
||||||
|
li {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
.ivu-tooltip,
|
||||||
|
.ivu-tooltip-rel {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #eaeaea;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
background-color: #fff;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #515a6e;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
background-color: transparent;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-box {
|
||||||
|
padding: 11px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li span.button {
|
||||||
|
background-repeat: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
table.small-table {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 5px;
|
||||||
|
th {
|
||||||
|
background-color: #fafafa;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 5px 20px;
|
||||||
|
}
|
||||||
|
tr:first-child td {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
tr:last-child td {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.input-box {
|
||||||
|
width: 100px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.ivu-input-wrapper {
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
$liColor: #969799, #e6a23c, #45b972, #eb0404;
|
||||||
|
|
||||||
|
@each $c in $liColor {
|
||||||
|
$i: index($liColor, $c);
|
||||||
|
.status-color-#{$i} {
|
||||||
|
color: $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* 规范 iconfont 图标本地实现(bsch 应用页规范 §2 操作列 / 布局头尾图标)。
|
||||||
|
* 平台 base.layout.beta 主题不含 iconfont 字体文件,且新旧 BschHome 均已不再提供
|
||||||
|
* icon-browse 一类的字形,这里以 SVG mask + currentColor 实现同名类:
|
||||||
|
* 颜色跟随文字色,天然支持 .operating-btn:hover 圆形反色效果。
|
||||||
|
* 后续若接入平台真实 iconfont,可整文件移除。
|
||||||
|
*/
|
||||||
|
.iconfont {
|
||||||
|
font-family: inherit;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.iconfont::before {
|
||||||
|
content: "" !important;
|
||||||
|
display: block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
background-color: currentColor;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
mask-position: center;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
mask-size: contain;
|
||||||
|
}
|
||||||
|
.icon-browse::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M512 256C299 256 121 361 32 512c89 151 267 256 480 256s391-105 480-256c-89-151-267-256-480-256zm0 416c-88 0-160-72-160-160s72-160 160-160 160 72 160 160-72 160-160 160z'/%3E%3Ccircle cx='512' cy='512' r='80'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M512 256C299 256 121 361 32 512c89 151 267 256 480 256s391-105 480-256c-89-151-267-256-480-256zm0 416c-88 0-160-72-160-160s72-160 160-160 160 72 160 160-72 160-160 160z'/%3E%3Ccircle cx='512' cy='512' r='80'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-edit::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M768 128c18 0 35 7 49 21l58 58c27 27 27 71 0 98L448 833l-200 54c-16 4-31-11-27-27l54-200L803 149c14-14 31-21 49-21zM128 896h768v48H128z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M768 128c18 0 35 7 49 21l58 58c27 27 27 71 0 98L448 833l-200 54c-16 4-31-11-27-27l54-200L803 149c14-14 31-21 49-21zM128 896h768v48H128z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-ashbin::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M416 96h192l32 64h160c18 0 32 14 32 32s-14 32-32 32H224c-18 0-32-14-32-32s14-32 32-32h160l32-64zM224 288h576l-36 520c-3 46-41 82-88 82H348c-47 0-85-36-88-82l-36-520zm176 64c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32zm224 0c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M416 96h192l32 64h160c18 0 32 14 32 32s-14 32-32 32H224c-18 0-32-14-32-32s14-32 32-32h160l32-64zM224 288h576l-36 520c-3 46-41 82-88 82H348c-47 0-85-36-88-82l-36-520zm176 64c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32zm224 0c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-user-filling::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 128a176 176 0 1 1 0 352 176 176 0 0 1 0-352zm0 416c190 0 320 104 320 224v32c0 26-21 48-48 48H240c-27 0-48-22-48-48v-32c0-120 130-224 320-224z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 128a176 176 0 1 1 0 352 176 176 0 0 1 0-352zm0 416c190 0 320 104 320 224v32c0 26-21 48-48 48H240c-27 0-48-22-48-48v-32c0-120 130-224 320-224z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-attachment::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M640 64H256c-35 0-64 29-64 64v768c0 35 29 64 64 64h512c35 0 64-29 64-64V256L640 64zm-16 112l144 144h-144V176zM320 448h384v48H320v-48zm0 128h384v48H320v-48zm0 128h384v48H320v-48z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M640 64H256c-35 0-64 29-64 64v768c0 35 29 64 64 64h512c35 0 64-29 64-64V256L640 64zm-16 112l144 144h-144V176zM320 448h384v48H320v-48zm0 128h384v48H320v-48zm0 128h384v48H320v-48z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-apps::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M224 128h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V160c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V160c0-18 14-32 32-32zM224 480h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V512c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V512c0-18 14-32 32-32z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M224 128h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V160c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V160c0-18 14-32 32-32zM224 480h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V512c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V512c0-18 14-32 32-32z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-logout::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M544 96c18 0 32 14 32 32v96h-64v-64H256v704h256v-64h64v96c0 18-14 32-32 32H224c-18 0-32-14-32-32V128c0-18 14-32 32-32h320zm208 416L600 664l-45-45 75-75H448v-64h182l-75-75 45-45 152 152z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M544 96c18 0 32 14 32 32v96h-64v-64H256v704h256v-64h64v96c0 18-14 32-32 32H224c-18 0-32-14-32-32V128c0-18 14-32 32-32h320zm208 416L600 664l-45-45 75-75H448v-64h182l-75-75 45-45 152 152z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-category::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 96l448 384h-112v352c0 18-14 32-32 32H608V608H416v256H208c-18 0-32-14-32-32V480H64l448-384z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 96l448 384h-112v352c0 18-14 32-32 32H608V608H416v256H208c-18 0-32-14-32-32V480H64l448-384z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
/* 停用(圆圈斜杠),字形取自 bsch iconfont icon-stop(\e6e0) */
|
||||||
|
.icon-stop::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667zM885.333333 512c0 85.333333-29.866667 164.266667-78.933333 228.266667l-533.333333-514.133334c64-55.466667 149.333333-87.466667 238.933333-87.466666 204.8 0 373.333333 168.533333 373.333333 373.333333z m-746.666666 0c0-91.733333 34.133333-174.933333 87.466666-241.066667l535.466667 516.266667c-66.133333 59.733333-153.6 98.133333-251.733333 98.133333-202.666667 0-371.2-168.533333-371.2-373.333333z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667zM885.333333 512c0 85.333333-29.866667 164.266667-78.933333 228.266667l-533.333333-514.133334c64-55.466667 149.333333-87.466667 238.933333-87.466666 204.8 0 373.333333 168.533333 373.333333 373.333333z m-746.666666 0c0-91.733333 34.133333-174.933333 87.466666-241.066667l535.466667 516.266667c-66.133333 59.733333-153.6 98.133333-251.733333 98.133333-202.666667 0-371.2-168.533333-371.2-373.333333z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 通知模块图标(2026-08-31 与 oldfront 对齐,仅保留用到的) ============
|
||||||
|
* 附件类型字形(word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi)取自 oldfront/src/assets/js/iconfont/iconfont.js 同名 symbol,
|
||||||
|
* icon-saoba(一键已读)/icon-print(打印)平台原字体已失传,按同规格重绘。
|
||||||
|
*/
|
||||||
|
.icon-saoba::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M832 64L960 192 640 512 512 384 832 64zM512 384L640 512C560 704 448 848 256 960 192 992 128 976 96 928 64 880 80 816 128 768 256 608 384 480 512 384z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M832 64L960 192 640 512 512 384 832 64zM512 384L640 512C560 704 448 848 256 960 192 992 128 976 96 928 64 880 80 816 128 768 256 608 384 480 512 384z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-print::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M352 64h320c18 0 32 14 32 32v160H320V96c0-18 14-32 32-32zM224 320h576c35 0 64 29 64 64v224c0 35-29 64-64 64h-96v-96c0-18-14-32-32-32H384c-18 0-32 14-32 32v96h-96c-35 0-64-29-64-64V384c0-35 29-64 64-64zM352 512h96v64c0 18 14 32 32 32h64c18 0 32-14 32-32v-64h96v352c0 18-14 32-32 32H352c-18 0-32-14-32-32V544c0-18 14-32 32-32z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M352 64h320c18 0 32 14 32 32v160H320V96c0-18 14-32 32-32zM224 320h576c35 0 64 29 64 64v224c0 35-29 64-64 64h-96v-96c0-18-14-32-32-32H384c-18 0-32 14-32 32v96h-96c-35 0-64-29-64-64V384c0-35 29-64 64-64zM352 512h96v64c0 18 14 32 32 32h64c18 0 32-14 32-32v-64h96v352c0 18-14 32-32 32H352c-18 0-32-14-32-32V544c0-18 14-32 32-32z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-word::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z M329.728 428.032l81.158144 282.95168h48.254976l54.833152-212.762624h2.195456l54.833152 212.762624h48.254976L700.416 428.032h-52.641792l-52.641792 214.953984h-2.195456L538.103808 428.032h-48.254976l-52.641792 214.953984h-2.195456L382.369792 428.032z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z M329.728 428.032l81.158144 282.95168h48.254976l54.833152-212.762624h2.195456l54.833152 212.762624h48.254976L700.416 428.032h-52.641792l-52.641792 214.953984h-2.195456L538.103808 428.032h-48.254976l-52.641792 214.953984h-2.195456L382.369792 428.032z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-excel::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M246.784 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H246.784c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M438.9888 716.8l91.725824-131.657728L622.915584 716.8h43.708416l-114.704384-159.62112L658.366464 409.6h-43.708416l-83.943424 119.607296L447.1808 409.6H403.488768l105.590784 147.57888L395.264 716.8h43.7248z M656.384 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M246.784 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H246.784c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M438.9888 716.8l91.725824-131.657728L622.915584 716.8h43.708416l-114.704384-159.62112L658.366464 409.6h-43.708416l-83.943424 119.607296L447.1808 409.6H403.488768l105.590784 147.57888L395.264 716.8h43.7248z M656.384 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-ppt::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M428.335104 745.472v-125.636608h89.501696c70.234112 0 105.7792-30.547968 105.7792-91.213824 0-60.235776-35.119104-90.353664-105.34912-90.353664H393.216v307.2l35.119104 0.004096z m87.793664-156.18048h-87.793664v-120.471552h87.793664c24.408064 0 42.3936 4.734976 54.382592 15.065088 11.993088 9.03168 17.985536 24.088576 17.985536 44.7488 0 20.647936-5.988352 35.704832-17.563648 45.604864-11.984896 9.89184-29.970432 15.0528-54.808576 15.0528h0.004096z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M428.335104 745.472v-125.636608h89.501696c70.234112 0 105.7792-30.547968 105.7792-91.213824 0-60.235776-35.119104-90.353664-105.34912-90.353664H393.216v307.2l35.119104 0.004096z m87.793664-156.18048h-87.793664v-120.471552h87.793664c24.408064 0 42.3936 4.734976 54.382592 15.065088 11.993088 9.03168 17.985536 24.088576 17.985536 44.7488 0 20.647936-5.988352 35.704832-17.563648 45.604864-11.984896 9.89184-29.970432 15.0528-54.808576 15.0528h0.004096z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-pdf1::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M230.4 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H230.4c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M645.042176 674.586624c-27.643904-2.052096-54.255616-12.316672-75.75552-30.797824-41.971712 9.23648-81.903616 22.581248-121.831424 39.010304-31.735808 56.459264-61.423616 85.200896-87.01952 85.200896-5.12 0-11.259904-1.024-15.355904-4.108288-11.264-5.128192-17.403904-16.42496-17.403904-27.713536 0-9.240576 2.048-34.902016 99.303424-76.996608 22.528-41.0624 39.923712-83.1488 54.259712-127.287296-12.288-24.63744-38.899712-85.204992-20.48-115.99872 6.144-11.292672 18.432-17.453056 31.744-16.42496 10.235904 0 20.471808 5.132288 26.615808 13.340672 13.312 18.477056 12.288 57.48736-5.12 114.97472a309.497856 309.497856 0 0 0 63.471616 82.1248c21.504-4.112384 42.995712-7.18848 64.495616-7.18848 48.115712 1.024 55.283712 23.609344 54.259712 36.958208 0.008192 34.906112-33.779712 34.906112-51.183616 34.906112z m-286.646272 63.639552l3.072-1.024c14.331904-5.128192 25.587712-15.396864 33.779712-28.745728-15.36 6.160384-27.643904 16.42496-36.851712 29.773824v-0.004096z m136.155136-307.95776h-3.072c-1.024 0-3.072 0-4.096 1.024-4.096 17.44896-1.024 35.926016 6.144 52.350976 6.144-17.444864 6.144-35.926016 1.024-53.374976z m7.168 148.84864l-1.024 2.048-1.024-1.024c-9.216 23.61344-19.456 47.222784-30.715904 69.808128l2.048-1.024v2.048a594.771968 594.771968 0 0 1 69.615616-20.529152l-1.024-1.024h3.072c-15.355904-15.405056-29.687808-32.854016-40.947712-50.302976z m139.227136 54.407168c-9.216 0-17.403904 0-26.619904 2.048 10.24 5.136384 20.48 7.18848 30.715904 8.216576 7.168 1.024 14.336 0 20.475904-2.052096 0-3.084288-4.096-8.21248-24.571904-8.21248z M640 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M230.4 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H230.4c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M645.042176 674.586624c-27.643904-2.052096-54.255616-12.316672-75.75552-30.797824-41.971712 9.23648-81.903616 22.581248-121.831424 39.010304-31.735808 56.459264-61.423616 85.200896-87.01952 85.200896-5.12 0-11.259904-1.024-15.355904-4.108288-11.264-5.128192-17.403904-16.42496-17.403904-27.713536 0-9.240576 2.048-34.902016 99.303424-76.996608 22.528-41.0624 39.923712-83.1488 54.259712-127.287296-12.288-24.63744-38.899712-85.204992-20.48-115.99872 6.144-11.292672 18.432-17.453056 31.744-16.42496 10.235904 0 20.471808 5.132288 26.615808 13.340672 13.312 18.477056 12.288 57.48736-5.12 114.97472a309.497856 309.497856 0 0 0 63.471616 82.1248c21.504-4.112384 42.995712-7.18848 64.495616-7.18848 48.115712 1.024 55.283712 23.609344 54.259712 36.958208 0.008192 34.906112-33.779712 34.906112-51.183616 34.906112z m-286.646272 63.639552l3.072-1.024c14.331904-5.128192 25.587712-15.396864 33.779712-28.745728-15.36 6.160384-27.643904 16.42496-36.851712 29.773824v-0.004096z m136.155136-307.95776h-3.072c-1.024 0-3.072 0-4.096 1.024-4.096 17.44896-1.024 35.926016 6.144 52.350976 6.144-17.444864 6.144-35.926016 1.024-53.374976z m7.168 148.84864l-1.024 2.048-1.024-1.024c-9.216 23.61344-19.456 47.222784-30.715904 69.808128l2.048-1.024v2.048a594.771968 594.771968 0 0 1 69.615616-20.529152l-1.024-1.024h3.072c-15.355904-15.405056-29.687808-32.854016-40.947712-50.302976z m139.227136 54.407168c-9.216 0-17.403904 0-26.619904 2.048 10.24 5.136384 20.48 7.18848 30.715904 8.216576 7.168 1.024 14.336 0 20.475904-2.052096 0-3.084288-4.096-8.21248-24.571904-8.21248z M640 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-txt::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M238.592 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H238.592c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M540.11904 741.376v-276.652032h108.07296V434.176H397.312v30.547968h108.07296V741.376h34.73408z M648.192 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M238.592 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H238.592c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M540.11904 741.376v-276.652032h108.07296V434.176H397.312v30.547968h108.07296V741.376h34.73408z M648.192 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-tupian1::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M329.703424 716.53376l74.645504-109.71136a11.603968 11.603968 0 0 1 17.73568-1.748992l49.332224 48.3328 108.2368-169.652224a11.599872 11.599872 0 0 1 19.84512 0.446464l134.483968 233.0624a11.599872 11.599872 0 0 1-10.05568 17.412096H339.304448a11.599872 11.599872 0 0 1-9.60512-18.14528l0.004096 0.004096z M352.055296 468.115456c-0.004096 32.31744 26.189824 58.519552 58.507264 58.523648 32.31744 0.004096 58.519552-26.189824 58.523648-58.507264v-0.016384c0.004096-32.31744-26.189824-58.519552-58.507264-58.523648s-58.519552 26.189824-58.523648 58.507264v0.016384z M660.48 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M329.703424 716.53376l74.645504-109.71136a11.603968 11.603968 0 0 1 17.73568-1.748992l49.332224 48.3328 108.2368-169.652224a11.599872 11.599872 0 0 1 19.84512 0.446464l134.483968 233.0624a11.599872 11.599872 0 0 1-10.05568 17.412096H339.304448a11.599872 11.599872 0 0 1-9.60512-18.14528l0.004096 0.004096z M352.055296 468.115456c-0.004096 32.31744 26.189824 58.519552 58.507264 58.523648 32.31744 0.004096 58.519552-26.189824 58.523648-58.507264v-0.016384c0.004096-32.31744-26.189824-58.519552-58.507264-58.523648s-58.519552 26.189824-58.523648 58.507264v0.016384z M660.48 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-yasuo::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M588.8 630.378496v111.24736a27.783168 27.783168 0 0 1-8.167424 19.664896 27.89376 27.89376 0 0 1-19.70176 8.146944h-56.905728a27.89376 27.89376 0 0 1-19.70176-8.142848 27.795456 27.795456 0 0 1-8.167424-19.677184V630.3744h112.644096z m-27.869184 63.73376h-56.905728v55.857152h56.905728v-55.857152z m26.939392-174.98112v55.857152h-55.967744v-55.857152h55.967744zM532.598784 126.976v56.32h56.201216V239.616h-56.209408v52.146176h56.209408v56.32h-56.201216v55.627776h56.201216v55.627776h-56.201216v55.62368H476.16v-55.627776h56.201216V403.705856H476.16v-55.62368h56.201216v-56.32H476.16v-56.32h56.201216V183.296H476.16V126.976h56.438784z M660.48 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M588.8 630.378496v111.24736a27.783168 27.783168 0 0 1-8.167424 19.664896 27.89376 27.89376 0 0 1-19.70176 8.146944h-56.905728a27.89376 27.89376 0 0 1-19.70176-8.142848 27.795456 27.795456 0 0 1-8.167424-19.677184V630.3744h112.644096z m-27.869184 63.73376h-56.905728v55.857152h56.905728v-55.857152z m26.939392-174.98112v55.857152h-55.967744v-55.857152h55.967744zM532.598784 126.976v56.32h56.201216V239.616h-56.209408v52.146176h56.209408v56.32h-56.201216v55.627776h56.201216v55.627776h-56.201216v55.62368H476.16v-55.627776h56.201216V403.705856H476.16v-55.62368h56.201216v-56.32H476.16v-56.32h56.201216V183.296H476.16V126.976h56.438784z M660.48 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.icon-weizhi::before {
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M242.688 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H242.688c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M652.288 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z M421.888 501.76c0-20.48 4.096-36.864 8.192-47.104s12.288-20.48 22.528-28.672c10.24-10.24 20.48-16.384 34.816-20.48 12.288-4.096 24.576-6.144 38.912-6.144 28.672 0 51.2 8.192 71.68 24.576 18.432 16.384 28.672 36.864 28.672 63.488 0 12.288-4.096 22.528-8.192 32.768s-14.336 22.528-30.72 36.864-22.528 24.576-28.672 30.72-8.192 14.336-12.288 22.528-2.048 14.336-2.048 34.816h-40.96c0-20.48 2.048-30.72 4.096-40.96s6.144-18.432 12.288-26.624 12.288-18.432 26.624-32.768 24.576-24.576 28.672-30.72 8.192-16.384 8.192-30.72-6.144-24.576-16.384-34.816-24.576-16.384-43.008-16.384c-40.96 0-61.44 28.672-61.44 69.632h-40.96z m122.88 225.28h-40.96v-40.96h40.96v40.96z'/%3E%3C/svg%3E");
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M242.688 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H242.688c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M652.288 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z M421.888 501.76c0-20.48 4.096-36.864 8.192-47.104s12.288-20.48 22.528-28.672c10.24-10.24 20.48-16.384 34.816-20.48 12.288-4.096 24.576-6.144 38.912-6.144 28.672 0 51.2 8.192 71.68 24.576 18.432 16.384 28.672 36.864 28.672 63.488 0 12.288-4.096 22.528-8.192 32.768s-14.336 22.528-30.72 36.864-22.528 24.576-28.672 30.72-8.192 14.336-12.288 22.528-2.048 14.336-2.048 34.816h-40.96c0-20.48 2.048-30.72 4.096-40.96s6.144-18.432 12.288-26.624 12.288-18.432 26.624-32.768 24.576-24.576 28.672-30.72 8.192-16.384 8.192-30.72-6.144-24.576-16.384-34.816-24.576-16.384-43.008-16.384c-40.96 0-61.44 28.672-61.44 69.632h-40.96z m122.88 225.28h-40.96v-40.96h40.96v40.96z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
@import "base/base";
|
||||||
|
@import "base/bscroll.css";
|
||||||
|
@import "base/reset";
|
||||||
|
|
||||||
|
@import "utils/mixins";
|
||||||
|
@import "utils/utils";
|
||||||
|
|
||||||
|
@import "componets/status";
|
||||||
|
@import "componets/small_table";
|
||||||
|
@import "componets/my_table";
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
fill: currentColor;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-subtitle {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 12px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
background: #5979ea;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4.second-title {
|
||||||
|
@extend .base-subtitle;
|
||||||
|
&::before {
|
||||||
|
background: #5979ea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4.subtitle {
|
||||||
|
@extend .second-title;
|
||||||
|
&::before {
|
||||||
|
background: #bda366;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-wrap {
|
||||||
|
display: flex;
|
||||||
|
padding-top: 10px;
|
||||||
|
.left-box {
|
||||||
|
width: 200px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.user-list-ul {
|
||||||
|
height: calc(100% - 50px);
|
||||||
|
overflow-y: auto;
|
||||||
|
li {
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-top: none;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border 200ms;
|
||||||
|
img {
|
||||||
|
object-fit: cover;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.stu-name {
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
span.stu-card {
|
||||||
|
width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li.active {
|
||||||
|
// background: #f3f3f3;
|
||||||
|
color: #7892ed;
|
||||||
|
border-right: 3px solid #5979ea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.right-box {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-spin-load {
|
||||||
|
width: 60px;
|
||||||
|
height: 90px;
|
||||||
|
padding-top: 60px;
|
||||||
|
background-image: url(../image/loading.gif);
|
||||||
|
background-size: 60px 60px;
|
||||||
|
background-position: top center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border: none;
|
||||||
|
color: #577afb;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ivu-table-cell-tree {
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@mixin tag_mixin($bgColor,$textColor) {
|
||||||
|
width:64px;
|
||||||
|
height:20px ;
|
||||||
|
padding: 0 5px;
|
||||||
|
background: $bgColor;
|
||||||
|
color: $textColor;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 2px 2px 2px 2px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// utils 工具// 颜色
|
||||||
|
|
||||||
|
@import "./varible";
|
||||||
|
.bg-error {
|
||||||
|
background-color: $color-error !important;
|
||||||
|
}
|
||||||
|
.text-error {
|
||||||
|
color: $color-error !important;
|
||||||
|
}
|
||||||
|
.text-success {
|
||||||
|
color: $color-success !important;
|
||||||
|
}
|
||||||
|
.text-warning {
|
||||||
|
color: $color-warning !important;
|
||||||
|
}
|
||||||
|
.text-primary {
|
||||||
|
color: $color-primary !important;
|
||||||
|
}
|
||||||
|
.text-normal {
|
||||||
|
color: #404042 !important;
|
||||||
|
}
|
||||||
|
.text-black {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.text-dark {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.text-deep {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.text-weak {
|
||||||
|
color: #b3b3b3;
|
||||||
|
}
|
||||||
|
.text-white {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error.disabled {
|
||||||
|
color: #aaa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字对齐
|
||||||
|
.tl {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.tc {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tr {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
// 浮动与清除浮动
|
||||||
|
.fl {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
.fr {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.fix {
|
||||||
|
*zoom: 1;
|
||||||
|
}
|
||||||
|
.fix:after {
|
||||||
|
display: table;
|
||||||
|
content: "";
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
// 显示
|
||||||
|
.dn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.di {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.db {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.dib {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.dt {
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
div.dib {
|
||||||
|
*display: inline;
|
||||||
|
*zoom: 1;
|
||||||
|
}
|
||||||
|
.vm {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.vib {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.ovh {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
// 定位
|
||||||
|
.pr {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pa {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.pf {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-middle {
|
||||||
|
position: relative;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.align-middle-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 1 through 100 {
|
||||||
|
.width-#{$i} {
|
||||||
|
width: $i * 1%;
|
||||||
|
}
|
||||||
|
.height-#{$i} {
|
||||||
|
height: $i * 1px;
|
||||||
|
}
|
||||||
|
// @if $i<50 and $i%2==0 {
|
||||||
|
// @if $i<50 {
|
||||||
|
.ml-#{$i} {
|
||||||
|
margin-left: $i * 1px !important;
|
||||||
|
}
|
||||||
|
.mr-#{$i} {
|
||||||
|
margin-right: $i * 1px !important;
|
||||||
|
}
|
||||||
|
.mt-#{$i} {
|
||||||
|
margin-top: $i * 1px !important;
|
||||||
|
}
|
||||||
|
.mb-#{$i} {
|
||||||
|
margin-bottom: $i * 1px !important;
|
||||||
|
}
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字体
|
||||||
|
@for $i from 10 through 28 {
|
||||||
|
.f#{$i} {
|
||||||
|
font-size: $i * 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .chart-box {
|
||||||
|
// height: 50px;
|
||||||
|
// width: 100%;
|
||||||
|
// border-bottom: 50px solid;
|
||||||
|
// border-image: linear-gradient(90deg, #f55c65 0%, #fcced0 48%, #d4dffc 52%, #7194f5 100%) 50 50 50 50;
|
||||||
|
// }
|
||||||
|
// @for $i from 0 through 50 {
|
||||||
|
// .blue-#{$i} {
|
||||||
|
// background-color: rgba(#7194f5, 0.1 * $i * 0.2);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// @for $i from 0 through 50 {
|
||||||
|
// .red-#{$i} {
|
||||||
|
// background-color: rgba(#f55c65, 0.1 * $i * 0.2);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/*scss变量*/
|
||||||
|
|
||||||
|
// 主题色
|
||||||
|
$color-error: #e8684a;
|
||||||
|
$color-success: #45b972;
|
||||||
|
$color-warning: #e9b954;
|
||||||
|
$color-primary: #5979ea;
|
||||||
|
|
||||||
|
// 文字色
|
||||||
|
$color-black: #404042;
|
||||||
|
$color-dark: #333;
|
||||||
|
$color-deep: #555;
|
||||||
|
$color-pl: #999999;
|
||||||
|
$color-weak: #b3b3b3;
|
||||||
|
$color-white: #fff;
|
||||||
|
|
||||||
|
// 背景色
|
||||||
|
$bg-bar: #f9f9f9;
|
||||||
|
$bg-page: #f3f3f3;
|
||||||
|
$bg-page-light: #f9f9f9;
|
||||||
|
|
||||||
|
$border-color: #e9e9e9;
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<Modal v-model="visible" title="子栏目排序" :width="520" :mask-closable="false">
|
||||||
|
<Alert show-icon>拖动左侧手柄进行排序,保存后按此顺序在门户展示</Alert>
|
||||||
|
<draggable class="sort-list" v-model="localChildren" item-key="id" handle=".drag-handle" @change="onDragChange">
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<div class="sort-item">
|
||||||
|
<span class="drag-handle">⠿</span>
|
||||||
|
<span class="sort-index">{{ index + 1 }}</span>
|
||||||
|
<span class="sort-item-name">{{ element.name || element.id }}</span>
|
||||||
|
<span class="sort-no">排序号: {{ element.sortNo ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
|
<div class="sort-empty" v-if="localChildren.length === 0">
|
||||||
|
暂无子栏目
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button type="primary" :loading="saving" @click="saveSort" :disabled="localChildren.length === 0">
|
||||||
|
保存排序
|
||||||
|
</Button>
|
||||||
|
<Button @click="visible = false">取消</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
/* UserBase GroupSort 平移:字段 sort→sortNo,接口换 categoryApi.sort */
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import draggable from "vuedraggable";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import { categoryApi } from "@/api/module/cms";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', { default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
parentId: number | null
|
||||||
|
parentName: string
|
||||||
|
children: any[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
|
||||||
|
const store = useCmsStore();
|
||||||
|
const localChildren = ref<any[]>([])
|
||||||
|
const saving = ref(false)
|
||||||
|
const hasChanged = ref(false)
|
||||||
|
|
||||||
|
function resetLocal() {
|
||||||
|
localChildren.value = (props.children || []).map((x) => ({ ...x }))
|
||||||
|
hasChanged.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragChange() {
|
||||||
|
hasChanged.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSort() {
|
||||||
|
if (!props.parentId && props.parentId !== 0) {
|
||||||
|
Message.warning('请先选择一个栏目')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hasChanged.value) {
|
||||||
|
Message.info('排序未发生变化')
|
||||||
|
visible.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ids = localChildren.value.map((x) => Number(x.id)).filter((x) => Number.isFinite(x))
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await categoryApi.sort({ siteId: store.siteId!, parentId: props.parentId, ids }).send()
|
||||||
|
Message.success('保存成功')
|
||||||
|
hasChanged.value = false
|
||||||
|
emit('saved')
|
||||||
|
visible.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, (v) => {
|
||||||
|
if (v) resetLocal()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sort-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0f4ff;
|
||||||
|
border-color: #c0d0ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽中:当前被拖拽的元素高亮 */
|
||||||
|
.sort-list :deep(.sort-item.sortable-chosen),
|
||||||
|
.sort-list :deep(.sort-item.sortable-drag) {
|
||||||
|
background: #fff7e6;
|
||||||
|
border-color: #fa8c16;
|
||||||
|
box-shadow: 0 6px 16px rgba(250, 140, 22, 0.4);
|
||||||
|
cursor: grabbing;
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
color: #fa8c16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽中:落点占位位置 */
|
||||||
|
.sort-list :deep(.sort-item.sortable-ghost) {
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px dashed #fa8c16;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #999;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-index {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #577afb;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-loading,
|
||||||
|
.sort-empty {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-tree-search-panel">
|
||||||
|
<div v-if="searchVisible" class="search-bar mt-8 mb-8">
|
||||||
|
<Input v-model="searchKeyword" :placeholder="searchPlaceholder" suffix="ios-search" search clearable
|
||||||
|
class="search-input" @on-search="onSearch" @on-clear="onClear" />
|
||||||
|
<Button v-if="showCloseButton" icon="md-close" @click="closeSearch" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tree-body">
|
||||||
|
<MyTree v-if="!treeLoading" ref="refMyTree" :autoClick="autoClick" :treeData="treeData"
|
||||||
|
:setting="treeSetting" :isCheck="checkable" @on-tree-click="treeLink"
|
||||||
|
@on-tree-check="(node: any) => emit('on-tree-check', node)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
/* UserBase GroupTreeSearchPanel 平移:去掉组类型特殊选中逻辑,默认选首棵树首个节点 */
|
||||||
|
import { ref, nextTick, useTemplateRef } from "vue";
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
import MyTree from '@/components/MyTree.vue'
|
||||||
|
|
||||||
|
const DEFAULT_CACHE_KEY = 'cms.category-tree.selected-node'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
treeApi: () => Promise<any[]>;
|
||||||
|
autoClick?: boolean;
|
||||||
|
autoSelect?: boolean;
|
||||||
|
defaultSearchVisible?: boolean;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
cacheKey?: string;
|
||||||
|
checkable?: boolean;
|
||||||
|
}>(), {
|
||||||
|
autoClick: false,
|
||||||
|
autoSelect: true,
|
||||||
|
defaultSearchVisible: false,
|
||||||
|
showCloseButton: false,
|
||||||
|
searchPlaceholder: '输入栏目名称搜索',
|
||||||
|
cacheKey: DEFAULT_CACHE_KEY,
|
||||||
|
checkable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'on-change': [treeNode: any]
|
||||||
|
'on-tree-data': [tree: any[]]
|
||||||
|
'on-tree-check': [treeNode: any]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const curTreeNode = defineModel<any>('curTreeNode', { default: {} })
|
||||||
|
|
||||||
|
const refMyTree = useTemplateRef('refMyTree')
|
||||||
|
const treeLoading = ref(false)
|
||||||
|
const treeData = ref<any[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const searchVisible = ref(props.defaultSearchVisible)
|
||||||
|
|
||||||
|
const treeSetting = ref({
|
||||||
|
view: { dblClickExpand: false, showIcon: false },
|
||||||
|
edit: { enable: false, showRemoveBtn: false, showRenameBtn: false, drag: false },
|
||||||
|
check: { enable: props.checkable, chkboxType: { Y: '', N: '' } },
|
||||||
|
callback: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTree(list: any[], depth = 1): any[] {
|
||||||
|
return (list || []).map((node) => {
|
||||||
|
const children = node.children;
|
||||||
|
const hasChildren = Array.isArray(children) && children.length > 0;
|
||||||
|
const formatted: any = { ...node, isParent: hasChildren };
|
||||||
|
if (depth === 1) formatted.open = true;
|
||||||
|
if (hasChildren) {
|
||||||
|
formatted.children = formatTree(children, depth + 1);
|
||||||
|
} else {
|
||||||
|
delete formatted.children;
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNodeById(list: any[], id?: number | null): any {
|
||||||
|
if (id == null) return null;
|
||||||
|
for (const node of list || []) {
|
||||||
|
if (Number(node.id) === Number(id)) return node;
|
||||||
|
const child = findNodeById(node.children || [], id);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedSelectedNodeId() {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(props.cacheKey);
|
||||||
|
if (!raw) return null;
|
||||||
|
const cached = JSON.parse(raw);
|
||||||
|
const id = Number(cached?.id);
|
||||||
|
return Number.isFinite(id) ? id : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSelectedNode(treeNode: any) {
|
||||||
|
if (!treeNode?.id) return;
|
||||||
|
sessionStorage.setItem(props.cacheKey, JSON.stringify({
|
||||||
|
id: Number(treeNode.id),
|
||||||
|
name: treeNode.name,
|
||||||
|
type: treeNode.type,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInitialSelectId(list: any[], preferredId?: number | null) {
|
||||||
|
const preferred = findNodeById(list, preferredId);
|
||||||
|
if (preferred) return preferred.id;
|
||||||
|
|
||||||
|
const cached = findNodeById(list, getCachedSelectedNodeId());
|
||||||
|
if (cached) return cached.id;
|
||||||
|
|
||||||
|
return list[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTree(preferredId?: number | null) {
|
||||||
|
treeLoading.value = true;
|
||||||
|
try {
|
||||||
|
const tree = await props.treeApi();
|
||||||
|
treeData.value = formatTree(tree || []);
|
||||||
|
emit('on-tree-data', cloneDeep(treeData.value));
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || '加载栏目树失败');
|
||||||
|
} finally {
|
||||||
|
treeLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!treeData.value.length) return;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
// 默认自动选中首个可用节点(触发 on-change)
|
||||||
|
if (props.autoSelect) {
|
||||||
|
const selectId = resolveInitialSelectId(treeData.value, preferredId);
|
||||||
|
if (selectId != null) {
|
||||||
|
refMyTree.value?.reloadTree(selectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTree(selectId?: number) {
|
||||||
|
await loadTree(selectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeLink(treeNode: any) {
|
||||||
|
const copyNode = cloneDeep(treeNode)
|
||||||
|
copyNode.children = [];
|
||||||
|
curTreeNode.value = copyNode;
|
||||||
|
cacheSelectedNode(copyNode);
|
||||||
|
emit('on-change', copyNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
refMyTree.value?.search(searchKeyword.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClear() {
|
||||||
|
searchKeyword.value = '';
|
||||||
|
refMyTree.value?.search('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch() {
|
||||||
|
searchVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
searchVisible.value = false;
|
||||||
|
onClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadTree(id?: number) {
|
||||||
|
refMyTree.value?.reloadTree(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNode(id: number, isClick = true) {
|
||||||
|
refMyTree.value?.selectNode(id, isClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNode(id: number) {
|
||||||
|
refMyTree.value?.removeNode(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameNode(id: number, name: string) {
|
||||||
|
refMyTree.value?.renameNode(id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeById(id: number) {
|
||||||
|
return refMyTree.value?.getNodeById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNote(pid: number, id: number, name: string, isParent?: boolean, children?: any[]) {
|
||||||
|
refMyTree.value?.addNote(pid, id, name, isParent, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTree(open: boolean) {
|
||||||
|
refMyTree.value?.openTree(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchByKeyword(keyword: string) {
|
||||||
|
searchKeyword.value = keyword;
|
||||||
|
onSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearByKeyword() {
|
||||||
|
searchKeyword.value = '';
|
||||||
|
onClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTree()
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openSearch,
|
||||||
|
closeSearch,
|
||||||
|
onSearch,
|
||||||
|
onClear,
|
||||||
|
searchByKeyword,
|
||||||
|
clearByKeyword,
|
||||||
|
loadTree,
|
||||||
|
refreshTree,
|
||||||
|
reloadTree,
|
||||||
|
selectNode,
|
||||||
|
removeNode,
|
||||||
|
renameNode,
|
||||||
|
getNodeById,
|
||||||
|
addNote,
|
||||||
|
openTree,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.group-tree-search-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-body :deep(.ztree) {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<Modal v-model="isModal" @on-visible-change="showChange" :title="title">
|
||||||
|
<Alert type="warning" class="mb-1" show-icon>
|
||||||
|
<template #icon>
|
||||||
|
<Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" slot="icon" :size="28"></Icon>
|
||||||
|
</template>
|
||||||
|
<div v-if="msg" :class="['message-box', info ? '' : 'mt-10 mb-10 ml-10']">{{ msg }}</div>
|
||||||
|
<template #desc v-if="info" slot="desc">{{ info }}</template>
|
||||||
|
</Alert>
|
||||||
|
<template #footer>
|
||||||
|
<Button type="primary" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button>
|
||||||
|
<Button :loading="cancleLoading" @click="onCancleClick">{{ closeText }}</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { Modal, Button, Alert, Icon } from "view-ui-plus";
|
||||||
|
|
||||||
|
const isModal = ref(false),
|
||||||
|
okLoading = ref(false),
|
||||||
|
cancleLoading = ref(false);
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string;
|
||||||
|
msg: string;
|
||||||
|
info?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
confirmText?: string;
|
||||||
|
closeText?: string;
|
||||||
|
confirmFn: any;
|
||||||
|
closeFn: any;
|
||||||
|
destroy: Function;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: "提示",
|
||||||
|
isLoading: true,
|
||||||
|
confirmText: "确认",
|
||||||
|
closeText: "取消",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(function () {
|
||||||
|
setTimeout(() => {
|
||||||
|
isModal.value = true;
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onOkClick = async () => {
|
||||||
|
if (!!props.isLoading) okLoading.value = true;
|
||||||
|
const { onOk, reslove } = props.confirmFn;
|
||||||
|
await Promise.resolve(onOk());
|
||||||
|
isModal.value = false;
|
||||||
|
okLoading.value = false;
|
||||||
|
reslove(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancleClick = async () => {
|
||||||
|
if (!!props.isLoading) cancleLoading.value = true;
|
||||||
|
const { onCancel, reslove } = props.closeFn;
|
||||||
|
if (typeof onCancel !== "undefined") await Promise.resolve(onCancel());
|
||||||
|
cancleLoading.value = false;
|
||||||
|
isModal.value = false;
|
||||||
|
reslove(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showChange = (state: boolean) => {
|
||||||
|
if (!state) {
|
||||||
|
props.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style></style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { MyConfirm } from "@/types/Basics";
|
||||||
|
import confirm from "./Confirm.vue";
|
||||||
|
|
||||||
|
import { createApp } from "vue";
|
||||||
|
export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onCancel }: MyConfirm) => {
|
||||||
|
return new Promise<boolean>((reslove, reject) => {
|
||||||
|
const instance = createApp(confirm, {
|
||||||
|
title,
|
||||||
|
msg,
|
||||||
|
info,
|
||||||
|
confirmText,
|
||||||
|
closeText,
|
||||||
|
isLoading,
|
||||||
|
confirmFn: {
|
||||||
|
onOk,
|
||||||
|
reslove,
|
||||||
|
},
|
||||||
|
closeFn: {
|
||||||
|
onCancel,
|
||||||
|
reslove,
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
closeUnmount();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建一个节点
|
||||||
|
const vnode = document.createElement("div");
|
||||||
|
// 添加到body元素上
|
||||||
|
document.body.appendChild(vnode);
|
||||||
|
// 实例挂载到节点上
|
||||||
|
instance.mount(vnode);
|
||||||
|
// 关闭状态
|
||||||
|
// 注册实例并移除节点
|
||||||
|
function closeUnmount() {
|
||||||
|
setTimeout(() => {
|
||||||
|
instance.unmount();
|
||||||
|
document.body.removeChild(vnode);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div :id="domId" :ref="domId" class="ztree"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
/* UserBase MyTree 原样平移(jQuery zTree 封装);全局 _ 改为 npm lodash 引入 */
|
||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
treeData: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
setting: {
|
||||||
|
type: Object,
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
autoClick: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
isCheck: {
|
||||||
|
typeof: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
domId: "",
|
||||||
|
treeSetting: _.cloneDeep(this.setting),
|
||||||
|
treeObj: null,
|
||||||
|
highlightNodes: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch("treeData", val => {
|
||||||
|
if (val) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.initTree(this.treeData, this.domId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.domId = this.uuid();
|
||||||
|
if (this.treeData) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.initTree(this.treeData, this.domId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
uuid() {
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||||
|
var r = (Math.random() * 16) | 0,
|
||||||
|
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reloadTree(nodeId) {
|
||||||
|
this.initTree(this.treeData, this.domId);
|
||||||
|
if (nodeId != null) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.selectNode(nodeId, true); // 第二个参数 true 会 emit on-tree-click
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initTree(_data, _domId) {
|
||||||
|
const _this = this;
|
||||||
|
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
|
||||||
|
if (_this.isCheck) {
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(treeId);
|
||||||
|
// 第4参 callbackFlag=true:点击文字切换勾选时同步触发 onCheck,与点击勾选框行为一致
|
||||||
|
treeObj.checkNode(treeNode, !treeNode.checked, true, true);
|
||||||
|
}
|
||||||
|
_this.$emit("on-tree-click", treeNode);
|
||||||
|
};
|
||||||
|
|
||||||
|
_this.treeSetting.callback.beforeDrop = (treeId, treeNodes, targetNode, moveType) => {
|
||||||
|
if (moveType == "inner") {
|
||||||
|
// 移除限制,允许叶子节点接受子节点
|
||||||
|
} else {
|
||||||
|
if (targetNode.pid === 0) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
_this.treeSetting.callback.beforeDrag = (treeId, treeNodes) => {
|
||||||
|
return treeNodes[0].drag !== false;
|
||||||
|
};
|
||||||
|
|
||||||
|
_this.treeSetting.callback.beforeClick = (treeId, treeNode, clickFlag) => {
|
||||||
|
return !(treeNode.click === false);
|
||||||
|
};
|
||||||
|
|
||||||
|
_this.treeSetting.callback.onDrop = (event, treeId, treeNodes, targetNode, moveType) => {
|
||||||
|
_this.$emit("on-tree-drag", { treeNodes, targetNode, moveType });
|
||||||
|
};
|
||||||
|
|
||||||
|
_this.treeSetting.callback.onCheck = (event, treeId, treeNode) => {
|
||||||
|
_this.$emit("on-tree-check", treeNode);
|
||||||
|
};
|
||||||
|
|
||||||
|
_this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data);
|
||||||
|
if (_this.autoClick) {
|
||||||
|
if (Array.isArray(this.treeData)) {
|
||||||
|
let id = this.treeData[0] && this.treeData[0].id;
|
||||||
|
if (id != null) _this.selectNode(id);
|
||||||
|
} else if (this.treeData && this.treeData.id != null) {
|
||||||
|
let id = this.treeData.id;
|
||||||
|
_this.selectNode(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectNode(id, isClick = true) {
|
||||||
|
const _domId = this.domId;
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||||
|
if (node == null) return false;
|
||||||
|
treeObj.selectNode(node);
|
||||||
|
treeObj.expandNode(node, true, false, false);
|
||||||
|
treeObj.selectNode(node);
|
||||||
|
if (isClick) this.$emit("on-tree-click", node);
|
||||||
|
},
|
||||||
|
openTree(open) {
|
||||||
|
const _domId = this.domId;
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
treeObj.expandAll(open);
|
||||||
|
},
|
||||||
|
removeNode(id) {
|
||||||
|
const _domId = this.domId;
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||||
|
if (node == null) return;
|
||||||
|
var pnode = node.getParentNode();
|
||||||
|
treeObj.removeNode(node);
|
||||||
|
if (pnode) {
|
||||||
|
pnode.isParent = true;
|
||||||
|
treeObj.updateNode(pnode);
|
||||||
|
this.selectNode(pnode.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getNodeById(id) {
|
||||||
|
const _domId = this.domId;
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
getCheckedNodes() {
|
||||||
|
const _domId = this.domId;
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
if (!treeObj) return [];
|
||||||
|
return treeObj.getCheckedNodes(true);
|
||||||
|
},
|
||||||
|
// 仅更新节点勾选状态,不级联、不触发 onCheck 回调
|
||||||
|
checkNodeById(id, checked) {
|
||||||
|
const _domId = this.domId;
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
if (!treeObj) return;
|
||||||
|
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||||
|
if (node) treeObj.checkNode(node, !!checked, false);
|
||||||
|
},
|
||||||
|
renameNode(id, name) {
|
||||||
|
const _domId = this.domId;
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||||
|
if (node == null) return;
|
||||||
|
node.name = name;
|
||||||
|
treeObj.updateNode(node);
|
||||||
|
},
|
||||||
|
addNote(pid, id, name, isParent, children) {
|
||||||
|
const _domId = this.domId;
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
var pnode = treeObj.getNodesByFilter(node => node.id == pid, true);
|
||||||
|
if (pnode == null) return;
|
||||||
|
var newNode = { pid, id, name, isParent };
|
||||||
|
if (children && children.length) newNode.children = children;
|
||||||
|
treeObj.addNodes(pnode, -1, newNode);
|
||||||
|
},
|
||||||
|
setHighlight(node, on = true) {
|
||||||
|
const a = $('#' + node.tId + '_a');
|
||||||
|
if (on) a.addClass('ztree-search-highlight');
|
||||||
|
else a.removeClass('ztree-search-highlight');
|
||||||
|
},
|
||||||
|
clearHighlight() {
|
||||||
|
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
|
||||||
|
this.highlightNodes = [];
|
||||||
|
},
|
||||||
|
search(keyword) {
|
||||||
|
const _domId = this.domId;
|
||||||
|
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
if (!treeObj) return;
|
||||||
|
this.clearHighlight();
|
||||||
|
const kw = (keyword || '').trim().toLowerCase();
|
||||||
|
if (!kw) return;
|
||||||
|
const matchNodes = treeObj.getNodesByFilter(
|
||||||
|
(node) => node.name && node.name.toLowerCase().includes(kw),
|
||||||
|
false
|
||||||
|
) || [];
|
||||||
|
if (!matchNodes.length) {
|
||||||
|
if (this.$Message && this.$Message.info) this.$Message.info('未搜索到匹配栏目');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matchNodes.forEach(n => {
|
||||||
|
let p = n;
|
||||||
|
while (p) {
|
||||||
|
treeObj.expandNode(p, true, false, false);
|
||||||
|
p = p.getParentNode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.highlightNodes = matchNodes;
|
||||||
|
matchNodes.forEach(n => this.setHighlight(n, true));
|
||||||
|
this.selectNode(matchNodes[0].id, true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ztree-search-highlight {
|
||||||
|
background-color: #ff6600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ztree li a.curSelectedNode {
|
||||||
|
background-color: #ffe4bb !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
|
||||||
|
* header 左侧「←学校列表」+ 校名徽标/快速切换下拉;面包屑首段=校名;
|
||||||
|
* 面包屑栏右侧「站点切换(校内主站+院系子站)+ 预览门户」承接院系子站维度。
|
||||||
|
* 菜单/高亮/子页面面包屑逻辑与原 layout 一致,布局数据来自 store.session。 */
|
||||||
|
import { ref, computed, watch, onBeforeMount } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
|
import { portalBase } from "@/api/module/cms";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useCmsStore();
|
||||||
|
|
||||||
|
const isPanel = ref(false);
|
||||||
|
const currPath = ref<string>("");
|
||||||
|
const navPath = ref<any[]>([]);
|
||||||
|
|
||||||
|
const baseHost = computed(() => config.baseHost);
|
||||||
|
const ssoHost = computed(() => config.ssoHost);
|
||||||
|
const appName = computed(() => "云海CMS");
|
||||||
|
const userInfo = computed(() => ({
|
||||||
|
name: store.session.baseUserName || "管理员",
|
||||||
|
avatar: store.session.baseUserAvatar,
|
||||||
|
}));
|
||||||
|
const isAlone = computed(() => store.session.isAlone);
|
||||||
|
const modList = computed(() => store.session.modList || []);
|
||||||
|
|
||||||
|
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
||||||
|
|
||||||
|
/** 当前学校 + 校内站点(主站在前,切换下拉用) */
|
||||||
|
const school = computed(() => store.currentSchool);
|
||||||
|
const schoolSites = computed(() => store.schoolSites);
|
||||||
|
/** 快速切换学校候选 = 可管理顶层站点(主站) */
|
||||||
|
const schoolOptions = computed(() =>
|
||||||
|
((store.user?.sites || []) as any[]).filter((s: any) => !s.parentSiteId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const flaten = (tree: any[], pid = 0): any[] => {
|
||||||
|
const arr: any[] = [];
|
||||||
|
tree.forEach((element: any) => {
|
||||||
|
const { id, name, modId } = element;
|
||||||
|
// 平台配置的 path 格式不可控,统一解析为内部路由 path(名称/modId 兜底)。
|
||||||
|
// 解析结果回写原对象:模板高亮(citem.page == currPath)与 linkMenu 消费的是
|
||||||
|
// 原始 children,旧扁平路由下 raw page 与解析结果恰好相等,学校前缀后必须显式回写
|
||||||
|
const resolved = resolveMenuPage(element.page, element.modId ?? element.id, element.name);
|
||||||
|
if (resolved.path) {
|
||||||
|
element.page = resolved.path;
|
||||||
|
}
|
||||||
|
element.external = resolved.external;
|
||||||
|
arr.push({ id, name, modId, page: resolved.path, external: resolved.external, pid });
|
||||||
|
if (element.children && element.children.length) {
|
||||||
|
arr.push(...flaten(element.children, id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuArr = computed(() => flaten(modList.value));
|
||||||
|
|
||||||
|
const routeChange = (to: any) => {
|
||||||
|
const meta: any = to.meta || {};
|
||||||
|
const { modId, title, subOf } = meta;
|
||||||
|
const navArr: any[] = [];
|
||||||
|
|
||||||
|
// 侧边栏子菜单组自动展开:当前高亮落在某组子项时展开该组(改善直达体验)
|
||||||
|
const expandGroupOf = (page: string) => {
|
||||||
|
if (!page) return;
|
||||||
|
modList.value.forEach((m: any) => {
|
||||||
|
if (m.children?.some((c: any) => c.page === page)) {
|
||||||
|
m.childState = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 列表子页面(docs/09 §3.2,如新增/编辑文章):面包屑挂到所属列表下、
|
||||||
|
// 侧边栏高亮锁定列表项,优先级高于本页菜单匹配。
|
||||||
|
// subOf = 所属列表的业务子路径(/article);菜单解析后带学校前缀,按后缀匹配,modId 兜底
|
||||||
|
if (subOf) {
|
||||||
|
const parent =
|
||||||
|
menuArr.value.find((x: any) => x.page && (x.page === subOf || x.page.endsWith(subOf))) ||
|
||||||
|
menuArr.value.find((x: any) => x.modId === subOf);
|
||||||
|
if (parent) {
|
||||||
|
currPath.value = parent.page || "";
|
||||||
|
expandGroupOf(currPath.value);
|
||||||
|
if (parent.pid) {
|
||||||
|
const gp = menuArr.value.find((x: any) => x.id === parent.pid);
|
||||||
|
if (gp) navArr.push({ name: gp.name });
|
||||||
|
}
|
||||||
|
navArr.push({ name: parent.name, page: parent.page });
|
||||||
|
navArr.push({ name: title });
|
||||||
|
navPath.value = navArr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
|
||||||
|
// 匹配不到再退回 modId 对照
|
||||||
|
const find: any =
|
||||||
|
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
|
||||||
|
menuArr.value.find((x: any) => x.modId === modId);
|
||||||
|
if (find) {
|
||||||
|
currPath.value = find.page || "";
|
||||||
|
expandGroupOf(currPath.value);
|
||||||
|
if (find.pid) {
|
||||||
|
const pfind = menuArr.value.find((x: any) => x.id === find.pid);
|
||||||
|
if (pfind) navArr.push({ name: pfind.name });
|
||||||
|
}
|
||||||
|
navArr.push({ name: find.name || title, page: find.page });
|
||||||
|
navPath.value = navArr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modId != null) {
|
||||||
|
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
|
||||||
|
if (r) {
|
||||||
|
currPath.value = r.path;
|
||||||
|
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currPath.value = "";
|
||||||
|
navPath.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkMenu = (item: any) => {
|
||||||
|
const page: string = item?.page || "";
|
||||||
|
if (!page) {
|
||||||
|
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
|
||||||
|
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 平台配置的外部链接模块:整页跳转
|
||||||
|
if (item?.external) {
|
||||||
|
window.location.href = page;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (page === route.path) {
|
||||||
|
router.go(0);
|
||||||
|
} else {
|
||||||
|
router.push(page);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkUserInfo = () => {
|
||||||
|
window.location.href = baseHost.value + "/v/userinfo";
|
||||||
|
};
|
||||||
|
const logout = () => {
|
||||||
|
let url = window.location.href;
|
||||||
|
url = "/cas/logout?service=" + url;
|
||||||
|
window.location.href = ssoHost.value + url;
|
||||||
|
};
|
||||||
|
const childChange = (index: number) => {
|
||||||
|
const arr = modList.value || [];
|
||||||
|
if (arr[index]) {
|
||||||
|
arr[index].childState = !arr[index].childState;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
|
||||||
|
* 兜底保留 modId 对照 */
|
||||||
|
const getMenuIcon = (item?: any): string => {
|
||||||
|
const page: string = item?.page || "";
|
||||||
|
if (page === "/article" || page.startsWith("/article")) return "ios-document";
|
||||||
|
if (page === "/category") return "ios-list-box";
|
||||||
|
if (page === "/banner") return "ios-images";
|
||||||
|
if (page === "/link") return "ios-link";
|
||||||
|
if (page === "/attachment") return "ios-folder-open";
|
||||||
|
if (page === "/site") return "ios-globe";
|
||||||
|
switch (item?.modId ?? item?.id) {
|
||||||
|
case 1: return "ios-document";
|
||||||
|
case 2: return "ios-list-box";
|
||||||
|
case 3: return "ios-images";
|
||||||
|
case 4: return "ios-link";
|
||||||
|
case 5: return "ios-folder-open";
|
||||||
|
case 6: return "ios-globe";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===== 学校上下文(docs/09 §3.3)===== */
|
||||||
|
function goWorkbench() {
|
||||||
|
router.push("/workbench");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快速切换学校:守卫负责恢复上下文,进入后落在文章列表 */
|
||||||
|
function onSchoolChange(code: string) {
|
||||||
|
if (!code || code === store.schoolCode) return;
|
||||||
|
router.push(`/sch/${code}/article`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSiteChange(siteId: number) {
|
||||||
|
const site = schoolSites.value.find((s: any) => s.id === siteId);
|
||||||
|
if (site) {
|
||||||
|
store.setCurrentSite({ id: site.id, code: site.code, name: site.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPortal() {
|
||||||
|
if (!store.currentSite) return;
|
||||||
|
if (config.isMock) {
|
||||||
|
// mock 模式:SPA 内部门户预览(免依赖后端 Thymeleaf 门户)
|
||||||
|
router.push(`/sch/${store.schoolCode}/portal-preview`);
|
||||||
|
} else {
|
||||||
|
window.open(`${portalBase(store.currentSite.code)}/`, "_blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文 */
|
||||||
|
watch(
|
||||||
|
() => store.user,
|
||||||
|
u => {
|
||||||
|
if (!u) return;
|
||||||
|
const code = route.params.schCode as string;
|
||||||
|
if (route.path.startsWith("/sch/") && !school.value) {
|
||||||
|
const sites = u.sites || [];
|
||||||
|
const main = sites.find((s: any) => s.code === code);
|
||||||
|
if (!main) {
|
||||||
|
router.replace("/workbench");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.enterSchool(
|
||||||
|
{
|
||||||
|
schId: main.schId,
|
||||||
|
schName: main.schName,
|
||||||
|
mainSiteId: main.id,
|
||||||
|
mainSiteCode: main.code,
|
||||||
|
mainSiteName: main.name,
|
||||||
|
},
|
||||||
|
sites.filter((s: any) => s.id === main.id || s.parentSiteId === main.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
routeChange(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => routeChange(route)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 深链启动期:学校上下文/菜单晚于首次 routeChange 到达,就绪后重算面包屑与高亮
|
||||||
|
watch(
|
||||||
|
() => [store.currentSchool?.mainSiteCode, store.session.modList.length],
|
||||||
|
() => routeChange(route)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout-wrap">
|
||||||
|
<header class="header-container fix">
|
||||||
|
<div class="fl align-middle icon-box">
|
||||||
|
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="isPanel = !isPanel">
|
||||||
|
<i class="iconfont icon-apps"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="delimiter fl align-middle"></div>
|
||||||
|
<a class="fl align-middle back-workbench" href="javascript:void(0);" title="返回学校列表" @click="goWorkbench">
|
||||||
|
<Icon type="ios-arrow-back" />
|
||||||
|
学校列表
|
||||||
|
</a>
|
||||||
|
<div class="delimiter fl align-middle"></div>
|
||||||
|
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||||
|
<div class="delimiter fl align-middle"></div>
|
||||||
|
<!-- 校名徽标 + 快速切换(当前学校常驻 header) -->
|
||||||
|
<div class="school-badge fl align-middle" v-if="school">
|
||||||
|
<Icon type="ios-school" :size="16" />
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
:model-value="school.mainSiteCode"
|
||||||
|
class="school-select"
|
||||||
|
transfer
|
||||||
|
@on-change="onSchoolChange"
|
||||||
|
>
|
||||||
|
<Option v-for="s in schoolOptions" :key="s.id" :value="s.code">
|
||||||
|
{{ s.schName || s.name }}
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="header-aside fr">
|
||||||
|
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
||||||
|
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||||||
|
{{ userInfo.name }}
|
||||||
|
</a>
|
||||||
|
<div class="delimiter fl align-middle"></div>
|
||||||
|
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
|
||||||
|
<i class="iconfont icon-logout"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="left-menu" v-if="!isAlone">
|
||||||
|
<div class="menu-top-box"></div>
|
||||||
|
<ul>
|
||||||
|
<template v-for="(item, index) in modList" :key="'menu_li_' + index">
|
||||||
|
<template v-if="item.children && item.children.length > 0">
|
||||||
|
<li>
|
||||||
|
<a href="javascript:;" @click="childChange(index)">
|
||||||
|
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
|
||||||
|
<Icon v-if="getMenuIcon(item)" :type="getMenuIcon(item)" />
|
||||||
|
{{ item.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<transition name="fade_child_ul">
|
||||||
|
<div class="child-item-wrap" v-show="item.childState">
|
||||||
|
<li
|
||||||
|
:class="citem.page == currPath ? 'active child-item' : 'child-item'"
|
||||||
|
v-for="citem in item.children"
|
||||||
|
:key="citem.id"
|
||||||
|
>
|
||||||
|
<a href="javascript:;" @click="linkMenu(citem)">
|
||||||
|
{{ citem.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
<li v-else :class="item.page == currPath ? 'active' : ''">
|
||||||
|
<a href="javascript:;" @click="linkMenu(item)">
|
||||||
|
<Icon v-if="getMenuIcon(item)" :type="getMenuIcon(item)" />
|
||||||
|
{{ item.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
<div class="clear-box"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="path-nav" :style="pathNavStyle">
|
||||||
|
<Breadcrumb separator=">">
|
||||||
|
<!-- 面包屑首段 = 学校上下文(常驻) -->
|
||||||
|
<BreadcrumbItem v-if="school">
|
||||||
|
<Icon type="ios-school" />
|
||||||
|
{{ school.schName || school.mainSiteName }}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<template v-for="(item, index) in navPath" :key="index">
|
||||||
|
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
|
||||||
|
{{ item.name }}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem v-else>{{ item.name }}</BreadcrumbItem>
|
||||||
|
</template>
|
||||||
|
</Breadcrumb>
|
||||||
|
<!-- 站点上下文(校内:主站 + 院系子站;切换后各页面即时跟随) -->
|
||||||
|
<div class="cms-site-bar fr">
|
||||||
|
<span class="site-label">站点:</span>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
:model-value="store.currentSite?.id"
|
||||||
|
:placeholder="schoolSites.length ? '选择站点' : '暂无可管理站点'"
|
||||||
|
style="width: 200px"
|
||||||
|
transfer
|
||||||
|
@on-change="onSiteChange"
|
||||||
|
>
|
||||||
|
<Option v-for="opt in schoolSites" :key="opt.id" :value="opt.id">
|
||||||
|
{{ opt.name }}{{ opt.parentSiteId ? "(院系)" : "" }}
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
<Button size="small" type="primary" ghost style="margin-left: 10px" @click="openPortal">
|
||||||
|
<Icon type="ios-eye" />
|
||||||
|
预览门户
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="fade_apps">
|
||||||
|
<div class="app-panel" v-if="isPanel">
|
||||||
|
<div class="tips-box"></div>
|
||||||
|
<ul class="fix">
|
||||||
|
<li class="fl" v-for="(item, index) in []" :key="index">
|
||||||
|
<a :href="item.url" target="_blank" rel="noopener noreferrer">
|
||||||
|
<div class="icon-box">
|
||||||
|
<img :src="item.icon" alt="" />
|
||||||
|
</div>
|
||||||
|
<p class="text-ellipsis">{{ item.name }}</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="link-panel">
|
||||||
|
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
|
||||||
|
全部应用
|
||||||
|
<i>→</i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<div class="base-wrap-main" :class="{ 'alone-page': isAlone }">
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<router-view v-if="store.currentSite" />
|
||||||
|
<div v-else class="no-site-tip">当前学校暂无可管理站点,请联系平台管理员在学校工作台添加。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
/* 返回学校列表 + 校名徽标:header 常驻学校上下文(docs/09 §3.3) */
|
||||||
|
.back-workbench {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.school-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
.school-select {
|
||||||
|
width: 180px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 站点切换条:挂在面包屑条右侧,沿用 path-nav 白底视觉 */
|
||||||
|
.cms-site-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 56px;
|
||||||
|
margin-right: 24px;
|
||||||
|
|
||||||
|
.site-label {
|
||||||
|
color: #777;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.no-site-tip {
|
||||||
|
height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, withDefaults } from "vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prefix?: string;
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
hoverColor?: string;
|
||||||
|
size?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
prefix: "icon",
|
||||||
|
name: "",
|
||||||
|
color: "#000",
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHover = ref(false);
|
||||||
|
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
|
||||||
|
const svgStyle = computed(() => {
|
||||||
|
if (!!props.size) return { width: props.size + "px", height: props.size + "px" };
|
||||||
|
else return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const compColor = computed(() => {
|
||||||
|
if (!props.hoverColor) return props.color;
|
||||||
|
else {
|
||||||
|
return !!isHover.value ? props.hoverColor : props.color;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
@mouseover="isHover = true"
|
||||||
|
@mouseout="isHover = false"
|
||||||
|
aria-hidden="true"
|
||||||
|
:color="compColor"
|
||||||
|
:style="svgStyle"
|
||||||
|
>
|
||||||
|
<use :xlink:href="symbolId" :fill="compColor" :color="compColor" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { ThemeVo } from "@/types/cms";
|
||||||
|
import { effectiveValue, invalidParamKeys, FormModelValue } from "@/utils/themeParamForm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题参数微调表单(方案 §6.3):按 theme.json params schema 动态生成。
|
||||||
|
* color→ColorPicker、select→Select、image→Input(手填 /cms/upload/ 路径,走附件上传后回填)、
|
||||||
|
* text→Input。父级 v-model 绑定 FormModelValue(声明过的 key,值已兜底为默认)。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
theme?: ThemeVo;
|
||||||
|
modelValue: FormModelValue;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", v: FormModelValue): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const themeName = computed(() => props.theme?.title || props.theme?.name || "");
|
||||||
|
const params = computed(() => props.theme?.params || []);
|
||||||
|
const badKeys = computed(() =>
|
||||||
|
props.theme ? invalidParamKeys(props.theme, props.modelValue) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
function setValue(key: string, value: string) {
|
||||||
|
emit("update:modelValue", { ...props.modelValue, [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue(key: string): string {
|
||||||
|
return effectiveValue(props.theme?.params.find(p => p.key === key), props.modelValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBad(key: string): boolean {
|
||||||
|
return badKeys.value.includes(key);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!theme" class="empty-tip">请先选择皮肤包</div>
|
||||||
|
<Form v-else :label-width="90" class="theme-param-form">
|
||||||
|
<div class="param-head">
|
||||||
|
参数微调 · {{ themeName }}
|
||||||
|
<span v-if="badKeys.length" class="param-warn">
|
||||||
|
有 {{ badKeys.length }} 个非法值未生效(将回退皮肤默认)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<FormItem v-for="p in params" :key="p.key" :label="p.label">
|
||||||
|
<!-- color:色板 -->
|
||||||
|
<ColorPicker
|
||||||
|
v-if="p.type === 'color'"
|
||||||
|
:model-value="displayValue(p.key)"
|
||||||
|
recommend
|
||||||
|
@on-change="(c: string) => setValue(p.key, c)"
|
||||||
|
/>
|
||||||
|
<!-- select:下拉 -->
|
||||||
|
<Select
|
||||||
|
v-else-if="p.type === 'select'"
|
||||||
|
:model-value="displayValue(p.key)"
|
||||||
|
style="width: 260px"
|
||||||
|
@on-change="(v: string) => setValue(p.key, v)"
|
||||||
|
>
|
||||||
|
<Option v-for="o in p.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||||
|
</Select>
|
||||||
|
<!-- image:附件路径(上传后回填 /cms/upload/**) -->
|
||||||
|
<Input
|
||||||
|
v-else-if="p.type === 'image'"
|
||||||
|
:model-value="displayValue(p.key)"
|
||||||
|
placeholder="/cms/upload/cms/... (仅接受 /cms/upload/** 路径)"
|
||||||
|
style="width: 320px"
|
||||||
|
@on-change="(v: string) => setValue(p.key, v)"
|
||||||
|
/>
|
||||||
|
<!-- text:短文本 -->
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
:model-value="displayValue(p.key)"
|
||||||
|
:maxlength="256"
|
||||||
|
style="width: 320px"
|
||||||
|
@on-change="(v: string) => setValue(p.key, v)"
|
||||||
|
/>
|
||||||
|
<span v-if="isBad(p.key)" class="bad-mark">当前值非法({{ p.type }} 类型校验失败)</span>
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.empty-tip {
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.param-head {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.param-warn {
|
||||||
|
color: #ed4014;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.bad-mark {
|
||||||
|
color: #ed4014;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ThemeVo } from "@/types/cms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题卡片单选(方案 §6.3):建站向导①与站点编辑共用。
|
||||||
|
* 展示 screenshot + 名称/描述;选中高亮;卡片布局在父级控制宽度。
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
themes: ThemeVo[];
|
||||||
|
/** 当前选中主题名 */
|
||||||
|
modelValue?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", name: string): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="theme-picker">
|
||||||
|
<div
|
||||||
|
v-for="t in themes"
|
||||||
|
:key="t.name"
|
||||||
|
class="theme-card"
|
||||||
|
:class="{ selected: modelValue === t.name }"
|
||||||
|
@click="emit('update:modelValue', t.name)"
|
||||||
|
>
|
||||||
|
<img class="theme-shot" :src="t.screenshotUrl" :alt="t.title" loading="lazy" />
|
||||||
|
<div class="theme-info">
|
||||||
|
<div class="theme-title">
|
||||||
|
{{ t.title }}
|
||||||
|
<span class="theme-name">{{ t.name }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="theme-desc">{{ t.description }}</p>
|
||||||
|
</div>
|
||||||
|
<i v-if="modelValue === t.name" class="theme-check">✓</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.theme-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.theme-card {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid #e5e6eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
&:hover {
|
||||||
|
border-color: #a9c6f5;
|
||||||
|
}
|
||||||
|
&.selected {
|
||||||
|
border-color: #2d8cf0;
|
||||||
|
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.theme-shot {
|
||||||
|
width: 100%;
|
||||||
|
height: 118px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: #f0f2f5;
|
||||||
|
}
|
||||||
|
.theme-info {
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
}
|
||||||
|
.theme-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.theme-name {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.theme-desc {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.theme-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2d8cf0;
|
||||||
|
color: #fff;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { themePreviewUrl } from "@/api/module/cms";
|
||||||
|
import { FormModelValue } from "@/utils/themeParamForm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题预览框(方案 §5.5/§6.3):iframe 加载 /api/theme/preview(同源 CAS 会话,
|
||||||
|
* no-store),参数变更由父级 debounce 后更新 src。内容源站点不存在时提示。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 预览内容源站点编码(取该站栏目/文章/轮播渲染) */
|
||||||
|
siteCode?: string;
|
||||||
|
theme?: string;
|
||||||
|
themeParams?: FormModelValue;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const src = computed(() => {
|
||||||
|
if (!props.siteCode || !props.theme) return "";
|
||||||
|
return themePreviewUrl(props.siteCode, props.theme, props.themeParams || {});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="theme-preview">
|
||||||
|
<template v-if="!siteCode || !theme">
|
||||||
|
<div class="preview-empty">
|
||||||
|
无可用内容源站点 —— 预览需要借用一个已有站点的内容
|
||||||
|
<p class="hint">请先创建/启用一个站点(或初始化演示数据)后再预览</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<iframe
|
||||||
|
v-else
|
||||||
|
:key="src"
|
||||||
|
:src="src"
|
||||||
|
class="preview-frame"
|
||||||
|
title="主题预览"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.theme-preview {
|
||||||
|
width: 100%;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 560px;
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.preview-empty {
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
.hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import router from "@/router";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
// @ts-ignore
|
||||||
|
import ViewUIPlus from "view-ui-plus";
|
||||||
|
import mittBus from "@/utils/mitt";
|
||||||
|
import "view-ui-plus/dist/styles/viewuiplus.css";
|
||||||
|
import "@/assets/styles/main.scss";
|
||||||
|
import "@/assets/styles/layout.css";
|
||||||
|
import "@/assets/styles/icons.css";
|
||||||
|
import directive from "@/utils/directive/index";
|
||||||
|
import "virtual:uno.css";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
app.use(ViewUIPlus);
|
||||||
|
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
|
import utils from "@/utils/index";
|
||||||
|
app.config.globalProperties.$conf = config;
|
||||||
|
app.config.globalProperties.$utils = utils;
|
||||||
|
app.config.globalProperties.$bus = mittBus;
|
||||||
|
|
||||||
|
directive(app);
|
||||||
|
import "virtual:svg-icons-register";
|
||||||
|
app.mount("#app");
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";
|
||||||
|
import { routes } from "./routes";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校上下文守卫(docs/09 §3.3):
|
||||||
|
* - /sch/:schCode 要求该主站编码在当前用户可管理站点内;
|
||||||
|
* - 深链直达且上下文未对准时,按 user.sites 自动恢复学校/站点上下文;
|
||||||
|
* - 启动引导期(App.vue 未装配 user)放行,由 SchoolLayout 数据就绪后兜底校验;
|
||||||
|
* - 校级用户不可管理的学校一律回工作台。
|
||||||
|
*/
|
||||||
|
router.beforeEach(to => {
|
||||||
|
if (!to.path.startsWith("/sch/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const store = useCmsStore();
|
||||||
|
const schCode = to.params.schCode as string;
|
||||||
|
if (!schCode) {
|
||||||
|
return "/workbench";
|
||||||
|
}
|
||||||
|
if (store.currentSchool?.mainSiteCode === schCode) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 启动引导期:user 尚未装载,放行给 SchoolLayout 兜底
|
||||||
|
if (!store.user) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const sites = store.user.sites || [];
|
||||||
|
const hit = sites.find(s => s.code === schCode);
|
||||||
|
if (!hit) {
|
||||||
|
return "/workbench";
|
||||||
|
}
|
||||||
|
const main = hit.parentSiteId ? sites.find(m => m.id === hit.parentSiteId) : hit;
|
||||||
|
if (!main) {
|
||||||
|
return "/workbench";
|
||||||
|
}
|
||||||
|
// URL 段必须是主站编码:院系子站编码统一定向到主站前缀
|
||||||
|
if (hit.parentSiteId) {
|
||||||
|
return `/sch/${main.code}` + to.fullPath.replace(`/sch/${schCode}`, "");
|
||||||
|
}
|
||||||
|
store.enterSchool(
|
||||||
|
{
|
||||||
|
schId: main.schId,
|
||||||
|
schName: main.schName,
|
||||||
|
mainSiteId: main.id,
|
||||||
|
mainSiteCode: main.code,
|
||||||
|
mainSiteName: main.name,
|
||||||
|
},
|
||||||
|
sites.filter(s => s.id === main.id || s.parentSiteId === main.id)
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { RouteRecordRaw } from "vue-router";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CMS 管理端路由(hash 模式,部署于 /cms/admin 子路径)。
|
||||||
|
* 学校化管理双层壳(docs/09 §3.3):
|
||||||
|
* 一级 /workbench 学校工作台(无壳全屏);二级 /sch/:schCode/* 学校专属管理壳(SchoolLayout)。
|
||||||
|
* 站点/学校上下文经 localStorage 持久化,各页面从 store 读取。
|
||||||
|
* meta.modId 与基础平台模块(GetUserMod)契约对应,供 menuRoute 解析菜单/面包屑;
|
||||||
|
* meta.subOf 声明「列表子页面」(面包屑/侧边栏高亮归属所属列表)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 旧路径(无学校前缀)→ 当前学校上下文的对应页;无学校上下文回工作台。
|
||||||
|
* 首次导航早于 App 启动引导(store 未恢复),故同步读 localStorage 兜底 */
|
||||||
|
function legacyRedirect(sub: string) {
|
||||||
|
return (to: any) => {
|
||||||
|
let code = useCmsStore().currentSchool?.mainSiteCode;
|
||||||
|
if (!code) {
|
||||||
|
try {
|
||||||
|
code = JSON.parse(localStorage.getItem("cms.currentSchool") || "null")?.mainSiteCode;
|
||||||
|
} catch {
|
||||||
|
/* 空上下文走工作台 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!code) {
|
||||||
|
return "/workbench";
|
||||||
|
}
|
||||||
|
const idSuffix = to.params.id ? `/${to.params.id}` : "";
|
||||||
|
return `/sch/${code}/${sub}${idSuffix}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
redirect: "/workbench",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 一级:学校工作台(全屏无侧边栏壳)
|
||||||
|
path: "/workbench",
|
||||||
|
name: "SchoolWorkbench",
|
||||||
|
component: () => import("@/views/cms/SchoolWorkbench.vue"),
|
||||||
|
meta: { title: "学校工作台" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 二级:学校专属管理壳,:schCode = 主站编码(URL 即学校上下文)
|
||||||
|
// (懒加载引入,避免 routes → SchoolLayout → menuRoute → routes 循环依赖)
|
||||||
|
path: "/sch/:schCode",
|
||||||
|
component: () => import("@/components/SchoolLayout.vue"),
|
||||||
|
children: [
|
||||||
|
{ path: "", redirect: (to: any) => `/sch/${to.params.schCode}/article` },
|
||||||
|
{
|
||||||
|
path: "article",
|
||||||
|
name: "ArticleList",
|
||||||
|
component: () => import("@/views/cms/ArticleList.vue"),
|
||||||
|
meta: { title: "文章列表", modId: 11 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 新增文章 = 文章列表的子页面(docs/09 §3.2):不挂菜单节点,
|
||||||
|
// 面包屑「内容管理>文章列表>新建文章」、侧边栏高亮文章列表
|
||||||
|
path: "article/edit",
|
||||||
|
name: "ArticleEdit",
|
||||||
|
component: () => import("@/views/cms/ArticleEdit.vue"),
|
||||||
|
meta: { title: "新建文章", modId: 11, subOf: "/article" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "article/edit/:id",
|
||||||
|
name: "ArticleEditId",
|
||||||
|
component: () => import("@/views/cms/ArticleEdit.vue"),
|
||||||
|
meta: { title: "编辑文章", modId: 11, subOf: "/article" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "category",
|
||||||
|
name: "CategoryManage",
|
||||||
|
component: () => import("@/views/cms/CategoryManage.vue"),
|
||||||
|
meta: { title: "栏目管理", modId: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "banner",
|
||||||
|
name: "BannerManage",
|
||||||
|
component: () => import("@/views/cms/BannerManage.vue"),
|
||||||
|
meta: { title: "轮播管理", modId: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "link",
|
||||||
|
name: "LinkManage",
|
||||||
|
component: () => import("@/views/cms/LinkManage.vue"),
|
||||||
|
meta: { title: "友情链接", modId: 4 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "attachment",
|
||||||
|
name: "AttachmentManage",
|
||||||
|
component: () => import("@/views/cms/AttachmentManage.vue"),
|
||||||
|
meta: { title: "附件管理", modId: 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "site",
|
||||||
|
name: "SiteManage",
|
||||||
|
component: () => import("@/views/cms/SiteManage.vue"),
|
||||||
|
meta: { title: "站点管理", modId: 6 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供)
|
||||||
|
path: "portal-preview",
|
||||||
|
name: "PortalPreview",
|
||||||
|
component: () => import("@/views/cms/PortalPreview.vue"),
|
||||||
|
meta: { title: "门户预览", modId: 99 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// ===== 旧路径兼容:重定向到当前学校上下文,不 404 =====
|
||||||
|
{ path: "/article", redirect: legacyRedirect("article") },
|
||||||
|
{ path: "/article/edit/:id?", redirect: legacyRedirect("article/edit") },
|
||||||
|
{ path: "/category", redirect: legacyRedirect("category") },
|
||||||
|
{ path: "/banner", redirect: legacyRedirect("banner") },
|
||||||
|
{ path: "/link", redirect: legacyRedirect("link") },
|
||||||
|
{ path: "/attachment", redirect: legacyRedirect("attachment") },
|
||||||
|
{ path: "/site", redirect: legacyRedirect("site") },
|
||||||
|
{ path: "/portal-preview", redirect: legacyRedirect("portal-preview") },
|
||||||
|
{
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
redirect: "/workbench",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { CurrentSchool, CurrentSite, AuthUserInfo } from "@/types/cms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CMS 管理端全局状态:CAS 登录用户 + 学校上下文 + 当前操作站点。
|
||||||
|
* 用户与站点列表来自 GET /api/auth/user;学校/站点切换写入 localStorage 持久化。
|
||||||
|
* 学校化管理(docs/09 §3.3):/workbench 一级 + /sch/{mainSiteCode}/* 二级双层壳,
|
||||||
|
* 布局数据(modList/用户信息)由 App.vue 启动装配进 session,SchoolLayout 直接消费。
|
||||||
|
*/
|
||||||
|
export const useCmsStore = defineStore("cmsStore", {
|
||||||
|
state: () => ({
|
||||||
|
user: null as AuthUserInfo | null,
|
||||||
|
currentSchool: null as CurrentSchool | null,
|
||||||
|
currentSite: null as CurrentSite | null,
|
||||||
|
/** 平台侧会话数据(原 App.vue layoutData 平移,SchoolLayout/工作台共用) */
|
||||||
|
session: {
|
||||||
|
baseUserName: "",
|
||||||
|
baseUserAvatar: "",
|
||||||
|
modList: [] as any[],
|
||||||
|
isAlone: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
isSuperAdmin: state => state.user?.superAdmin === true,
|
||||||
|
siteId: state => state.currentSite?.id,
|
||||||
|
schoolCode: state => state.currentSchool?.mainSiteCode,
|
||||||
|
/** 当前学校的全部站点(主站在前):切换下拉与工作台共用 */
|
||||||
|
schoolSites: state => {
|
||||||
|
const school = state.currentSchool;
|
||||||
|
const sites = state.user?.sites || [];
|
||||||
|
if (!school) return [];
|
||||||
|
return sites.filter(
|
||||||
|
s => s.id === school.mainSiteId || s.parentSiteId === school.mainSiteId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setUser(user: AuthUserInfo | null) {
|
||||||
|
this.user = user;
|
||||||
|
},
|
||||||
|
setSession(session: { baseUserName?: string; baseUserAvatar?: string; modList?: any[]; isAlone?: boolean }) {
|
||||||
|
if (session.baseUserName !== undefined) this.session.baseUserName = session.baseUserName;
|
||||||
|
if (session.baseUserAvatar !== undefined) this.session.baseUserAvatar = session.baseUserAvatar;
|
||||||
|
if (session.modList !== undefined) this.session.modList = session.modList;
|
||||||
|
if (session.isAlone !== undefined) this.session.isAlone = session.isAlone;
|
||||||
|
},
|
||||||
|
setCurrentSchool(school: CurrentSchool | null) {
|
||||||
|
this.currentSchool = school;
|
||||||
|
if (school) {
|
||||||
|
localStorage.setItem("cms.currentSchool", JSON.stringify(school));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("cms.currentSchool");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restoreSchool() {
|
||||||
|
const raw = localStorage.getItem("cms.currentSchool");
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.currentSchool = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
this.currentSchool = null;
|
||||||
|
}
|
||||||
|
return this.currentSchool;
|
||||||
|
},
|
||||||
|
setCurrentSite(site: CurrentSite | null) {
|
||||||
|
this.currentSite = site;
|
||||||
|
if (site) {
|
||||||
|
localStorage.setItem("cms.currentSite", JSON.stringify(site));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("cms.currentSite");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restoreSite() {
|
||||||
|
const raw = localStorage.getItem("cms.currentSite");
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.currentSite = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
this.currentSite = null;
|
||||||
|
}
|
||||||
|
return this.currentSite;
|
||||||
|
},
|
||||||
|
/** 进入学校上下文:校卡 + 默认站点(记住的站点仍属本校则沿用,否则主站) */
|
||||||
|
enterSchool(school: CurrentSchool, sites: { id: number; code: string; name: string; parentSiteId?: number }[]) {
|
||||||
|
this.setCurrentSchool(school);
|
||||||
|
const remembered = this.currentSite;
|
||||||
|
const belongs = remembered && sites.some(s => s.id === remembered.id);
|
||||||
|
if (belongs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const main = sites.find(s => s.id === school.mainSiteId) || sites[0];
|
||||||
|
if (main) {
|
||||||
|
this.setCurrentSite({ id: main.id, code: main.code, name: main.name });
|
||||||
|
} else {
|
||||||
|
// 该校无可管理站点:清掉他校残留,避免页面串数据
|
||||||
|
this.setCurrentSite(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* 公共类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
export declare interface IObj {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface PageParams {
|
||||||
|
page: !number;
|
||||||
|
count: !number;
|
||||||
|
[keys: string]: any; //任意属性
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface PageData {
|
||||||
|
list: T;
|
||||||
|
total: number;
|
||||||
|
[keys: string]: any; //任意属性
|
||||||
|
}
|
||||||
|
interface RelayConf {
|
||||||
|
ziyuankuHost: string; //资源库
|
||||||
|
zhuanxiangHost: string; //专项
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 系统配置 */
|
||||||
|
export interface BaseConf {
|
||||||
|
baseHost: string;
|
||||||
|
ssoHost: string;
|
||||||
|
serviceHost: string;
|
||||||
|
isMock: Boolean;
|
||||||
|
isDebug?: Boolean;
|
||||||
|
[keys: string]: any; //任意属性
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PC端角色 */
|
||||||
|
export interface PcDebugUser {
|
||||||
|
DebugUser: string;
|
||||||
|
AppType: string;
|
||||||
|
// yschapirequest: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* webapp端角色 */
|
||||||
|
export declare interface WappDebugUser {
|
||||||
|
ticket: string;
|
||||||
|
schoolId: Number;
|
||||||
|
userId: Number;
|
||||||
|
roleId: Number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface CurrentRole {
|
||||||
|
childId: number | null;
|
||||||
|
roleId: number;
|
||||||
|
schId: number;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface layoutData {
|
||||||
|
appName: String;
|
||||||
|
appList: string[];
|
||||||
|
modList: Array<string>;
|
||||||
|
userInfo: Object;
|
||||||
|
ssoHost: String;
|
||||||
|
baseHost: String;
|
||||||
|
isAlone: Boolean; //是否只有一个选项卡
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseDict {
|
||||||
|
k: any;
|
||||||
|
v: string | null;
|
||||||
|
[keys: string]: any; //任意属性
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 计算表格高度的参数类型 */
|
||||||
|
export interface UseTableSetting {
|
||||||
|
/** 是否有查询面板 */
|
||||||
|
isSearch: boolean = true;
|
||||||
|
/** 是否有分页 */
|
||||||
|
isPage: boolean = true;
|
||||||
|
/** 是否是tabs布局 */
|
||||||
|
isTabs: boolean = false;
|
||||||
|
/** 是否有按钮操作 */
|
||||||
|
isOperat: boolean = true;
|
||||||
|
/** 额外的高度 */
|
||||||
|
otherH: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 布局组件类型 */
|
||||||
|
export interface LayoutData {
|
||||||
|
appName: string | null;
|
||||||
|
appList: Array<any>;
|
||||||
|
modList: Array<any>;
|
||||||
|
userInfo: any;
|
||||||
|
ssoHost: string | null;
|
||||||
|
baseHost: string | null;
|
||||||
|
isAlone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义弹出对话框 */
|
||||||
|
export interface MyConfirm {
|
||||||
|
title?: string;
|
||||||
|
msg: string;
|
||||||
|
info?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
confirmText?: string;
|
||||||
|
closeText?: string;
|
||||||
|
onOk?: Function;
|
||||||
|
onCancel?: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare enum NODE_ENV {
|
||||||
|
/**
|
||||||
|
*开发环境
|
||||||
|
*/
|
||||||
|
development = "development",
|
||||||
|
/**
|
||||||
|
*生产环境
|
||||||
|
*/
|
||||||
|
production = "production",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const _: any;
|
||||||
|
export const $: any;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const $: any;
|
||||||
|
const _: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
import Utils from "@/utils/index";
|
||||||
|
import mittBus from '@/utils/mitt'
|
||||||
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
|
||||||
|
export interface VueGlobal {
|
||||||
|
$conf: BaseConf;
|
||||||
|
$bus: typeof mittBus;
|
||||||
|
$utils: typeof Utils;
|
||||||
|
$echarts: echarts;
|
||||||
|
[key:string]:any;
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* CMS 后端契约类型(与 com.yunhai.cms.model 一一对应,方案 §9.2 API 清单)。
|
||||||
|
* 统一响应由 alova 封装解包:{code, msg, data} → code==0 返回 data。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===== 登录态 ===== */
|
||||||
|
|
||||||
|
/** 当前角色(平台 BoaRoleInfo) */
|
||||||
|
export interface CurrentRole {
|
||||||
|
roleId?: number;
|
||||||
|
roleName?: string;
|
||||||
|
schId?: number;
|
||||||
|
schName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/auth/user 返回 */
|
||||||
|
export interface AuthUserInfo {
|
||||||
|
account: string;
|
||||||
|
name: string;
|
||||||
|
currentRole?: CurrentRole;
|
||||||
|
superAdmin: boolean;
|
||||||
|
roleIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站点(SiteVo) */
|
||||||
|
export interface SiteVo {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id) */
|
||||||
|
parentSiteId?: number;
|
||||||
|
/** 上级站点名称(列表展示用,服务端装配) */
|
||||||
|
parentName?: string;
|
||||||
|
schId?: number;
|
||||||
|
schName?: string;
|
||||||
|
name: string;
|
||||||
|
theme?: string;
|
||||||
|
/** 站点配置 JSON 原文(logo/联系方式/页脚/domain/public_base_url) */
|
||||||
|
configJson?: string;
|
||||||
|
status: number;
|
||||||
|
createTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** layout 使用的当前站点精简态 */
|
||||||
|
export interface CurrentSite {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学校上下文精简态(docs/09 §3.3:学校卡片 = 顶层站点,URL 段用主站编码) */
|
||||||
|
export interface CurrentSchool {
|
||||||
|
schId?: number;
|
||||||
|
schName?: string;
|
||||||
|
mainSiteId: number;
|
||||||
|
mainSiteCode: string;
|
||||||
|
mainSiteName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学校工作台卡片(SchoolVo) */
|
||||||
|
export interface SchoolCard {
|
||||||
|
schId?: number;
|
||||||
|
schName?: string;
|
||||||
|
mainSiteId: number;
|
||||||
|
mainSiteCode: string;
|
||||||
|
mainSiteName: string;
|
||||||
|
theme?: string;
|
||||||
|
siteCount: number;
|
||||||
|
articleCount: number;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UserBase 学校选项(添加学校选择器) */
|
||||||
|
export interface SchoolOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 栏目 ===== */
|
||||||
|
|
||||||
|
/** 栏目树节点(CategoryTreeNode) */
|
||||||
|
export interface CategoryTreeNode {
|
||||||
|
id?: number;
|
||||||
|
parentId: number;
|
||||||
|
siteId?: number;
|
||||||
|
name: string;
|
||||||
|
/** 1=文章列表 2=单页 3=外链 */
|
||||||
|
type: number;
|
||||||
|
linkUrl?: string;
|
||||||
|
template?: string;
|
||||||
|
/** 单页内容(type=2) */
|
||||||
|
content?: string;
|
||||||
|
sortNo: number;
|
||||||
|
seoTitle?: string;
|
||||||
|
seoDescription?: string;
|
||||||
|
status: number;
|
||||||
|
children: CategoryTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 文章 ===== */
|
||||||
|
|
||||||
|
/** 文章分页行(ArticlePageItem) */
|
||||||
|
export interface ArticlePageItem {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
categoryId: number;
|
||||||
|
categoryName?: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
/** 0=草稿 1=已发布 2=已下架 */
|
||||||
|
status: number;
|
||||||
|
topmost: number;
|
||||||
|
publishTime?: string;
|
||||||
|
offlineTime?: string;
|
||||||
|
viewCount: number;
|
||||||
|
createTime?: string;
|
||||||
|
createBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文章编辑回显(ArticleEditVo) */
|
||||||
|
export interface ArticleEditVo {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
categoryId: number;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
content?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
authorName?: string;
|
||||||
|
source?: string;
|
||||||
|
status: number;
|
||||||
|
topmost: number;
|
||||||
|
publishTime?: string;
|
||||||
|
offlineTime?: string;
|
||||||
|
extJson?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文章保存入参(ArticleSaveRequest) */
|
||||||
|
export interface ArticleSaveRequest {
|
||||||
|
id?: number;
|
||||||
|
siteId: number;
|
||||||
|
categoryId: number;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
content?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
authorName?: string;
|
||||||
|
source?: string;
|
||||||
|
topmost?: number;
|
||||||
|
/** 定时发布时间(空=不指定) */
|
||||||
|
publishTime?: string;
|
||||||
|
offlineTime?: string;
|
||||||
|
/** 微信预留开关(本期后端不消费) */
|
||||||
|
syncWechat?: boolean;
|
||||||
|
/** 本次编辑上传的附件 id */
|
||||||
|
attachmentIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文章分页查询(ArticlePageQuery) */
|
||||||
|
export interface ArticlePageQuery {
|
||||||
|
siteId: number;
|
||||||
|
categoryId?: number;
|
||||||
|
status?: number;
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 附件 ===== */
|
||||||
|
|
||||||
|
/** 附件(AttachmentVo) */
|
||||||
|
export interface AttachmentVo {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
articleId?: number;
|
||||||
|
fileName: string;
|
||||||
|
url: string;
|
||||||
|
fileSize?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
createTime?: string;
|
||||||
|
orphan: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 轮播 ===== */
|
||||||
|
|
||||||
|
/** 轮播(BannerVo) */
|
||||||
|
export interface BannerVo {
|
||||||
|
id?: number;
|
||||||
|
siteId: number;
|
||||||
|
position: string;
|
||||||
|
title: string;
|
||||||
|
imageUrl: string;
|
||||||
|
/** 1=文章 2=栏目 3=外链 */
|
||||||
|
linkType: number;
|
||||||
|
linkValue?: string;
|
||||||
|
sortNo: number;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 友链 ===== */
|
||||||
|
|
||||||
|
/** 友链(LinkVo) */
|
||||||
|
export interface LinkVo {
|
||||||
|
id?: number;
|
||||||
|
siteId: number;
|
||||||
|
groupName: string;
|
||||||
|
name: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
linkUrl: string;
|
||||||
|
sortNo: number;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 通用 ===== */
|
||||||
|
|
||||||
|
/** 删除入参(DeleteRequest) */
|
||||||
|
export interface DeleteRequest {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端分页包装 {total, list} */
|
||||||
|
export interface PageResult<T> {
|
||||||
|
total: number;
|
||||||
|
list: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 主题 ===== */
|
||||||
|
|
||||||
|
/** theme.json 可调参数选项(select 类型) */
|
||||||
|
export interface ThemeParamOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题包参数 schema(ThemeParam) */
|
||||||
|
export interface ThemeParamVo {
|
||||||
|
key: string;
|
||||||
|
/** 聚合进 :root{} 的 CSS 变量名(仅 color 类型有) */
|
||||||
|
cssVar?: string;
|
||||||
|
label: string;
|
||||||
|
/** color / image / select / text */
|
||||||
|
type: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
options?: ThemeParamOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题包(ThemeVo,GET /api/theme/list) */
|
||||||
|
export interface ThemeVo {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
version?: string;
|
||||||
|
author?: string;
|
||||||
|
bannerSize?: string;
|
||||||
|
/** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */
|
||||||
|
screenshotUrl: string;
|
||||||
|
params: ThemeParamVo[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
declare module "*.vue" {
|
||||||
|
import { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { createAlova, Method, StatesHook } from "alova";
|
||||||
|
import fetchAdapter from "alova/fetch";
|
||||||
|
import VueHook from "alova/vue";
|
||||||
|
import Utils from "..";
|
||||||
|
/* ============= 类型定义 ============= */
|
||||||
|
export interface RequestHooks {
|
||||||
|
/** 前置票据/通用头注入 */
|
||||||
|
injectHeaders?: (method: Method) => void;
|
||||||
|
/** 加载开始 */
|
||||||
|
onLoadingStart?: () => void;
|
||||||
|
/** 加载结束 */
|
||||||
|
onLoadingEnd?: () => void;
|
||||||
|
/** 业务错误提示(code !== 0)*/
|
||||||
|
onBizError?: (msg: string) => void;
|
||||||
|
/** http 错误提示(status !== 200)*/
|
||||||
|
onHttpError?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 和后端约定的通用响应格式 */
|
||||||
|
export interface BschResponse<T = any> {
|
||||||
|
code: number;
|
||||||
|
msg?: string;
|
||||||
|
data: T;
|
||||||
|
urlToRedirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============= 默认空实现 ============= */
|
||||||
|
const defHooks: Required<RequestHooks> = {
|
||||||
|
injectHeaders: () => {},
|
||||||
|
onLoadingStart: () => {},
|
||||||
|
onLoadingEnd: () => {},
|
||||||
|
onBizError: () => {},
|
||||||
|
onHttpError: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============= 错误处理(原 axios 逻辑平移) ============= */
|
||||||
|
function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
||||||
|
// http 层错误
|
||||||
|
if (status !== 200) {
|
||||||
|
let msg = "网络错误";
|
||||||
|
switch (status) {
|
||||||
|
case 401:
|
||||||
|
case 403:
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
msg = "请求的资源不存在!";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
msg = res?.msg || "内部服务错误!";
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务层错误
|
||||||
|
switch (res.code) {
|
||||||
|
case 0:
|
||||||
|
return res.data ?? (null as T);
|
||||||
|
case 302:
|
||||||
|
console.log(res);
|
||||||
|
// 后端 CasSsoApiRedirectFilter 已在 service 中拼好 ;redirectToReferer=<hex>,
|
||||||
|
// 直接跳转即可;登录后由 CasSsoRefererRedirectFilter 解码 302 回前端页。
|
||||||
|
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,
|
||||||
|
// 全局 onBizError 与各页面的 catch 均不再触发(不弹 redirect 报错)
|
||||||
|
window.location.href = res.urlToRedirectTo!;
|
||||||
|
return new Promise(() => {}) as T;
|
||||||
|
case 4:
|
||||||
|
throw new Error(res.msg || "参数错误!");
|
||||||
|
default:
|
||||||
|
throw new Error(res.msg || "业务错误");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============= 工厂函数 ============= */
|
||||||
|
export function createBschAlova(
|
||||||
|
baseURL: string,
|
||||||
|
hooks: RequestHooks = {},
|
||||||
|
statesHook: StatesHook<any> = VueHook,
|
||||||
|
) {
|
||||||
|
const { injectHeaders, onLoadingStart, onLoadingEnd, onBizError, onHttpError } = {
|
||||||
|
...defHooks,
|
||||||
|
...hooks,
|
||||||
|
};
|
||||||
|
|
||||||
|
let reqCount = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
/* ---- 请求拦截 ---- */
|
||||||
|
function beforeRequest(method: Method) {
|
||||||
|
reqCount++;
|
||||||
|
if (reqCount === 1) timer = setTimeout(() => onLoadingStart(), 800);
|
||||||
|
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
if (reqCount === 0) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
onLoadingEnd();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 1. 用户注入票据/角色等头
|
||||||
|
injectHeaders(method);
|
||||||
|
|
||||||
|
method.config.credentials = "include";
|
||||||
|
// 2. 通用头
|
||||||
|
method.config.headers = Object.assign(method.config.headers || {}, {
|
||||||
|
yschapirequest: "1",
|
||||||
|
timeout: 20000,
|
||||||
|
yschreferer: Utils.strToHexCharCode(window.location.href),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 响应拦截 ---- */
|
||||||
|
async function onSuccess(response: Response) {
|
||||||
|
reqCount--;
|
||||||
|
if (reqCount === 0) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
onLoadingEnd();
|
||||||
|
}
|
||||||
|
const res: BschResponse = await response.json();
|
||||||
|
try {
|
||||||
|
return errorHandle(res, response.status);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(e);
|
||||||
|
|
||||||
|
onBizError(e.message);
|
||||||
|
return Promise.reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onError(err: any) {
|
||||||
|
reqCount = 0;
|
||||||
|
clearTimeout(timer);
|
||||||
|
onLoadingEnd();
|
||||||
|
|
||||||
|
const msg = err?.message || "网络请求出错";
|
||||||
|
onHttpError(msg);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onComplete(method: Method) {
|
||||||
|
if (method.fromCache) {
|
||||||
|
reqCount--;
|
||||||
|
if (reqCount === 0) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
onLoadingEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createAlova({
|
||||||
|
statesHook,
|
||||||
|
requestAdapter: fetchAdapter(),
|
||||||
|
baseURL,
|
||||||
|
cacheFor: null,
|
||||||
|
beforeRequest,
|
||||||
|
responded: { onSuccess, onError, onComplete },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// @/utils/alova-iview.ts
|
||||||
|
import { createBschAlova } from "./alova-core";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import conf from "@/assets/config/host.config";
|
||||||
|
|
||||||
|
/* 全局 loading 遮罩实例 */
|
||||||
|
let spinInstance: any = null;
|
||||||
|
|
||||||
|
/* 主动关闭 loading */
|
||||||
|
function closeLoading() {
|
||||||
|
if (spinInstance) {
|
||||||
|
Message.destroy();
|
||||||
|
spinInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = conf.serviceHost;
|
||||||
|
|
||||||
|
/* 创建带 View Design UI 的 Alova 实例 */
|
||||||
|
export const alovaInstance = createBschAlova(baseUrl, {
|
||||||
|
injectHeaders(method) {
|
||||||
|
// currentRole: encodeURI(JSON.stringify({ userId, roleId, schId: schoolId })),
|
||||||
|
if (localStorage.getItem("currentRole") != null) {
|
||||||
|
method.config.headers["currentRole"] = localStorage.getItem("currentRole") as string;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* 2. 加载动画 – View Design Spin */
|
||||||
|
onLoadingStart() {
|
||||||
|
spinInstance = Message.loading("加载中...");
|
||||||
|
},
|
||||||
|
onLoadingEnd: closeLoading,
|
||||||
|
onBizError(msg: string) {
|
||||||
|
if (!!msg) Message.error(msg);
|
||||||
|
},
|
||||||
|
|
||||||
|
onHttpError(msg: string) {
|
||||||
|
Message.error({ title: "网络错误", content: msg });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { App } from "vue";
|
||||||
|
|
||||||
|
import realImg from "./real-img";
|
||||||
|
|
||||||
|
// 自动导入当前文件夹下的所有自定义指令(默认导出项)
|
||||||
|
export default (app: App<Element>) => {
|
||||||
|
realImg(app);
|
||||||
|
};
|
||||||
|
|
||||||
|
// const modules = import.meta.glob("../directive/**/**.ts");
|
||||||
|
|
||||||
|
// for (const path in modules) {
|
||||||
|
// // 排除当前文件
|
||||||
|
// if (path !== "../directive/index.ts") {
|
||||||
|
// modules[path]().then(mod => {
|
||||||
|
// mod.default(app);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { App } from "vue";
|
||||||
|
|
||||||
|
export default (app: App<Element>): void => {
|
||||||
|
app.directive("real-img", {
|
||||||
|
async mounted(el, binding, vnode: any) {
|
||||||
|
console.log(el);
|
||||||
|
if (el.getAttribute("data-load") == null) el.classList.add("img-hide");
|
||||||
|
let imgURL = binding.value; //获取图片地址
|
||||||
|
|
||||||
|
if (imgURL) {
|
||||||
|
let exist = await imageIsExist(imgURL);
|
||||||
|
if (exist) {
|
||||||
|
el.setAttribute("src", imgURL);
|
||||||
|
el.setAttribute("data-load", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.classList.remove("img-hide");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测图片是否存在
|
||||||
|
* @param url
|
||||||
|
*/
|
||||||
|
let imageIsExist = function (url: string) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
var img: HTMLImageElement | null = new Image();
|
||||||
|
img.onload = function () {
|
||||||
|
if ((this as any).complete == true) {
|
||||||
|
resolve(true);
|
||||||
|
img = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = function () {
|
||||||
|
resolve(false);
|
||||||
|
img = null;
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { ref } from "vue";
|
||||||
|
export function useCompRef<T extends abstract new (...args: any) => any>(_comp: T) {
|
||||||
|
return ref<InstanceType<T>>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { VueGlobal } from "@/types/Basics";
|
||||||
|
import { ComponentInternalInstance, getCurrentInstance } from "vue";
|
||||||
|
export default function useCurrentInstance(): VueGlobal {
|
||||||
|
const { appContext } = getCurrentInstance() as ComponentInternalInstance;
|
||||||
|
const proxy = appContext.config.globalProperties;
|
||||||
|
return proxy as unknown as VueGlobal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type PathType = "image" | "icons";
|
||||||
|
export const requireImg = (imgPath: string, basePath?: PathType) => {
|
||||||
|
basePath = basePath || "image";
|
||||||
|
return new URL(`../../assets/${basePath}/${imgPath}`, import.meta.url).href;
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ref, watch, type Ref } from "vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将任意响应式数据与 sessionStorage 双向绑定
|
||||||
|
* @param key sessionStorage 的 key
|
||||||
|
* @param def 默认值(当 sessionStorage 没有时)
|
||||||
|
* @param reviver 可选:解析时用 JSON.parse 的第二个参数
|
||||||
|
* @returns Ref<T>
|
||||||
|
*/
|
||||||
|
export function useSessionStorage<T>(
|
||||||
|
key: string,
|
||||||
|
def: T,
|
||||||
|
reviver?: (this: any, key: string, value: any) => any,
|
||||||
|
): Ref<T> {
|
||||||
|
/* ---------- 初始化 ---------- */
|
||||||
|
const raw = sessionStorage.getItem(key);
|
||||||
|
let init: T = def;
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
init = JSON.parse(raw, reviver);
|
||||||
|
} catch {
|
||||||
|
/* 解析失败回退默认值 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const state = ref<T>(init) as Ref<T>;
|
||||||
|
|
||||||
|
/* ---------- 变化时落盘 ---------- */
|
||||||
|
watch(
|
||||||
|
state,
|
||||||
|
newVal => {
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(newVal));
|
||||||
|
},
|
||||||
|
{ deep: true, flush: "post" },
|
||||||
|
);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { IObj, UseTableSetting } from "@/types/Basics";
|
||||||
|
import { resolveComponent } from "vue";
|
||||||
|
export default class Utils {
|
||||||
|
/**
|
||||||
|
* 格式化日期
|
||||||
|
*
|
||||||
|
* @param {(Date | string)} 日期
|
||||||
|
* @param {string} fmt 格式化字符串
|
||||||
|
* @returns {string} 日期字符串
|
||||||
|
* @memberof Utils
|
||||||
|
*/
|
||||||
|
public static dateFormat(date: Date | string, fmt: string): string {
|
||||||
|
if (typeof date === "string") date = date.replace(/\-/g, "/");
|
||||||
|
let dt = new Date(date);
|
||||||
|
let o: IObj = {
|
||||||
|
"M+": dt.getMonth() + 1, //月份
|
||||||
|
"d+": dt.getDate(), //日
|
||||||
|
"H+": dt.getHours(), //小时
|
||||||
|
"m+": dt.getMinutes(), //分
|
||||||
|
"s+": dt.getSeconds(), //秒
|
||||||
|
"q+": Math.floor((dt.getMonth() + 3) / 3), //季度
|
||||||
|
S: dt.getMilliseconds(), //毫秒
|
||||||
|
};
|
||||||
|
if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (dt.getFullYear() + "").substr(4 - RegExp.$1.length));
|
||||||
|
|
||||||
|
for (let k in o)
|
||||||
|
if (new RegExp("(" + k + ")").test(fmt))
|
||||||
|
fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : ("00" + o[k]).substr(("" + o[k]).length));
|
||||||
|
return fmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取地址栏指定参数
|
||||||
|
*/
|
||||||
|
public static getUrlParam(name: string): string | null {
|
||||||
|
let reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
|
||||||
|
let r = window.location.search.substr(1).match(reg);
|
||||||
|
if (r != null) return unescape(r[2]);
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成uuid
|
||||||
|
*/
|
||||||
|
public static uuid(): string {
|
||||||
|
return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||||
|
let r = (Math.random() * 16) | 0,
|
||||||
|
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回文件大小
|
||||||
|
*/
|
||||||
|
public static getFileSize(size: number): string {
|
||||||
|
if (!size) return "";
|
||||||
|
if (size / 1024 < 1024) {
|
||||||
|
size = Math.ceil(size / 1024);
|
||||||
|
return size + "KB";
|
||||||
|
} else if (size / (1024 * 1024) < 1024) {
|
||||||
|
size = Math.ceil((size / (1024 * 1024)) * 100) / 100;
|
||||||
|
return size + "MB";
|
||||||
|
} else if (size / (1024 * 1024 * 1024) < 1024) {
|
||||||
|
size = Math.ceil((size / (1024 * 1024 * 1024)) * 100) / 100;
|
||||||
|
return size + "GB";
|
||||||
|
} else return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定键
|
||||||
|
*/
|
||||||
|
public static delKey(obj: IObj, ...args: string[]) {
|
||||||
|
args.forEach(v => delete obj[v]);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iview Tooltip封装
|
||||||
|
*/
|
||||||
|
public static operatingHandle(
|
||||||
|
h: any,
|
||||||
|
info: string,
|
||||||
|
iconClass: string,
|
||||||
|
callback: Function,
|
||||||
|
disabled: boolean = false,
|
||||||
|
) {
|
||||||
|
if (disabled) iconClass += " disabled";
|
||||||
|
|
||||||
|
return h(
|
||||||
|
resolveComponent("Tooltip"),
|
||||||
|
{
|
||||||
|
content: info,
|
||||||
|
placement: "bottom",
|
||||||
|
transfer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
class: `operating-btn iconfont ${iconClass}`,
|
||||||
|
onClick: callback,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static operatingTextHandle(
|
||||||
|
h: any,
|
||||||
|
info: string,
|
||||||
|
className: string,
|
||||||
|
callback: Function,
|
||||||
|
disabled: boolean = false,
|
||||||
|
) {
|
||||||
|
if (disabled) className += " disabled";
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
class: `operating-link ${className}`,
|
||||||
|
onClick: disabled ? null : callback,
|
||||||
|
},
|
||||||
|
info,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算表格高度
|
||||||
|
*/
|
||||||
|
public static useTableHeight({
|
||||||
|
isSearch = false,
|
||||||
|
isPage = false,
|
||||||
|
isTabs = false,
|
||||||
|
isOperat = false,
|
||||||
|
otherH = 0,
|
||||||
|
} = {}) {
|
||||||
|
let bodyH = document.body.clientHeight,
|
||||||
|
headerH = 112,
|
||||||
|
searchH = 52,
|
||||||
|
operatH = 48,
|
||||||
|
tabsH = 56,
|
||||||
|
pageH = 72,
|
||||||
|
paddingH = 8;
|
||||||
|
|
||||||
|
try {
|
||||||
|
//@ts-ignore 平台注入的全局对象,独立运行时不存在
|
||||||
|
const bschUtil = BschLoadUtil.getInstance();
|
||||||
|
if (!bschUtil.getIsSelf()) headerH = 56;
|
||||||
|
} catch (e) {
|
||||||
|
/* 独立运行:保留自身 header(56) + 面包屑(56) = 112 */
|
||||||
|
}
|
||||||
|
let tableH = bodyH - headerH - paddingH;
|
||||||
|
isOperat && (tableH -= operatH);
|
||||||
|
isTabs && (tableH -= tabsH);
|
||||||
|
isSearch && (tableH -= searchH);
|
||||||
|
isPage && (tableH -= pageH);
|
||||||
|
otherH > 0 && (tableH -= otherH);
|
||||||
|
return tableH;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 数字转大写汉字
|
||||||
|
* @param num
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public static convertToChinaNum(num: number) {
|
||||||
|
var arr1 = new Array("零", "一", "二", "三", "四", "五", "六", "七", "八", "九");
|
||||||
|
var arr2 = new Array(
|
||||||
|
"",
|
||||||
|
"十",
|
||||||
|
"百",
|
||||||
|
"千",
|
||||||
|
"万",
|
||||||
|
"十",
|
||||||
|
"百",
|
||||||
|
"千",
|
||||||
|
"亿",
|
||||||
|
"十",
|
||||||
|
"百",
|
||||||
|
"千",
|
||||||
|
"万",
|
||||||
|
"十",
|
||||||
|
"百",
|
||||||
|
"千",
|
||||||
|
"亿",
|
||||||
|
); //可继续追加更高位转换值
|
||||||
|
if (!num || isNaN(num)) {
|
||||||
|
return "零";
|
||||||
|
}
|
||||||
|
var english = num.toString().split("");
|
||||||
|
var result = "";
|
||||||
|
for (var i = 0; i < english.length; i++) {
|
||||||
|
var des_i = english.length - 1 - i; //倒序排列设值
|
||||||
|
result = arr2[i] + result;
|
||||||
|
var arr1_index: any = english[des_i];
|
||||||
|
result = arr1[arr1_index] + result;
|
||||||
|
}
|
||||||
|
//将【零千、零百】换成【零】 【十零】换成【十】
|
||||||
|
result = result.replace(/零(千|百|十)/g, "零").replace(/十零/g, "十");
|
||||||
|
//合并中间多个零为一个零
|
||||||
|
result = result.replace(/零+/g, "零");
|
||||||
|
//将【零亿】换成【亿】【零万】换成【万】
|
||||||
|
result = result.replace(/零亿/g, "亿").replace(/零万/g, "万");
|
||||||
|
//将【亿万】换成【亿】
|
||||||
|
result = result.replace(/亿万/g, "亿");
|
||||||
|
//移除末尾的零
|
||||||
|
result = result.replace(/零+$/, "");
|
||||||
|
//将【零一十】换成【零十】
|
||||||
|
//result = result.replace(/零一十/g, '零十');//貌似正规读法是零一十
|
||||||
|
//将【一十】换成【十】
|
||||||
|
result = result.replace(/^一十/g, "十");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象转url参数
|
||||||
|
* @param num
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public static createUrl(url: string, query: any) {
|
||||||
|
var temp: any[] = [];
|
||||||
|
for (var k in query) {
|
||||||
|
if (query[k] != null) temp.push(k + "=" + query[k]);
|
||||||
|
}
|
||||||
|
return url + "?" + temp.join("&");
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取当前角色信息
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public static getCurrentRoleInfo<T>() {
|
||||||
|
let currentRole = localStorage.getItem("currentRole");
|
||||||
|
if (!currentRole) return null;
|
||||||
|
let { schId, roleId, childId, userId } = JSON.parse(decodeURI(currentRole ?? ""));
|
||||||
|
return { schId, roleId, childId, userId } as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tree对象转化为数组
|
||||||
|
* @param tree
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public static treeToArray(tree: any[]): any[] {
|
||||||
|
return tree.reduce((res, item) => {
|
||||||
|
const { children, ...i } = item;
|
||||||
|
return res.concat(i, children && children.length ? this.treeToArray(children) : []);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数组转化为tree对象
|
||||||
|
* @param tree
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public static arrayToTree(arr: any[], pid: number): any[] {
|
||||||
|
return arr
|
||||||
|
.filter(item => item.pid === pid)
|
||||||
|
.map(item => ({ ...item, children: this.arrayToTree(arr, item.id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
//字符串的16进制表达
|
||||||
|
public static strToHexCharCode(_str: string) {
|
||||||
|
if (_str === "") return "";
|
||||||
|
var hexCharCode: string[] = [];
|
||||||
|
for (var i = 0; i < _str.length; i++) {
|
||||||
|
var str = _str.charCodeAt(i).toString(16);
|
||||||
|
if (_str.length == 1) str += "0" + _str;
|
||||||
|
hexCharCode.push(str);
|
||||||
|
}
|
||||||
|
return hexCharCode.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static hexToString(hex: string) {
|
||||||
|
if (hex === "") return "";
|
||||||
|
// 处理奇数长度,补前导零
|
||||||
|
if (hex.length % 2 !== 0) {
|
||||||
|
hex = "0" + hex;
|
||||||
|
[[3]];
|
||||||
|
}
|
||||||
|
let str = "";
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
// 每两位解析为一个字符的Unicode值
|
||||||
|
const hexPair = hex.substr(i, 2);
|
||||||
|
const charCode = parseInt(hexPair, 16);
|
||||||
|
str += String.fromCharCode(charCode);
|
||||||
|
[[14, 16]];
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将颜色值HEX格式转换为rgb的格式
|
||||||
|
* @param {hex} hex 需要转换的rgb字符串
|
||||||
|
* @param {number} opacity 透明度
|
||||||
|
* @return {string} ;
|
||||||
|
*/
|
||||||
|
public static hexToRgb(hex: string, opacity: number = 1) {
|
||||||
|
let str = hex.replace("#", "");
|
||||||
|
if (str.length % 3) {
|
||||||
|
return "hex格式不正确!";
|
||||||
|
}
|
||||||
|
//获取截取的字符长度
|
||||||
|
let count = str.length / 3;
|
||||||
|
//根据字符串的长度判断是否需要 进行幂次方
|
||||||
|
let power = 6 / str.length;
|
||||||
|
let r = parseInt("0x" + str.substring(0 * count, 1 * count)) ** power;
|
||||||
|
let g = parseInt("0x" + str.substring(1 * count, 2 * count)) ** power;
|
||||||
|
let b = parseInt("0x" + str.substring(2 * count)) ** power;
|
||||||
|
return `rgb(${r}, ${g}, ${b},${opacity})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static removeHtmlTag(str: string) {
|
||||||
|
str = str.replace(/<\/?[^>]*>/g, ""); //去除HTML tag
|
||||||
|
str = str.replace(/[ | ]*\n/g, "\n"); //去除行尾空白
|
||||||
|
str = str.replace(/\n[\s| | ]*\r/g, "\n"); //去除多余空行
|
||||||
|
str = str.replace(/ /gi, ""); //去掉
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unpackPerms = (bits: number) => [
|
||||||
|
!!(bits & 1), // create
|
||||||
|
!!(bits & 2), // update
|
||||||
|
!!(bits & 4), // move
|
||||||
|
!!(bits & 8), // delete
|
||||||
|
];
|
||||||
|
|
||||||
|
public static copyToClipboard(text: string) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.setAttribute("value", text);
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
try {
|
||||||
|
const successful = document.execCommand("copy");
|
||||||
|
if (successful) {
|
||||||
|
return Promise.resolve();
|
||||||
|
} else {
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return Promise.reject(err);
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { RouteRecordRaw } from "vue-router";
|
||||||
|
import { routes } from "@/router/routes";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台模块配置 → 前端内部路由的统一解析器(Notice 同款平移)。
|
||||||
|
*
|
||||||
|
* 基础平台里配置的模块 path 格式不可控(可能带旧前缀、带 index.html#/hash、
|
||||||
|
* 是应用入口地址甚至完整 URL、或干脆配错),直接 router.push(item.page) 匹配
|
||||||
|
* 不到就会白屏或被重定向兜到默认页。所有消费模块 page 的地方(侧边栏跳转/
|
||||||
|
* 高亮)必须先经过这里。
|
||||||
|
*
|
||||||
|
* 学校化管理(docs/09 §3.3)后页面全部挂在 /sch/:schCode 嵌套路由下:
|
||||||
|
* 平台模块 path 仍配置业务路径(如 /article),解析时匹配子路径形态,
|
||||||
|
* 并在运行时用当前学校上下文(store.schoolCode)拼出 /sch/{code}/article。
|
||||||
|
* 解析顺序:路径精确匹配 → 模块名匹配 → 外链判定 → modId 契约匹配。
|
||||||
|
* 全部落空时返回空结果并 console.warn,绝不返回未注册的原始路径。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RouteEntry {
|
||||||
|
/** 顶层路由为绝对 path;/sch/:schCode 子路由为相对子路径(如 /article) */
|
||||||
|
sub: string;
|
||||||
|
absolute: boolean;
|
||||||
|
title?: string;
|
||||||
|
modId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 可解析路由清单(含嵌套 children;重定向路由穿透跳过,参数化叶子跳过) */
|
||||||
|
const ROUTE_ENTRIES: RouteEntry[] = [];
|
||||||
|
(function walk(rs: RouteRecordRaw[], underSchool: boolean) {
|
||||||
|
rs.forEach(r => {
|
||||||
|
const raw = r.path || "";
|
||||||
|
const isSchoolRoot = raw === "/sch/:schCode";
|
||||||
|
if (raw.includes(":") && !isSchoolRoot && !underSchool) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextUnder = underSchool || isSchoolRoot;
|
||||||
|
const sub = nextUnder && !raw.startsWith("/") ? "/" + raw : raw;
|
||||||
|
if (!r.redirect && r.component) {
|
||||||
|
const meta: any = r.meta || {};
|
||||||
|
ROUTE_ENTRIES.push({ sub, absolute: !nextUnder, title: meta.title, modId: meta.modId });
|
||||||
|
}
|
||||||
|
if (r.children && r.children.length) {
|
||||||
|
walk(r.children, nextUnder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(routes, false);
|
||||||
|
|
||||||
|
/** 顶层绝对 path 集合(重定向路由已穿透跳过) */
|
||||||
|
const ABSOLUTE_PATHS = ROUTE_ENTRIES.filter(e => e.absolute).map(e => e.sub);
|
||||||
|
|
||||||
|
/** 标题 → 路由(重复取先注册的) */
|
||||||
|
const ROUTE_BY_TITLE = new Map<string, RouteEntry>();
|
||||||
|
for (const e of ROUTE_ENTRIES) {
|
||||||
|
if (e.title && !ROUTE_BY_TITLE.has(e.title)) ROUTE_BY_TITLE.set(e.title, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** modId → 路由(取该 modId 的第一个非重定向路由) */
|
||||||
|
const ROUTE_BY_MODID = new Map<number, RouteEntry>();
|
||||||
|
for (const e of ROUTE_ENTRIES) {
|
||||||
|
if (e.modId != null && !ROUTE_BY_MODID.has(e.modId)) ROUTE_BY_MODID.set(e.modId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台历史模块名 → 路由(旧菜单叫法与现页面标题不一致的对照) */
|
||||||
|
const NAME_ALIASES: Record<string, string> = {};
|
||||||
|
|
||||||
|
const EXTERNAL_RE = /^(https?:)?\/\//i;
|
||||||
|
|
||||||
|
/** 从平台原始配置里提取内部路由候选串(去掉 hash、query、尾斜杠) */
|
||||||
|
function extractCandidate(raw: string): string {
|
||||||
|
let p = raw.trim();
|
||||||
|
try {
|
||||||
|
p = decodeURIComponent(p);
|
||||||
|
} catch {
|
||||||
|
/* 含非法编码时保持原样 */
|
||||||
|
}
|
||||||
|
// 带 hash 的地址(.../index.html#/setting):取 # 后的 SPA 路由
|
||||||
|
const hashIdx = p.indexOf("#");
|
||||||
|
if (hashIdx >= 0) {
|
||||||
|
p = p.slice(hashIdx + 1);
|
||||||
|
} else if (EXTERNAL_RE.test(p)) {
|
||||||
|
// 无 hash 的完整 URL:取 pathname(如 https://host/setting)
|
||||||
|
try {
|
||||||
|
p = new URL(p, "http://placeholder.local").pathname;
|
||||||
|
} catch {
|
||||||
|
/* 保持原样 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const qIdx = p.indexOf("?");
|
||||||
|
if (qIdx >= 0) p = p.slice(0, qIdx);
|
||||||
|
// 入口文件名去掉后即可命中根重定向路由
|
||||||
|
p = p.replace(/index\.html?$/i, "");
|
||||||
|
p = p.replace(/\/+$/, "");
|
||||||
|
if (p && !p.startsWith("/")) p = "/" + p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相对(学校壳内)子路径 → 具体路径:运行时拼当前学校编码;无学校上下文返回 null */
|
||||||
|
function toConcrete(entry: RouteEntry): string | null {
|
||||||
|
if (entry.absolute) {
|
||||||
|
return entry.sub;
|
||||||
|
}
|
||||||
|
const code = useCmsStore().schoolCode;
|
||||||
|
return code ? `/sch/${code}${entry.sub}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 候选串 → 已注册路由 path;未命中返回 null */
|
||||||
|
function matchRoute(candidate: string): string | null {
|
||||||
|
if (!candidate) return null;
|
||||||
|
const lower = candidate.toLowerCase();
|
||||||
|
// 1. 绝对路径精确匹配
|
||||||
|
const exact = ABSOLUTE_PATHS.find(p => p.toLowerCase() === lower);
|
||||||
|
if (exact) return exact;
|
||||||
|
// 2. 学校壳内子路径匹配(/article → /sch/{code}/article),兼容旧 /cms 前缀
|
||||||
|
const stripped = lower.startsWith("/cms/") ? lower.slice("/cms".length) : lower;
|
||||||
|
const entry = ROUTE_ENTRIES.find(e => !e.absolute && e.sub.toLowerCase() === stripped);
|
||||||
|
if (entry) {
|
||||||
|
return toConcrete(entry);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按模块名解析路由:精确标题 → 历史别名 → 双向包含(命中多个则放弃) */
|
||||||
|
function matchByName(name?: string | null): RouteEntry | null {
|
||||||
|
const n = String(name ?? "").trim();
|
||||||
|
if (!n) return null;
|
||||||
|
const byTitle = ROUTE_BY_TITLE.get(n);
|
||||||
|
if (byTitle) return byTitle;
|
||||||
|
const alias = NAME_ALIASES[n];
|
||||||
|
if (alias) {
|
||||||
|
return ROUTE_ENTRIES.find(e => e.sub === alias && e.absolute) || null;
|
||||||
|
}
|
||||||
|
const hits = ROUTE_ENTRIES.filter(e => {
|
||||||
|
const label = e.title || e.sub;
|
||||||
|
return label.length >= 2 && (n.includes(label) || label.includes(n));
|
||||||
|
});
|
||||||
|
return hits.length === 1 ? hits[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否为本应用入口类地址(/、/cms、…/index.html):只表示"打开应用",
|
||||||
|
* 不携带页面信息,不能当作有效跳转目标 */
|
||||||
|
function isAppEntryCandidate(candidate: string): boolean {
|
||||||
|
return !candidate || candidate === "/" || candidate === "/cms";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedMenuPage {
|
||||||
|
/** 内部路由 path;解析不出时为空串 */
|
||||||
|
path: string;
|
||||||
|
/** 外部链接(完整 URL 且不属于本应用路由),需整页跳转而非 router.push */
|
||||||
|
external: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空配置的统一返回值 */
|
||||||
|
export const EMPTY_MENU_PAGE: ResolvedMenuPage = { path: "", external: false };
|
||||||
|
|
||||||
|
export function resolveMenuPage(
|
||||||
|
page?: string | null,
|
||||||
|
modId?: number | null,
|
||||||
|
name?: string | null
|
||||||
|
): ResolvedMenuPage {
|
||||||
|
const raw = String(page ?? "").trim();
|
||||||
|
const byName = (): RouteEntry | null => matchByName(name);
|
||||||
|
const byModId = (): RouteEntry | null =>
|
||||||
|
modId != null ? ROUTE_BY_MODID.get(modId) ?? null : null;
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
const candidate = extractCandidate(raw);
|
||||||
|
const byPath = matchRoute(candidate);
|
||||||
|
if (byPath) return { path: byPath, external: false };
|
||||||
|
|
||||||
|
// 完整 URL:指向本应用入口的不算有效配置(继续用名字/modId 识别),
|
||||||
|
// 其余按外部链接整页打开
|
||||||
|
if (EXTERNAL_RE.test(raw) && !raw.includes("#")) {
|
||||||
|
if (!isAppEntryCandidate(candidate)) {
|
||||||
|
console.warn(`[menuRoute] 模块「${name ?? modId}」page 配置为外部链接: ${raw}`);
|
||||||
|
return { path: raw, external: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate && !isAppEntryCandidate(candidate)) {
|
||||||
|
console.warn(`[menuRoute] 模块「${name ?? modId}」page「${raw}」未匹配到路由,回退名称/modId 识别`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byNameHit = byName();
|
||||||
|
if (byNameHit) {
|
||||||
|
const path = toConcrete(byNameHit);
|
||||||
|
if (path) return { path, external: false };
|
||||||
|
}
|
||||||
|
const byModIdHit = byModId();
|
||||||
|
if (byModIdHit) {
|
||||||
|
const path = toConcrete(byModIdHit);
|
||||||
|
if (path) return { path, external: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw && !isAppEntryCandidate(extractCandidate(raw))) {
|
||||||
|
console.warn(`[menuRoute] 模块「${name ?? modId}」无法解析 page「${raw}」,菜单项将不可跳转`);
|
||||||
|
}
|
||||||
|
return EMPTY_MENU_PAGE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// import { BschIframeType, LightAppModal, IframePageParmas, RoleInfo, StateEnum } from "@/types";
|
||||||
|
import mitt, { Emitter } from "mitt";
|
||||||
|
|
||||||
|
type MittType<T = any> = {
|
||||||
|
reload: void; //刷新页面
|
||||||
|
setMenuPath: number; //选择左侧菜单
|
||||||
|
resetMenuPath: any[]; //重置设置左侧菜单
|
||||||
|
setNavPath: any[]; //设置面包屑
|
||||||
|
resetLayout: { identity: number; modId: number }; //重置页面菜单
|
||||||
|
};
|
||||||
|
|
||||||
|
// 类型
|
||||||
|
const emitter: Emitter<MittType> = mitt<MittType>();
|
||||||
|
// 导出
|
||||||
|
export default emitter;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
EXTRA_KEY_RE,
|
||||||
|
SITE_CONFIG_FIELD_DEFS,
|
||||||
|
extraRowError,
|
||||||
|
firstExtraError,
|
||||||
|
parseSiteConfig,
|
||||||
|
serializeSiteConfig,
|
||||||
|
validateFieldValue,
|
||||||
|
} from "./siteConfigForm";
|
||||||
|
|
||||||
|
/** docs/09 §3.1 站点配置结构化:parse/serialize/校验 */
|
||||||
|
describe("siteConfigForm", () => {
|
||||||
|
it("parse:已知键入 fields、未知字符串键入扩展行、theme_params/对象键无损入 preserved", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
logo: "/cms/upload/logo.png",
|
||||||
|
contact: "电话 0571-88888888",
|
||||||
|
domain: "www.xxx.edu.cn",
|
||||||
|
public_base_url: "https://www.xxx.edu.cn",
|
||||||
|
my_ext: "自定义值",
|
||||||
|
theme_params: { primary: "#b01a2e" },
|
||||||
|
wechat: { appid: "wx123" },
|
||||||
|
});
|
||||||
|
const m = parseSiteConfig(json);
|
||||||
|
expect(m.fields.logo).toBe("/cms/upload/logo.png");
|
||||||
|
expect(m.fields.contact).toBe("电话 0571-88888888");
|
||||||
|
expect(m.fields.domain).toBe("www.xxx.edu.cn");
|
||||||
|
expect(m.fields.public_base_url).toBe("https://www.xxx.edu.cn");
|
||||||
|
expect(m.extras).toEqual([{ key: "my_ext", value: "自定义值" }]);
|
||||||
|
expect(m.preserved.theme_params).toEqual({ primary: "#b01a2e" });
|
||||||
|
expect(m.preserved.wechat).toEqual({ appid: "wx123" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parse:非法 JSON / 空值按空配置处理,已知键非字符串值无损保留", () => {
|
||||||
|
expect(parseSiteConfig("这不是json{").fields).toEqual({});
|
||||||
|
expect(parseSiteConfig(null).extras).toEqual([]);
|
||||||
|
expect(parseSiteConfig("").preserved).toEqual({});
|
||||||
|
const m = parseSiteConfig(JSON.stringify({ logo: 123, other: [1, 2] }));
|
||||||
|
expect(m.fields.logo).toBeUndefined();
|
||||||
|
expect(m.preserved.logo).toBe(123);
|
||||||
|
expect(m.preserved.other).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serialize:空值剔除、扩展行空键/空值剔除、preserved 永不丢失", () => {
|
||||||
|
const m = parseSiteConfig(JSON.stringify({ logo: "/cms/upload/logo.png", theme_params: { primary: "#fff" }, wechat: { x: 1 } }));
|
||||||
|
m.fields.contact = " "; // 空白 → 剔除
|
||||||
|
m.extras.push({ key: "k1", value: "v1" });
|
||||||
|
m.extras.push({ key: "", value: "被剔除" });
|
||||||
|
m.extras.push({ key: "k2", value: " " }); // 空值 → 剔除
|
||||||
|
const out = JSON.parse(serializeSiteConfig(m));
|
||||||
|
expect(out.logo).toBe("/cms/upload/logo.png");
|
||||||
|
expect(out).not.toHaveProperty("contact");
|
||||||
|
expect(out.k1).toBe("v1");
|
||||||
|
expect(out).not.toHaveProperty("k2");
|
||||||
|
expect(out.theme_params).toEqual({ primary: "#fff" });
|
||||||
|
expect(out.wechat).toEqual({ x: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("roundtrip:parse→serialize→parse 内容不丢(含扩展行与 preserved)", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
copyright: "XX学校 · 版权所有 © 2026",
|
||||||
|
seo_description: "校讯",
|
||||||
|
my_ext: "v",
|
||||||
|
theme_params: { bg: "#f7f5f2" },
|
||||||
|
});
|
||||||
|
const once = parseSiteConfig(json);
|
||||||
|
once.fields.slogan = "厚德博学";
|
||||||
|
const twice = parseSiteConfig(serializeSiteConfig(once));
|
||||||
|
expect(twice.fields.slogan).toBe("厚德博学");
|
||||||
|
expect(twice.fields.copyright).toBe("XX学校 · 版权所有 © 2026");
|
||||||
|
expect(twice.extras).toEqual([{ key: "my_ext", value: "v" }]);
|
||||||
|
expect(twice.preserved.theme_params).toEqual({ bg: "#f7f5f2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateFieldValue:url/host/asset/text 各类型通过与拒绝", () => {
|
||||||
|
const def = (kind: string, maxLength?: number) =>
|
||||||
|
SITE_CONFIG_FIELD_DEFS.find(d => d.key === { url: "public_base_url", host: "domain", asset: "logo", text: "copyright" }[kind])!;
|
||||||
|
expect(validateFieldValue(def("url"), "https://www.xxx.edu.cn")).toBe("");
|
||||||
|
expect(validateFieldValue(def("url"), "www.xxx.edu.cn")).toContain("http");
|
||||||
|
expect(validateFieldValue(def("host"), "www.xxx.edu.cn")).toBe("");
|
||||||
|
expect(validateFieldValue(def("host"), "https://www.xxx.edu.cn")).toContain("域名");
|
||||||
|
expect(validateFieldValue(def("asset"), "/cms/upload/a.png")).toBe("");
|
||||||
|
expect(validateFieldValue(def("asset"), "https://cdn.example.com/a.png")).toBe("");
|
||||||
|
expect(validateFieldValue(def("asset"), "javascript:alert(1)")).toContain("路径");
|
||||||
|
expect(validateFieldValue(def("text"), "正常文字")).toBe("");
|
||||||
|
expect(validateFieldValue({ key: "t", label: "t", group: "footer", kind: "text", maxLength: 5 }, "超过五个字的文案")).toContain("5");
|
||||||
|
expect(validateFieldValue(def("url"), "")).toBe(""); // 空值一律放行(非必填)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extraRowError:格式/重名/重复校验;空键行放行", () => {
|
||||||
|
const rows = [{ key: "my_ext", value: "v" }];
|
||||||
|
expect(extraRowError(rows, 0)).toBe("");
|
||||||
|
expect(extraRowError([{ key: "", value: "v" }], 0)).toBe("");
|
||||||
|
expect(extraRowError([{ key: "my-ext", value: "v" }], 0)).toContain("下划线");
|
||||||
|
expect(extraRowError([{ key: "1bad", value: "v" }], 0)).toContain("下划线");
|
||||||
|
expect(extraRowError([{ key: "logo", value: "v" }], 0)).toContain("重名");
|
||||||
|
expect(extraRowError([{ key: "theme_params", value: "v" }], 0)).toContain("重名");
|
||||||
|
expect(extraRowError([{ key: "a", value: "1" }, { key: "a", value: "2" }], 1)).toContain("重复");
|
||||||
|
expect(EXTRA_KEY_RE.test("wechat_appid")).toBe(true);
|
||||||
|
expect(EXTRA_KEY_RE.test("_priv")).toBe(true);
|
||||||
|
expect(EXTRA_KEY_RE.test("9bad")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("firstExtraError:返回第一条错误;全部合法返回空串", () => {
|
||||||
|
expect(firstExtraError([{ key: "ok_key", value: "1" }, { key: "bad-key", value: "2" }])).toContain("下划线");
|
||||||
|
expect(firstExtraError([{ key: "a", value: "1" }, { key: "b", value: "2" }])).toBe("");
|
||||||
|
expect(firstExtraError([])).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已知键清单与 docs/09 §0 键盘点表一致(防漂移)", () => {
|
||||||
|
expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([
|
||||||
|
"logo", "favicon", "slogan", "colleges_label",
|
||||||
|
"contact", "copyright", "beian",
|
||||||
|
"seo_description",
|
||||||
|
"domain", "public_base_url",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* 站点配置结构化表单纯函数模块(docs/09 §3.1)。
|
||||||
|
* 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);未知字符串键 → 可编辑扩展行
|
||||||
|
* (Projman「添加项」模式);未知非字符串键(theme_params 等系统托管键)原样保留无损回写。
|
||||||
|
* vitest 单测对象:siteConfigForm.spec.ts,不依赖组件挂载基建。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SiteConfigGroup = "display" | "footer" | "seo" | "domain";
|
||||||
|
|
||||||
|
export interface SiteConfigFieldDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
group: SiteConfigGroup;
|
||||||
|
placeholder?: string;
|
||||||
|
tip?: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
/** url: http(s) URL;host: 域名(不带协议);asset: / 开头路径或 http(s) URL;text: 仅长度 */
|
||||||
|
kind?: "text" | "url" | "host" | "asset";
|
||||||
|
maxLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 SiteService.validateConfigJson 的已知键约束保持一致 */
|
||||||
|
export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
|
||||||
|
{
|
||||||
|
key: "logo", label: "站点 Logo", group: "display", kind: "asset", maxLength: 500,
|
||||||
|
placeholder: "如 /cms/upload/2026/06/logo.png 或 https://…",
|
||||||
|
tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500,
|
||||||
|
placeholder: "如 /cms/upload/…/favicon.png(留空用主题内置图标)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200,
|
||||||
|
placeholder: "门户顶栏展示的一句话标语(可选)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "colleges_label", label: "院系下拉文案", group: "display", kind: "text", maxLength: 50,
|
||||||
|
placeholder: "留空默认「专业院系」",
|
||||||
|
tip: "门户右上「专业院系」下拉的文案;仅主站有效(下拉由院系子站生成)。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
|
||||||
|
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
|
||||||
|
placeholder: "如:XX学校 · 版权所有 © 2026",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100,
|
||||||
|
placeholder: "如:浙ICP备XXXXXXXX号-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true,
|
||||||
|
placeholder: "搜索引擎摘要(meta description,建议 80~160 字)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "domain", label: "绑定域名", group: "domain", kind: "host", maxLength: 200,
|
||||||
|
placeholder: "如 www.xxx.edu.cn(不带 https://)",
|
||||||
|
tip: "学校独立域名台账;DNS 解析生效后需配合填写对外基地址。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "public_base_url", label: "对外基地址", group: "domain", kind: "url", maxLength: 500,
|
||||||
|
placeholder: "如 https://www.xxx.edu.cn",
|
||||||
|
tip: "绑定独立域名后必填;未填时全站链接走 base 域子路径形态(/cms/portal/{站点编码})。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [
|
||||||
|
{ name: "display", title: "基础展示" },
|
||||||
|
{ name: "footer", title: "页脚信息" },
|
||||||
|
{ name: "seo", title: "SEO" },
|
||||||
|
{ name: "domain", title: "域名与访问" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护) */
|
||||||
|
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params"];
|
||||||
|
|
||||||
|
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */
|
||||||
|
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
|
||||||
|
export const HOST_RE = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
|
||||||
|
export const URL_RE = /^https?:\/\/\S+$/;
|
||||||
|
export const ASSET_RE = /^(\/.+|https?:\/\/\S+)$/;
|
||||||
|
|
||||||
|
export interface SiteConfigExtraRow {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteConfigFormModel {
|
||||||
|
fields: Record<string, string>;
|
||||||
|
extras: SiteConfigExtraRow[];
|
||||||
|
/** 系统托管键 + 非字符串未知键,无损保留 */
|
||||||
|
preserved: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** configJson → 表单模型:已知键入 fields、未知字符串键入扩展行、其余无损入 preserved */
|
||||||
|
export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel {
|
||||||
|
const model: SiteConfigFormModel = { fields: {}, extras: [], preserved: {} };
|
||||||
|
if (!configJson || !configJson.trim()) {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
let parsed: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(configJson);
|
||||||
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||||
|
parsed = obj;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 非法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
|
||||||
|
}
|
||||||
|
if (!parsed) {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
|
||||||
|
const reserved = new Set(RESERVED_SITE_CONFIG_KEYS);
|
||||||
|
Object.keys(parsed).forEach(k => {
|
||||||
|
const v = parsed![k];
|
||||||
|
if (typeof v !== "string") {
|
||||||
|
model.preserved[k] = v;
|
||||||
|
} else if (known.has(k)) {
|
||||||
|
model.fields[k] = v;
|
||||||
|
} else if (reserved.has(k)) {
|
||||||
|
model.preserved[k] = v;
|
||||||
|
} else {
|
||||||
|
model.extras.push({ key: k, value: v });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表单模型 → configJson:空值剔除;扩展行空键/空值剔除;preserved 原样回写(永不丢失) */
|
||||||
|
export function serializeSiteConfig(model: SiteConfigFormModel): string {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
SITE_CONFIG_FIELD_DEFS.forEach(def => {
|
||||||
|
const v = (model.fields[def.key] || "").trim();
|
||||||
|
if (v) {
|
||||||
|
out[def.key] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(model.extras || []).forEach(row => {
|
||||||
|
const k = (row.key || "").trim();
|
||||||
|
const v = (row.value || "").trim();
|
||||||
|
if (k && v) {
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.keys(model.preserved || {}).forEach(k => {
|
||||||
|
out[k] = model.preserved[k];
|
||||||
|
});
|
||||||
|
return JSON.stringify(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */
|
||||||
|
export function validateFieldValue(def: SiteConfigFieldDef, value: string): string {
|
||||||
|
const v = (value || "").trim();
|
||||||
|
if (!v) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
switch (def.kind) {
|
||||||
|
case "url":
|
||||||
|
return URL_RE.test(v) ? "" : "须为完整的 http(s):// 地址";
|
||||||
|
case "host":
|
||||||
|
return HOST_RE.test(v) ? "" : "须为域名形式(如 www.xxx.edu.cn,不带协议)";
|
||||||
|
case "asset":
|
||||||
|
return ASSET_RE.test(v) ? "" : "须为站点上传路径(/ 开头)或 http(s) 地址";
|
||||||
|
default:
|
||||||
|
return def.maxLength && v.length > def.maxLength ? `不能超过 ${def.maxLength} 字` : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扩展行校验:键名格式 / 与内置键或系统键冲突 / 行间重复;返回错误消息,空串=通过(空键行视为待填) */
|
||||||
|
export function extraRowError(rows: SiteConfigExtraRow[], index: number): string {
|
||||||
|
const row = rows[index];
|
||||||
|
const key = (row?.key || "").trim();
|
||||||
|
if (!key) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!EXTRA_KEY_RE.test(key)) {
|
||||||
|
return "键名仅限字母/数字/下划线,且以字母或下划线开头";
|
||||||
|
}
|
||||||
|
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
|
||||||
|
if (known.has(key) || RESERVED_SITE_CONFIG_KEYS.includes(key)) {
|
||||||
|
return "扩展键不能与内置键/系统键重名";
|
||||||
|
}
|
||||||
|
const dup = rows.findIndex(r => (r.key || "").trim() === key);
|
||||||
|
if (dup >= 0 && dup !== index) {
|
||||||
|
return "扩展键重复";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存前兜底:返回第一条扩展行错误,空串=全部通过 */
|
||||||
|
export function firstExtraError(rows: SiteConfigExtraRow[]): string {
|
||||||
|
for (let i = 0; i < (rows || []).length; i++) {
|
||||||
|
const msg = extraRowError(rows, i);
|
||||||
|
if (msg) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
COLOR_RE,
|
||||||
|
formModelFromTheme,
|
||||||
|
invalidParamKeys,
|
||||||
|
isValidParamValue,
|
||||||
|
mergeThemeParamsIntoConfigJson,
|
||||||
|
themeParamsFromConfigJson,
|
||||||
|
} from "./themeParamForm";
|
||||||
|
import { ThemeVo } from "@/types/cms";
|
||||||
|
|
||||||
|
/** 与后端 default/t-demo-red schema 对齐的夹具(§4.2) */
|
||||||
|
function theme(): ThemeVo {
|
||||||
|
return {
|
||||||
|
name: "t-demo-red",
|
||||||
|
title: "测试红(夹具)",
|
||||||
|
screenshotUrl: "/api/theme/t-demo-red/asset/screenshot.png",
|
||||||
|
params: [
|
||||||
|
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#b01a2e" },
|
||||||
|
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#f7f5f2" },
|
||||||
|
{
|
||||||
|
key: "headerStyle",
|
||||||
|
label: "头部样式",
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "banner",
|
||||||
|
options: [{ value: "banner", label: "横幅头图" }, { value: "plain", label: "简洁顶栏" }],
|
||||||
|
},
|
||||||
|
{ key: "logoImage", label: "头图", type: "image", defaultValue: "" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("themeParamForm(方案 §6.3/§8.4)", () => {
|
||||||
|
it("T28: schema→表单模型:四类型各映射为对应生效值 + 默认值兜底", () => {
|
||||||
|
const model = formModelFromTheme(theme());
|
||||||
|
expect(model.primary).toBe("#b01a2e");
|
||||||
|
expect(model.bg).toBe("#f7f5f2");
|
||||||
|
expect(model.headerStyle).toBe("banner");
|
||||||
|
expect(model.logoImage).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T28: 已有合法值保留,非法显式值回退默认", () => {
|
||||||
|
const model = formModelFromTheme(theme(), {
|
||||||
|
primary: "#123456", // 合法,保留
|
||||||
|
bg: "red;}", // 非法,回退默认
|
||||||
|
headerStyle: "plain", // select 枚举内合法
|
||||||
|
logoImage: "javascript:alert(1)", // 非法 image
|
||||||
|
});
|
||||||
|
expect(model.primary).toBe("#123456");
|
||||||
|
expect(model.bg).toBe("#f7f5f2");
|
||||||
|
expect(model.headerStyle).toBe("plain");
|
||||||
|
expect(model.logoImage).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T29: 值校验 —— color 正则通过/拒绝", () => {
|
||||||
|
expect(COLOR_RE.test("#b01a2e")).toBe(true);
|
||||||
|
expect(COLOR_RE.test("#fff")).toBe(true);
|
||||||
|
expect(COLOR_RE.test("#b01a2eff")).toBe(true);
|
||||||
|
expect(COLOR_RE.test("red")).toBe(false);
|
||||||
|
expect(COLOR_RE.test("red;}")).toBe(false);
|
||||||
|
expect(COLOR_RE.test("#gggggg")).toBe(false);
|
||||||
|
expect(COLOR_RE.test("javascript:alert(1)")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T29: select 枚举外拒绝;image 非 /cms/upload/** 拒绝;空值一律不合法", () => {
|
||||||
|
const t = theme();
|
||||||
|
const select = t.params.find(p => p.key === "headerStyle")!;
|
||||||
|
const image = t.params.find(p => p.key === "logoImage")!;
|
||||||
|
const color = t.params.find(p => p.key === "primary")!;
|
||||||
|
expect(isValidParamValue(select, "plain")).toBe(true);
|
||||||
|
expect(isValidParamValue(select, "hacker")).toBe(false);
|
||||||
|
// B-7:与应用真实路径对齐(context-path=/cms),旧 /upload/** 前缀不再放行
|
||||||
|
expect(isValidParamValue(image, "/cms/upload/cms/logo.png")).toBe(true);
|
||||||
|
expect(isValidParamValue(image, "/upload/cms/logo.png")).toBe(false);
|
||||||
|
expect(isValidParamValue(image, "https://evil/x.png")).toBe(false);
|
||||||
|
expect(isValidParamValue(color, "")).toBe(false);
|
||||||
|
expect(isValidParamValue(color, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T29: invalidParamKeys 收集非法值 key", () => {
|
||||||
|
const bad = invalidParamKeys(theme(), {
|
||||||
|
primary: "#123456",
|
||||||
|
headerStyle: "not-in-enum",
|
||||||
|
});
|
||||||
|
expect(bad).toContain("headerStyle");
|
||||||
|
expect(bad).not.toContain("primary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T30: themeParams↔configJson 装拆 —— 合并保留其余键 + 丢弃未声明键", () => {
|
||||||
|
const t = theme();
|
||||||
|
const merged = mergeThemeParamsIntoConfigJson(
|
||||||
|
'{"logo":"/upload/cms/logo.png","contact":"0571-88888888"}',
|
||||||
|
t,
|
||||||
|
{ primary: "#ff0000", headerStyle: "plain", zzhack: "zzvalue" },
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(merged);
|
||||||
|
expect(parsed.logo).toBe("/upload/cms/logo.png");
|
||||||
|
expect(parsed.contact).toBe("0571-88888888");
|
||||||
|
expect(parsed.theme_params.primary).toBe("#ff0000");
|
||||||
|
expect(parsed.theme_params.headerStyle).toBe("plain");
|
||||||
|
expect(parsed.theme_params).not.toHaveProperty("zzhack"); // 未声明 key 丢弃(后端同语义)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T30: 空/非法 configJson 兼容;无有效参数不写入 theme_params 键", () => {
|
||||||
|
const t = theme();
|
||||||
|
const merged = mergeThemeParamsIntoConfigJson("{bad json", t, {});
|
||||||
|
expect(JSON.parse(merged)).toEqual({});
|
||||||
|
|
||||||
|
const merged2 = mergeThemeParamsIntoConfigJson(
|
||||||
|
'{"theme_params":{"primary":"#b01a2e"},"logo":"x"}',
|
||||||
|
t,
|
||||||
|
{ headerStyle: "banner" },
|
||||||
|
);
|
||||||
|
const parsed2 = JSON.parse(merged2);
|
||||||
|
expect(parsed2.logo).toBe("x");
|
||||||
|
expect(parsed2.theme_params).toEqual({ headerStyle: "banner" }); // 旧的非法/未更新值被替换
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T30: configJson → themeParams 编辑回显(仅声明 key + 非法值丢弃)", () => {
|
||||||
|
const t = theme();
|
||||||
|
const model = themeParamsFromConfigJson(
|
||||||
|
'{"theme_params":{"primary":"#b01a2e","zzhack":"x","headerStyle":"plain"}}',
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
expect(model.primary).toBe("#b01a2e");
|
||||||
|
expect(model.headerStyle).toBe("plain");
|
||||||
|
expect(model.zzhack).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { ThemeParamVo, ThemeVo } from "@/types/cms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* themeParams 纯函数模块(方案 §6.3,评审 R10/R6):schema → 表单模型映射、
|
||||||
|
* 值校验(color 正则 / select 枚举 / image 仅 /upload/**)、themeParams ↔ configJson 装拆。
|
||||||
|
* 与后端合并语义(方案 §5.3)保持一致:仅替换 theme_params 键、保留其余键、丢弃未声明键。
|
||||||
|
* vitest 单测对象(§8.4 T28-T30),不依赖组件挂载基建。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** color 合法值:#RGB/#RRGGBB/#RRGGBBAA(与后端 ThemeParam.COLOR_PATTERN 一致) */
|
||||||
|
export const COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
|
|
||||||
|
/** param key 约束(与后端 KEY_PATTERN 一致) */
|
||||||
|
export const KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,31}$/;
|
||||||
|
|
||||||
|
export interface FormModelValue {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单值校验(与后端 ThemeParam.isValidValue 同语义;null/空一律不合法) */
|
||||||
|
export function isValidParamValue(param: ThemeParamVo, value: string | undefined | null): boolean {
|
||||||
|
if (value === undefined || value === null || value === "") return false;
|
||||||
|
switch (param.type) {
|
||||||
|
case "color":
|
||||||
|
return COLOR_RE.test(value);
|
||||||
|
case "image":
|
||||||
|
// B-7:与后端对齐——附件 webUrl 相对形态含 context-path(/cms/upload/**)
|
||||||
|
return value.startsWith("/cms/upload/");
|
||||||
|
case "select":
|
||||||
|
return !!param.options?.some(o => o.value === value);
|
||||||
|
case "text":
|
||||||
|
return value.length <= 256;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取参数生效值:显式值合法用之;否则回退 defaultValue(缺省也视为合法默认) */
|
||||||
|
export function effectiveValue(param: ThemeParamVo, model: FormModelValue): string {
|
||||||
|
const raw = model[param.key];
|
||||||
|
if (isValidParamValue(param, raw)) return raw as string;
|
||||||
|
return param.defaultValue ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** T28: schema → 表单模型(声明过的 key 全部带上生效值) */
|
||||||
|
export function formModelFromTheme(theme: ThemeVo, existing: FormModelValue = {}): FormModelValue {
|
||||||
|
const model: FormModelValue = {};
|
||||||
|
theme.params.forEach(p => {
|
||||||
|
const raw = existing[p.key];
|
||||||
|
model[p.key] = isValidParamValue(p, raw) ? (raw as string) : (p.defaultValue ?? "");
|
||||||
|
});
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** T28: 值校验兜底 —— 收集当前表单模型里的非法值 key(空值不报,默认值兜底) */
|
||||||
|
export function invalidParamKeys(theme: ThemeVo, model: FormModelValue): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
theme.params.forEach(p => {
|
||||||
|
const v = model[p.key];
|
||||||
|
if (v !== undefined && v !== "" && !isValidParamValue(p, v)) {
|
||||||
|
result.push(p.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** T30a: 服务端合并语义(与后端 §5.3 一致)—— 仅替换 theme_params、保留其余键、丢弃未声明/非法值 */
|
||||||
|
export function mergeThemeParamsIntoConfigJson(configJson: string | undefined | null,
|
||||||
|
theme: ThemeVo,
|
||||||
|
model: FormModelValue): string {
|
||||||
|
let config: Record<string, unknown> = {};
|
||||||
|
if (configJson && configJson.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configJson);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
config = { ...parsed };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 非合法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const filtered: Record<string, string> = {};
|
||||||
|
theme.params.forEach(p => {
|
||||||
|
const v = model[p.key];
|
||||||
|
if (isValidParamValue(p, v)) {
|
||||||
|
filtered[p.key] = v as string;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(filtered).length === 0) {
|
||||||
|
delete config.theme_params;
|
||||||
|
} else {
|
||||||
|
config.theme_params = filtered;
|
||||||
|
}
|
||||||
|
return JSON.stringify(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** T30b: configJson → themeParams(编辑回显:仅取声明过的 key,非法值丢弃) */
|
||||||
|
export function themeParamsFromConfigJson(configJson: string | undefined | null,
|
||||||
|
theme: ThemeVo): FormModelValue {
|
||||||
|
const model: FormModelValue = {};
|
||||||
|
if (!configJson) return model;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configJson);
|
||||||
|
const tp = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>).theme_params
|
||||||
|
: undefined;
|
||||||
|
if (tp && typeof tp === "object") {
|
||||||
|
theme.params.forEach(p => {
|
||||||
|
const raw = (tp as Record<string, unknown>)[p.key];
|
||||||
|
const v = typeof raw === "string" ? raw : "";
|
||||||
|
if (isValidParamValue(p, v)) model[p.key] = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 非合法 JSON 按无参数处理
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题是否声明了某类型参数(组件渲染分支用) */
|
||||||
|
export function hasParamType(theme: ThemeVo | undefined | null, type: string): boolean {
|
||||||
|
return !!theme?.params?.some(p => p.type === type);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||