Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9719009b3 | ||
|
|
02bb3ac96a | ||
|
|
fe75784d13 | ||
|
|
01d91ddd4b | ||
|
|
39dfc645ff | ||
|
|
4208f8ca89 | ||
|
|
cff4325947 | ||
|
|
b6b022e4ca | ||
|
|
6334b24d34 | ||
|
|
49678a6568 | ||
|
|
1a913490ef | ||
|
|
af451703e7 | ||
|
|
010fb39e85 | ||
|
|
3a0f1e0cc9 | ||
|
|
649a08629b | ||
|
|
31cbec152a | ||
|
|
5d048abdae | ||
|
|
d05272b820 | ||
|
|
abccca4397 | ||
|
|
e5a9506228 | ||
|
|
be19ed066a | ||
|
|
f29c193ea7 | ||
|
|
6f1f666479 | ||
|
|
a05b0ded55 | ||
|
|
34733ec280 | ||
|
|
7c925cd4f2 | ||
|
|
dfd2b89fbd | ||
|
|
6ee9ab5684 | ||
|
|
9ba4cb135a | ||
|
|
604d77049b | ||
|
|
4b93b131c5 | ||
|
|
198175975b | ||
|
|
d8ac455428 | ||
|
|
dedd4a6a3a | ||
|
|
7b99ec398d | ||
|
|
1e887906eb | ||
|
|
2a077aa917 | ||
|
|
fc566eed8b | ||
|
|
e494a2d2d8 | ||
|
|
bb5c5e513f | ||
|
|
bcd639cfc2 | ||
|
|
3990811ed5 | ||
|
|
c77be8b5d8 | ||
|
|
67a7164897 | ||
|
|
506fae0097 | ||
|
|
82eb067b19 | ||
|
|
a3a75c3680 |
@@ -0,0 +1,8 @@
|
||||
# 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*(线上由 env-config 下发)。
|
||||
# API 走 /api 同源相对路径(serviceHost 留空 + vite proxy 改写转发到本地后端 8072 的 /cms/api),
|
||||
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境。
|
||||
# ⚠️ VITE_* 仅本地开发生效,线上一律走 /api/env-config?target=admin 运行时配置。
|
||||
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
|
||||
@@ -19,7 +19,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl / zip(alpine)
|
||||
run: apk add --no-cache git curl zip
|
||||
run: |
|
||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
apk add --no-cache git curl zip
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -38,24 +41,34 @@ jobs:
|
||||
- name: 构建前端(node 容器)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f frontbuild 2>/dev/null || true
|
||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." frontbuild:/build/
|
||||
docker exec frontbuild sh -c '
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上是共享
|
||||
# 命名空间。旧写法固定名 frontbuild 会在多仓/多条 workflow 并发时互相 docker rm -f 掉
|
||||
# 对方正在构建的容器,表现为无编译报错的 exit 137(SIGKILL,2026-09-10 三仓同推即撞)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底(手动重跑/未注入时也不撞名)。
|
||||
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
docker exec "$BUILD_CONTAINER" sh -c '
|
||||
set -e
|
||||
cd /build
|
||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
||||
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
npm ci
|
||||
npm run build
|
||||
'
|
||||
echo "=== dist 产物 ==="
|
||||
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
|
||||
# 从容器 cp dist 到宿主工作目录
|
||||
docker cp frontbuild:/build/dist ./dist
|
||||
docker rm -f frontbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
echo "BUILD CHECK OK"
|
||||
|
||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||
# 打包 dist → zip(vite outDir 已简化为直出 dist/,无中间层;进入 dist 打包,
|
||||
# zip 根目录直接是 index.html/css/js。此前 outDir=dist/base 时曾误打 dist/ 整层,
|
||||
# zip 根只有 base/ 目录,projman 前端发布校验报「zip 根目录缺少 html 文件」)
|
||||
- name: 打包产物
|
||||
run: |
|
||||
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
@@ -72,7 +85,7 @@ jobs:
|
||||
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
|
||||
run: |
|
||||
NEXUS_URL="http://nexus.yunhaixingtong.com:8081/repository/front-hosted/${ZIP_FILE}"
|
||||
curl -sS -X PUT \
|
||||
curl -sSf -X PUT \
|
||||
-u "${NEXUS_USER}:${NEXUS_PASSWORD}" \
|
||||
--upload-file "${ZIP_FILE}" \
|
||||
"${NEXUS_URL}"
|
||||
@@ -82,7 +95,7 @@ jobs:
|
||||
# 通知 projman:webhook 收到后建档(type=front, status=pending),部署由用户在「前端发布」Tab 手动触发
|
||||
- name: 通知 projman 建档
|
||||
run: |
|
||||
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
|
||||
curl -sSf -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
|
||||
-d "{\"app\":\"yschcms\",\"front\":\"front\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\",\"nexusUrl\":\"${NEXUS_URL}\"}"
|
||||
|
||||
@@ -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,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<contextPath>/api/env-config
|
||||
(后端免登录端点,projman 配置管理在线维护),替代生产构建环境变量。
|
||||
必须同步:host.config.ts 在模块求值期就读 window.__APP_CONFIG__;失败/本地开发
|
||||
(根路径,段数不足 2)则不设置:本地走 .env.dev/.env.mock(VITE_*),线上走同域推导兜底。
|
||||
target = URL 第 2 段目录名:管理端产物部署于 /{contextPath}/admin/,故为 admin,
|
||||
对应后端配置键 yh.env-config.source.admin。
|
||||
注意:同步 XHR 不支持 timeout 属性(赋值会抛 InvalidAccessError),依赖内网快速失败 -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var seg = location.pathname.split("/").filter(Boolean);
|
||||
if (seg.length < 2) return; // 本地开发(根路径):走 .env.dev / .env.mock
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
|
||||
xhr.send(null);
|
||||
if (xhr.status === 200) {
|
||||
var conf = JSON.parse(xhr.responseText);
|
||||
if (conf && typeof conf === "object") window.__APP_CONFIG__ = conf;
|
||||
}
|
||||
} catch (e) {
|
||||
/* 拉取失败保持未设置,走同域推导兜底 */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<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,510 @@
|
||||
/**
|
||||
* Mock 内存数据库 + 种子数据(仅 dev mock 模式使用,vite.config 按需加载)。
|
||||
* 数据形状与 com.yunhai.cms.model.vo 一一对应;时间串用 "yyyy-MM-dd HH:mm:ss"。
|
||||
*/
|
||||
|
||||
/* ===== 行类型 ===== */
|
||||
|
||||
export interface MockSite {
|
||||
id: number;
|
||||
code: string;
|
||||
/** 门户路径(W4:顶层=code;子站=父path/code) */
|
||||
path: 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;
|
||||
/** 场景引用类型(B2):banner/link/site;空=未使用 */
|
||||
refType?: string;
|
||||
refId?: 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 MockSvcDict {
|
||||
id: number;
|
||||
siteId: number;
|
||||
dictType: "object" | "category";
|
||||
dictKey: string;
|
||||
dictName: string;
|
||||
sortNo: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface MockSvcItem {
|
||||
id: number;
|
||||
siteId: number;
|
||||
itemCode: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
objectKey?: string;
|
||||
categoryKey?: string;
|
||||
deptGroupId?: number;
|
||||
deptName?: string;
|
||||
handleMode: number;
|
||||
onlineFlag: number;
|
||||
handlePlace?: string;
|
||||
handleTimeLimit?: string;
|
||||
promiseTimeLimit?: string;
|
||||
contactPhone?: string;
|
||||
supervisePhone?: string;
|
||||
processHtml?: string;
|
||||
materialHtml?: string;
|
||||
noticeHtml?: string;
|
||||
faqCategoryId?: number;
|
||||
noticeCategoryId?: number;
|
||||
ruleCategoryId?: number;
|
||||
coverImage?: string;
|
||||
sortNo: number;
|
||||
status: number;
|
||||
publishTime?: string;
|
||||
offlineTime?: string;
|
||||
viewCount: number;
|
||||
extJson?: string;
|
||||
attachmentIds?: number[];
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export interface MockDb {
|
||||
sites: MockSite[];
|
||||
categories: MockCategory[];
|
||||
articles: MockArticle[];
|
||||
attachments: MockAttachment[];
|
||||
banners: MockBanner[];
|
||||
links: MockLink[];
|
||||
svcItems: MockSvcItem[];
|
||||
svcDicts: MockSvcDict[];
|
||||
/** 上传文件缓存: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: [],
|
||||
svcItems: [],
|
||||
svcDicts: [],
|
||||
files: new Map(),
|
||||
};
|
||||
|
||||
/* ---- 站点(demo 为主站 + jdx/xxgc 院系子站;fsz 独立顶层站无子站) ---- */
|
||||
db.sites.push(
|
||||
{
|
||||
id: 1,
|
||||
code: "demo",
|
||||
path: "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",
|
||||
path: "fsz",
|
||||
parentSiteId: 0,
|
||||
schId: 1002,
|
||||
schName: "云海大学附属中学",
|
||||
name: "附中信息门户",
|
||||
theme: "blue-white",
|
||||
configJson: JSON.stringify({ copyright: "云大附中 · Mock 数据" }),
|
||||
status: 1,
|
||||
createTime: daysAgo(60),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
code: "jdx",
|
||||
path: "demo/jdx",
|
||||
parentSiteId: 1,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
name: "机电学院",
|
||||
theme: "academic-navy",
|
||||
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",
|
||||
path: "demo/xxgc",
|
||||
parentSiteId: 1,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
name: "信息工程学院",
|
||||
theme: "campus-azure",
|
||||
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 }),
|
||||
// B2 场景引用:轮播图/友链 Logo 附件带 refType/refId(与真实后端形态一致)
|
||||
at({ fileName: "banner-1.png", url: "/mock-img/banner-1.svg", mimeType: "image/png", refType: "banner", refId: 1 }),
|
||||
at({ fileName: "banner-2.png", url: "/mock-img/banner-2.svg", mimeType: "image/png", refType: "banner", refId: 2 }),
|
||||
at({ fileName: "banner-3.png", url: "/mock-img/banner-3.svg", mimeType: "image/png", refType: "banner", refId: 3 }),
|
||||
at({ fileName: "logo-教育部.png", url: "/mock-img/logo-1.svg", mimeType: "image/png", refType: "link", refId: 1 }),
|
||||
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" })
|
||||
);
|
||||
|
||||
/* ---- 事项字典 + 办事事项(事项库 R2;demo 站为主,另给附中一条便于切换站点观察) ---- */
|
||||
let sdid = 0;
|
||||
const sd = (o: Partial<MockSvcDict> & { dictType: "object" | "category"; dictKey: string; dictName: string }) => ({
|
||||
id: ++sdid,
|
||||
siteId: 1,
|
||||
sortNo: sdid,
|
||||
status: 1,
|
||||
...o,
|
||||
} as MockSvcDict);
|
||||
db.svcDicts.push(
|
||||
sd({ dictType: "object", dictKey: "person", dictName: "个人" }),
|
||||
sd({ dictType: "object", dictKey: "legal", dictName: "法人" }),
|
||||
sd({ dictType: "object", dictKey: "other", dictName: "其他组织" }),
|
||||
sd({ dictType: "category", dictKey: "jw", dictName: "教务服务" }),
|
||||
sd({ dictType: "category", dictKey: "hq", dictName: "后勤服务" }),
|
||||
sd({ dictType: "category", dictKey: "xs", dictName: "学生事务" }),
|
||||
sd({ dictType: "category", dictKey: "stop", dictName: "已停用类别", status: 0 }),
|
||||
sd({ siteId: 2, dictType: "object", dictKey: "person", dictName: "个人" }),
|
||||
sd({ siteId: 2, dictType: "category", dictKey: "jw", dictName: "教务服务" })
|
||||
);
|
||||
|
||||
let siid = 0;
|
||||
const si = (o: Partial<MockSvcItem> & { itemCode: string; title: string }) => ({
|
||||
id: ++siid,
|
||||
siteId: 1,
|
||||
handleMode: 1,
|
||||
onlineFlag: 1,
|
||||
sortNo: siid,
|
||||
status: 1,
|
||||
viewCount: Math.floor(Math.random() * 800),
|
||||
processHtml: para("申请人携带材料到服务窗口提出申请。", "窗口受理后 1 个工作日内完成审核。"),
|
||||
materialHtml: para("1. 申请表(现场领取)", "2. 本人身份证明", "3. 相关证明材料"),
|
||||
noticeHtml: para("材料齐全当日办结;如需补正,以窗口告知为准。"),
|
||||
extJson: JSON.stringify({ apply_url: "https://svhall.example.com/#/apply" }),
|
||||
createTime: daysAgo(siid * 3),
|
||||
...o,
|
||||
} as MockSvcItem);
|
||||
db.svcItems.push(
|
||||
si({ itemCode: "BG-ZX-001", title: "转学办理", summary: "适用于在校学生转学申请", objectKey: "person", categoryKey: "jw", deptGroupId: 10, deptName: "教务处", handleMode: 1, handleTimeLimit: "3 个工作日", contactPhone: "0571-88888888", publishTime: daysAgo(12) }),
|
||||
si({ itemCode: "BG-XJ-002", title: "休学办理", summary: "因病或其他原因申请休学", objectKey: "person", categoryKey: "jw", deptGroupId: 10, deptName: "教务处", handleMode: 3, handleTimeLimit: "5 个工作日", publishTime: daysAgo(10) }),
|
||||
si({ itemCode: "BG-HQ-100", title: "宿舍报修", summary: "学生宿舍水电与设施报修", objectKey: "person", categoryKey: "hq", deptGroupId: 20, deptName: "后勤处", handleMode: 2, onlineFlag: 0, handlePlace: "后勤服务大厅", publishTime: daysAgo(8) }),
|
||||
si({ itemCode: "BG-XS-210", title: "学生证补办", summary: "学生证遗失后的补办申请", objectKey: "person", categoryKey: "xs", deptGroupId: 30, deptName: "学生工作处", handleMode: 1, publishTime: daysAgo(6) }),
|
||||
si({ itemCode: "BG-FR-300", title: "法人年检材料受理", summary: "面向法人单位的年检材料受理", objectKey: "legal", categoryKey: "jw", deptGroupId: 10, deptName: "教务处", handleMode: 2, coverImage: "/mock-img/banner-3.svg", publishTime: daysAgo(4) }),
|
||||
si({ itemCode: "BG-DRAFT-001", title: "校园卡挂失(草稿)", objectKey: "person", categoryKey: "xs", status: 0, handleMode: 1 }),
|
||||
si({ itemCode: "BG-OFF-001", title: "教室借用申请(已下架)", objectKey: "person", categoryKey: "hq", status: 2, handleMode: 2, publishTime: daysAgo(20), offlineTime: daysAgo(2) }),
|
||||
si({ siteId: 2, itemCode: "FZ-001", title: "附中转学办理", objectKey: "person", categoryKey: "jw", deptName: "附中教务处", status: 1, publishTime: daysAgo(5) })
|
||||
);
|
||||
|
||||
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,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,47 @@
|
||||
{
|
||||
"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",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"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,249 @@
|
||||
/**
|
||||
* yh-theme.css 生成器(《管理端整体主题迁移方案.md》§6.4:color-mix 构建期静态化)。
|
||||
* 定稿源:07-seam-pure.html(浅色接缝条·纯享版 2026-09-11)。
|
||||
* 用法:node scripts/gen-yh-theme.mjs → src/assets/styles/yh-theme.css
|
||||
* 运行时零 color-mix,兼容下限=支持 CSS 变量。
|
||||
*/
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const hex = n => Math.round(n).toString(16).padStart(2, "0");
|
||||
const parse = c => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
|
||||
/** 与 CSS color-mix(in srgb, A w%, B) 等价的线性混合 */
|
||||
const mix = (a, b, w) => {
|
||||
const [r1, g1, b1] = parse(a);
|
||||
const [r2, g2, b2] = parse(b);
|
||||
return `#${hex(r1 * w + r2 * (1 - w))}${hex(g1 * w + g2 * (1 - w))}${hex(b1 * w + b2 * (1 - w))}`;
|
||||
};
|
||||
const rgba = (c, a) => {
|
||||
const [r, g, b] = parse(c);
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
};
|
||||
|
||||
// 定稿五档品牌色(07-seam-pure.html L31-35)
|
||||
const THEMES = {
|
||||
blue: { brand: "#577afb", b2: "#7b8ffd", dark: "#4264e4", soft: "#eef2ff", a: 0.34, glow: 0.42 },
|
||||
ocean: { brand: "#2f7ce0", b2: "#4a9bf5", dark: "#2568c4", soft: "#eaf3fe", a: 0.34, glow: 0.42 },
|
||||
ink: { brand: "#3d4d63", b2: "#6d82a1", dark: "#2e3c4f", soft: "#eef1f6", a: 0.34, glow: 0.5 },
|
||||
violet: { brand: "#6d5ae0", b2: "#8f7cf2", dark: "#5a48c9", soft: "#f1effe", a: 0.34, glow: 0.42 },
|
||||
teal: { brand: "#0d9488", b2: "#18b5a5", dark: "#0b7c72", soft: "#e8f7f5", a: 0.36, glow: 0.42 },
|
||||
};
|
||||
const NAVY = "#0d1424"; // side-deep 混色基底
|
||||
const WHITE = "#ffffff";
|
||||
|
||||
const sideTokens = t => ({
|
||||
deepSolid: mix(t.brand, NAVY, 0.72),
|
||||
deepGradTop: mix(t.brand, WHITE, 0.96),
|
||||
deepGradBot: mix(t.brand, NAVY, 0.7),
|
||||
lightSolid: mix(t.brand, WHITE, 0.07),
|
||||
lightGradTop: mix(t.brand, WHITE, 0.13),
|
||||
lightGradBot: mix(t.brand, "#f5f7fa", 0.03),
|
||||
lightLine: mix(t.brand, "#e6eaf0", 0.18),
|
||||
lightHover: mix(t.brand, "#f2f4f8", 0.1),
|
||||
lightActive: mix(t.brand, WHITE, 0.15),
|
||||
strip: mix(t.brand, WHITE, 0.13), // 接缝条渐隐起点
|
||||
});
|
||||
|
||||
const B = THEMES.blue;
|
||||
const BS = sideTokens(B);
|
||||
|
||||
let css = `/* 本文件由 scripts/gen-yh-theme.mjs 生成(color-mix 已构建期静态化),勿手改派生色值。
|
||||
定稿:07-seam-pure.html(浅色接缝条·纯享版);挂接:main.ts 置于 icons.css 之后。
|
||||
档位开关挂在 <html>:data-theme=blue|ocean|ink|violet|teal × side-deep|side-light × grad-on|grad-off × side-plain */
|
||||
|
||||
/* ============ 变量体系(:root 默认档 = blue + side-deep + grad-on + side-follow) ============ */
|
||||
:root{
|
||||
--brand:${B.brand}; --brand-2:${B.b2}; --brand-dark:${B.dark};
|
||||
--brand-soft:${B.soft}; --brand-a:${rgba(B.brand, B.a)}; --glow:${rgba(B.brand, B.glow)};
|
||||
--topbar-bg:linear-gradient(100deg,var(--brand) 0%,var(--brand-2) 100%);
|
||||
--green:#20a162; --green-d:#188451;
|
||||
--red:#e54d42; --red-soft:#fef1f0;
|
||||
/* 第一波(P1)沿用现状 56/56,紧凑化 52/44 留 P2(方案 §8-D2) */
|
||||
--topbar-h:56px; --crumb-h:56px; --side-w:200px; --r:10px; --r-s:7px;
|
||||
--page-bg:#f3f5fa; --panel:#fff; --line:#e9edf5; --line-2:#f0f3f9;
|
||||
--text:#1e2635; --text-2:#5a6478; --text-3:#98a1b2;
|
||||
--sh:0 1px 2px rgba(16,24,40,.04),0 14px 30px -22px rgba(16,24,40,.4);
|
||||
--side-bg:linear-gradient(180deg,${BS.deepGradTop} 0%,${BS.deepGradBot} 100%);
|
||||
--side-text:#c7d2ea; --side-icon:#95a6cb;
|
||||
--side-line:rgba(255,255,255,.08);
|
||||
--side-hover:rgba(255,255,255,.09); --side-hover-text:#fff;
|
||||
--side-active-bg:rgba(255,255,255,.18); --side-active-text:#fff;
|
||||
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
|
||||
--strip-bg:linear-gradient(180deg,${BS.strip} 0,#fff 100%);
|
||||
}
|
||||
`;
|
||||
|
||||
// 每档主题:品牌 token + 侧栏底色四态 + 接缝条起点色(非底色 token 与主题无关,只写一次)
|
||||
for (const [name, t] of Object.entries(THEMES)) {
|
||||
const s = sideTokens(t);
|
||||
css += `
|
||||
/* ${name} · brand ${t.brand} */
|
||||
html[data-theme="${name}"]{
|
||||
--brand:${t.brand}; --brand-2:${t.b2}; --brand-dark:${t.dark};
|
||||
--brand-soft:${t.soft}; --brand-a:${rgba(t.brand, t.a)}; --glow:${rgba(t.brand, t.glow)};
|
||||
--strip-bg:linear-gradient(180deg,${s.strip} 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="${name}"].side-deep{--side-bg:${s.deepSolid}}
|
||||
html[data-theme="${name}"].side-deep.grad-on{--side-bg:linear-gradient(180deg,${s.deepGradTop} 0%,${s.deepGradBot} 100%)}
|
||||
html[data-theme="${name}"].side-light{--side-bg:${s.lightSolid}}
|
||||
html[data-theme="${name}"].side-light.grad-on{--side-bg:linear-gradient(180deg,${s.lightGradTop} 0%,${s.lightGradBot} 100%)}
|
||||
html[data-theme="${name}"].grad-off{--topbar-bg:${t.brand}}
|
||||
`;
|
||||
}
|
||||
|
||||
css += `
|
||||
/* ---- 深浅档非底色 token(与品牌色联动的用 var 派生;浅色档 line/hover/active 用默认档实测值,
|
||||
主题间差异肉眼不可辨,保持单档以压复杂度——同定稿 side-light 组) ---- */
|
||||
html.side-light{
|
||||
--side-text:#4a5568; --side-icon:#98a2b3;
|
||||
--side-line:${BS.lightLine};
|
||||
--side-hover:${BS.lightHover}; --side-hover-text:#1e2635;
|
||||
--side-active-bg:${BS.lightActive}; --side-active-text:var(--brand-dark);
|
||||
--side-bar:var(--brand); --side-scroll:#d3d9e3;
|
||||
}
|
||||
html.grad-off{--topbar-bg:var(--brand)}
|
||||
|
||||
/* ---- 侧栏与品牌色解耦(side-follow 关) ---- */
|
||||
html.side-plain.side-deep{
|
||||
--side-bg:#2c3444; --side-text:#c2cbd9; --side-icon:#909dae;
|
||||
--side-line:rgba(255,255,255,.08);
|
||||
--side-hover:rgba(255,255,255,.08); --side-hover-text:#fff;
|
||||
--side-active-bg:rgba(255,255,255,.16); --side-active-text:#fff;
|
||||
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
|
||||
}
|
||||
html.side-plain.side-light{
|
||||
--side-bg:#f7f8fa; --side-text:#4a5568; --side-icon:#98a2b3;
|
||||
--side-line:#e6eaf0; --side-hover:#eff1f5;
|
||||
--side-active-bg:#eceef3; --side-active-text:#2f3a4c;
|
||||
--side-bar:#8b95a8; --side-scroll:#d3d9e3;
|
||||
}
|
||||
html.side-plain.grad-on.side-deep{--side-bg:linear-gradient(180deg,#333c4d 0%,#272e3c 100%)}
|
||||
html.side-plain.grad-on.side-light{--side-bg:linear-gradient(180deg,#fafbfc 0%,#f2f4f7 100%)}
|
||||
html.side-plain{--strip-bg:linear-gradient(180deg,#f2f4f8 0,#fff 100%)}
|
||||
`;
|
||||
|
||||
/* ============================ 皮肤层(§6.2 类名映射表,不改现有类名) ============================ */
|
||||
css += `
|
||||
/* ---- 顶栏:.header-container ---- */
|
||||
.layout-wrap .header-container{background:var(--topbar-bg);box-shadow:0 1px 3px rgba(16,24,40,.14);transition:background .28s}
|
||||
.layout-wrap .header-container h4{font-weight:600;letter-spacing:.4px}
|
||||
.layout-wrap .header-container .delimiter{background:rgba(255,255,255,.28)}
|
||||
/* 顶栏 logo 徽标(模板新增节点) */
|
||||
.logo-badge{width:30px;height:30px;flex:0 0 auto;border-radius:9px;background:#fff;display:grid;place-items:center;box-shadow:0 2px 7px rgba(0,0,0,.16);margin-right:10px}
|
||||
.logo-badge svg,.logo-badge img{width:22px;height:22px;display:block}
|
||||
/* 用户胶囊 */
|
||||
.layout-wrap .header-aside{padding:0 8px 0 5px;height:36px;border-radius:999px;transition:background .16s}
|
||||
.layout-wrap .header-aside:hover{background:rgba(255,255,255,.16)}
|
||||
|
||||
/* ---- 侧栏:.left-menu(皮肤覆盖 layout.css 浅色档;深浅由 --side-* 驱动) ---- */
|
||||
.layout-wrap .left-menu{background:var(--side-bg);border-right:1px solid var(--side-line);color:var(--side-text);transition:background .28s}
|
||||
.layout-wrap .left-menu::-webkit-scrollbar{width:8px}
|
||||
.layout-wrap .left-menu::-webkit-scrollbar-thumb{background:var(--side-scroll);border-radius:8px}
|
||||
.layout-wrap .left-menu ul{padding:8px 0 18px}
|
||||
.layout-wrap .left-menu li{margin:1px 8px;border-radius:var(--r-s);transition:background .15s,color .15s}
|
||||
.layout-wrap .left-menu li a{
|
||||
display:block;height:40px;line-height:40px;padding:0 12px;border-radius:var(--r-s);
|
||||
color:var(--side-text);font-size:13px;border-right:none;transition:background .15s,color .15s;position:relative;
|
||||
}
|
||||
.layout-wrap .left-menu li a:hover{background:var(--side-hover);color:var(--side-hover-text)}
|
||||
.layout-wrap .left-menu li a:hover .iconfont,.layout-wrap .left-menu li:hover .iconfont{color:var(--side-hover-text)}
|
||||
.layout-wrap .left-menu li.active{background:var(--side-active-bg);border-right:0}
|
||||
.layout-wrap .left-menu li.active a{color:var(--side-active-text);font-weight:600;background:transparent;border-right:none}
|
||||
.layout-wrap .left-menu li.active a .iconfont{color:var(--side-active-text)}
|
||||
.layout-wrap .left-menu li.active::before{
|
||||
content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);
|
||||
width:3px;height:20px;border-radius:0 3px 3px 0;background:var(--side-bar);
|
||||
}
|
||||
.layout-wrap .left-menu li.child-item{margin:0 8px}
|
||||
.layout-wrap .left-menu li.child-item a{height:36px;line-height:36px;font-size:12.5px}
|
||||
.layout-wrap .left-menu li.child-item.active::before{height:16px}
|
||||
/* 菜单图标 mask 类(icons.css 用 background-color 上色,这里给品牌感近色) */
|
||||
.layout-wrap .left-menu .iconfont{margin-right:8px}
|
||||
.layout-wrap .left-menu .arrow-up,.layout-wrap .left-menu .arrow-down{border-color:var(--side-icon)}
|
||||
|
||||
/* ---- 面包屑:.path-nav → 浅色接缝条(方案 §7.3 融合站点身份带) ---- */
|
||||
.layout-wrap .path-nav{
|
||||
height:var(--crumb-h);background:var(--strip-bg);border-bottom:1px solid var(--line);
|
||||
color:var(--text-3);box-sizing:border-box;
|
||||
}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb-item-link{color:var(--text-2)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb>span:last-child .ivu-breadcrumb-item-link,.layout-wrap .path-nav .ivu-breadcrumb>span:last-child{color:var(--text);font-weight:600}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)}
|
||||
.layout-wrap .path-nav .crumb-home{color:var(--brand)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0}
|
||||
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变;子站让位身份橙 */
|
||||
.layout-wrap .path-nav::before{
|
||||
content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1;
|
||||
}
|
||||
.layout-wrap .path-nav.band-sub::before{background:#ff9900}
|
||||
.layout-wrap .path-nav::after{display:none}
|
||||
|
||||
/* ---- 内容区:灰底 + 白卡 ---- */
|
||||
.base-wrap-main{background:var(--page-bg)}
|
||||
.base-wrap-main .main-container{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
|
||||
box-shadow:var(--sh);margin:0 18px 24px;padding:16px 20px 24px;box-sizing:border-box;
|
||||
}
|
||||
/* 栏目工作台左树卡 */
|
||||
.base-wrap-main .tree-container{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--sh)}
|
||||
|
||||
/* ---- 搜索区分区化(卡片内 filterbar 语义) ---- */
|
||||
.base-wrap-main .search-wrap{
|
||||
background:#fbfcfe;border:1px solid var(--line-2);border-radius:var(--r-s);
|
||||
padding:10px 12px;height:auto;min-height:52px;box-sizing:border-box;margin-bottom:12px;
|
||||
}
|
||||
|
||||
/* ---- 表格/操作列皮肤(三蓝归一 #5979EA/#2d8cf0 → var(--brand)) ---- */
|
||||
.ivu-table-header th,.ivu-table-fixed-header th{background:#f7f9fc !important;color:var(--text-2)}
|
||||
.ivu-table td{border-bottom:1px solid var(--line-2)}
|
||||
.ivu-table-body tr:hover td,.ivu-table tbody tr:hover td{background:var(--brand-soft) !important}
|
||||
.base-wrap-main .ivu-table .operating-link{color:var(--brand)}
|
||||
.base-wrap-main .ivu-table .operating-link.disabled{color:#aaa}
|
||||
.ivu-table-cell .operating-btn:hover{background:var(--brand);color:#fff}
|
||||
|
||||
/* ---- view-ui-plus 品牌态 ---- */
|
||||
.ivu-btn-primary{background-color:var(--brand);border-color:var(--brand)}
|
||||
.ivu-btn-primary:hover,.ivu-btn-primary.active{background-color:var(--brand-dark);border-color:var(--brand-dark)}
|
||||
.ivu-btn-success{background-color:var(--green);border-color:var(--green)}
|
||||
.ivu-btn-success:hover,.ivu-btn-success.active{background-color:var(--green-d);border-color:var(--green-d)}
|
||||
.ivu-btn-error{background-color:var(--red);border-color:var(--red)}
|
||||
.ivu-btn-error:hover,.ivu-btn-error.active{background-color:#c93f36;border-color:#c93f36}
|
||||
.ivu-btn-text{color:var(--brand)}
|
||||
.ivu-btn-text:hover{color:var(--brand-dark)}
|
||||
.ivu-tabs-ink-bar{background-color:var(--brand)}
|
||||
.ivu-tabs-tab:hover,.ivu-tabs-tab-active{color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item-active{background:var(--brand);border-color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item-active a,.base-wrap-main .ivu-page-item-active a:hover{color:#fff !important}
|
||||
.base-wrap-main .ivu-page-item:hover{border-color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item:hover a,.base-wrap-main .ivu-page-item:hover i{color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-prev:hover i,.base-wrap-main .ivu-page-next:hover i{border-color:var(--brand);color:var(--brand)}
|
||||
.ivu-select-item-selected{color:var(--brand)}
|
||||
.ivu-select-item-selected:hover{background:var(--brand-soft)}
|
||||
.ivu-checkbox-checked .ivu-checkbox-inner{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-radio-checked .ivu-radio-inner{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-checkbox-wrapper:hover .ivu-checkbox-inner,.ivu-radio-wrapper:hover .ivu-radio-inner{border-color:var(--brand)}
|
||||
.ivu-input:focus,.ivu-input-focused,.ivu-select-visible .ivu-select-selection{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand-a)}
|
||||
.ivu-input:hover,.ivu-select-selection:hover{border-color:#c6d0e2}
|
||||
.ivu-switch-checked{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-tree-title:hover{background-color:var(--brand-soft)}
|
||||
.ivu-tree-title-selected,a.ivu-tree-title-selected:hover{background-color:var(--brand-soft);color:var(--brand)}
|
||||
.ivu-spin-dot{background-color:var(--brand)}
|
||||
.ivu-modal-header-inner{font-weight:600;color:var(--text)}
|
||||
a{color:var(--brand)}
|
||||
a:hover{color:var(--brand-dark)}
|
||||
|
||||
/* ---- 滚动条(定稿全局) ---- */
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-thumb{background:#c9d0dd;border-radius:8px}
|
||||
::-webkit-scrollbar-thumb:hover{background:#aeb8c9}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
|
||||
/* ---- 工作台/主题管理全屏页深色顶栏吃主题变量(方案 §7.3) ---- */
|
||||
.wb-header{background:var(--topbar-bg) !important}
|
||||
`;
|
||||
|
||||
writeFileSync(resolve(root, "src/assets/styles/yh-theme.css"), css, "utf8");
|
||||
console.log("yh-theme.css written:", Buffer.byteLength(css), "bytes");
|
||||
@@ -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,460 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* B5 栏目工作台组件行为(方案 P1-2)+ P-D1 内容管理 IA 切分(v2 §B):
|
||||
* ① 列表型栏目 → 右侧「栏目内容概览」(统计 + 最近 5 条只读 + CTA),不再渲染完整操作表;
|
||||
* ② 切换树节点 → 概览 watch categoryId 联动 reload;
|
||||
* ③ 单页型 → 摘要面板(含"编辑内容"入口)不渲染文章列表;
|
||||
* ④ 外链型 → linkUrl + 打开链接;
|
||||
* ⑤ 新建文章按钮 → 路由跳转带 categoryId query;
|
||||
* ⑥ T-D-02/03:/article 读 ?categoryId= 预筛选;概览"查看全部"深链到该地址。
|
||||
*
|
||||
* 无 @vue/test-utils 依赖:createApp 手写挂载 + vi.mock 桩(树面板/排序弹窗/图标/确认框/api/router)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
const { pageMock, routerPush, openMock, saveMock, saveRejection, pageResult, treeFixture, routeMock } = vi.hoisted(() => ({
|
||||
pageMock: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
saveMock: vi.fn(),
|
||||
/** 第 N 次(0 起)save.send() 拒绝;-1=全部成功(批量添加"失败继续"用) */
|
||||
saveRejection: { value: -1 },
|
||||
/** articleApi.page 返回值桩:默认空列表(各用例按需覆盖 list/total) */
|
||||
pageResult: { value: { list: [] as any[], total: 0 } },
|
||||
/** categoryApi.tree 返回值桩:默认空树(用例按需给栏目层级) */
|
||||
treeFixture: { value: [] as any[] },
|
||||
/** useRoute 桩:可变 query,测 ?categoryId= 深链初始化 */
|
||||
routeMock: { value: { params: {}, query: {} as Record<string, string> } },
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
articleApi: {
|
||||
page: (query: any) => {
|
||||
pageMock(query);
|
||||
return { send: () => Promise.resolve(pageResult.value) };
|
||||
},
|
||||
publish: () => ({ send: () => Promise.resolve({}) }),
|
||||
offline: () => ({ send: () => Promise.resolve({}) }),
|
||||
topmost: () => ({ send: () => Promise.resolve({}) }),
|
||||
delete: () => ({ send: () => Promise.resolve({}) }),
|
||||
},
|
||||
categoryApi: {
|
||||
tree: () => ({ send: () => Promise.resolve(treeFixture.value) }),
|
||||
save: (payload: any) => {
|
||||
const nth = saveMock.mock.calls.length;
|
||||
saveMock(payload);
|
||||
return {
|
||||
send: () =>
|
||||
saveRejection.value === nth
|
||||
? Promise.reject(new Error("栏目名称已存在"))
|
||||
: Promise.resolve({ id: 100 + nth }),
|
||||
};
|
||||
},
|
||||
delete: () => ({ send: () => Promise.resolve({}) }),
|
||||
},
|
||||
uploadUrl: () => "/api/attachment/upload",
|
||||
portalBase: (code: string) => `/portal/${code}`,
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => ({ push: routerPush, replace: vi.fn() }),
|
||||
useRoute: () => routeMock.value,
|
||||
}));
|
||||
|
||||
/** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点";捕获 props 供 treeApi 断言 */
|
||||
vi.mock("@/components/CategoryTreePanel.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["curTreeNode", "treeApi", "defaultSearchVisible", "showCloseButton"],
|
||||
emits: ["update:curTreeNode", "on-change", "on-tree-data"],
|
||||
setup(props, { emit, expose }) {
|
||||
(globalThis as any).__treeEmit = (node: any) => {
|
||||
emit("update:curTreeNode", node);
|
||||
emit("on-change", node);
|
||||
};
|
||||
// P-D-01:概览统计要"直属+子孙",用 on-tree-data 把(含虚拟根的)栏目树喂给 CategoryManage.treeData
|
||||
(globalThis as any).__treeEmitData = (list: any[]) => emit("on-tree-data", list);
|
||||
(globalThis as any).__treeProps = props;
|
||||
// CategoryManage 会经模板 ref 调这些方法(真实组件的 exposed API)
|
||||
expose({ loadTree: () => {}, refreshTree: () => {}, reloadTree: () => {} });
|
||||
return () => h("div", { class: "tree-stub" }, "tree-stub");
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/CategorySortModal.vue", () => ({
|
||||
default: defineComponent({ props: ["visible", "parentId", "parentName", "children"], emits: ["saved"], render: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/SvgIcon.vue", () => ({
|
||||
default: defineComponent({ props: ["name"], render: () => h("span", "icon") }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||
|
||||
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||
import CategoryManage from "@/views/cms/CategoryManage.vue";
|
||||
import BatchAddCategory from "@/components/BatchAddCategory.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
let currentStore: ReturnType<typeof useCmsStore> | null = null;
|
||||
|
||||
/** 挂载:真实 pinia(预置学校/站点上下文)+ 全局 view-ui-plus */
|
||||
function mountApp(root: Component) {
|
||||
const pinia = createPinia();
|
||||
const store = useCmsStore(pinia);
|
||||
currentStore = store;
|
||||
store.user = { superAdmin: true, sites: [] } as any;
|
||||
store.currentSchool = { schId: 1, schName: "演示学校", mainSiteId: 1, mainSiteCode: "demo1", mainSiteName: "演示主站" } as any;
|
||||
store.currentSite = { id: 1, code: "demo1", name: "演示站" } as any;
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(root as any);
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
async function flush(frames = 5) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(globalThis as any).__treeEmit = undefined;
|
||||
(globalThis as any).__treeEmitData = undefined;
|
||||
(globalThis as any).__treeProps = undefined;
|
||||
pageResult.value = { list: [], total: 0 };
|
||||
treeFixture.value = [];
|
||||
routeMock.value = { params: {}, query: {} };
|
||||
currentStore = null;
|
||||
window.open = openMock as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () => {
|
||||
it("① 固定栏目模式:categoryId 正确传给查询且隐藏栏目下拉(仅剩状态筛选)", async () => {
|
||||
const state = reactive({ cid: 5, fixed: true });
|
||||
const host = defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||
});
|
||||
const el = mountApp(host);
|
||||
await flush();
|
||||
|
||||
expect(pageMock).toHaveBeenCalled();
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryIds: 5 });
|
||||
// 栏目下拉隐藏(剩余 .ivu-select 为状态筛选与分页 sizer,占位文本不含"全部栏目")
|
||||
expect(el.textContent).not.toContain("全部栏目");
|
||||
expect([...el.querySelectorAll(".ivu-select-placeholder")].every(p => !p.textContent?.includes("全部栏目"))).toBe(true);
|
||||
});
|
||||
|
||||
it("② 切换树节点:categoryId 变化触发面板 reload(watch 联动)", async () => {
|
||||
const state = reactive({ cid: 5, fixed: true });
|
||||
const host = defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||
});
|
||||
mountApp(host);
|
||||
await flush();
|
||||
expect(pageMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
state.cid = 7;
|
||||
await flush();
|
||||
|
||||
expect(pageMock).toHaveBeenCalledTimes(2);
|
||||
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryIds: 7 });
|
||||
});
|
||||
|
||||
it("T-D-02 文章页 ?categoryId= 深链:读 query 初始化栏目筛选并回显下拉", async () => {
|
||||
treeFixture.value = [{ id: 5, name: "新闻中心", type: 1, children: [{ id: 51, name: "校园时讯", type: 1 }] }];
|
||||
routeMock.value = { params: {}, query: { categoryId: "5" } };
|
||||
const el = mountApp(defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, {}),
|
||||
}));
|
||||
await flush();
|
||||
|
||||
// query 里的字符串 id 须转成数字:既对齐后端 categoryIds,也让栏目下拉回显选中项
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryIds: 5 });
|
||||
expect(el.querySelector(".ivu-select-selected-value")?.textContent).toContain("新闻中心");
|
||||
});
|
||||
|
||||
it("⑤ 面板新建文章:非固定模式带 categoryId query 跳转;固定模式入口收敛到工作台面板头(面板内无重复按钮)", async () => {
|
||||
const state = reactive({ cid: 5, fixed: false });
|
||||
const host = defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||
});
|
||||
const el = mountApp(host);
|
||||
await flush();
|
||||
|
||||
const btn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("新建文章"));
|
||||
expect(btn).toBeTruthy();
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
// 全局列表模式:新建不带栏目预选(栏目由用户在下拉自选)
|
||||
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit");
|
||||
|
||||
// 固定栏目模式:新建入口由 CategoryManage 面板头承担,面板内不再渲染
|
||||
state.fixed = true;
|
||||
await flush();
|
||||
expect([...el.querySelectorAll("button")].some(b => b.textContent?.includes("新建文章"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
|
||||
it("③ 单页型栏目:显示摘要面板(含编辑内容入口),不渲染文章列表", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
(globalThis as any).__treeEmit({ id: 2, parentId: 0, name: "学校简介", type: 2, content: "<p>云海第一中学始建于 1958 年。</p>", sortNo: 0, status: 1 });
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("学校简介");
|
||||
expect(el.textContent).toContain("云海第一中学始建于 1958 年");
|
||||
expect(el.textContent).toContain("编辑内容");
|
||||
expect(el.querySelector(".article-list-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("④ 外链型栏目:显示 linkUrl 与「打开链接」,点击新窗口打开", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
(globalThis as any).__treeEmit({ id: 3, parentId: 0, name: "教育部链接", type: 3, linkUrl: "https://www.moe.gov.cn", sortNo: 0, status: 1 });
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("https://www.moe.gov.cn");
|
||||
const openBtn = [...el.querySelectorAll("button, a")].find(b => b.textContent?.includes("打开链接"));
|
||||
expect(openBtn).toBeTruthy();
|
||||
openBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(openMock).toHaveBeenCalledWith("https://www.moe.gov.cn", "_blank");
|
||||
});
|
||||
|
||||
it("T-D-01 列表型栏目:右侧降级为「栏目内容概览」(统计 + 最近 5 条只读),不再渲染操作表", async () => {
|
||||
pageResult.value = {
|
||||
total: 12,
|
||||
list: [1, 2, 3, 4, 5, 6].map(i => ({
|
||||
id: 900 + i,
|
||||
title: `云海大学第${i}次党代会筹备推进会`,
|
||||
categoryId: 5,
|
||||
categoryName: "新闻中心",
|
||||
status: 1,
|
||||
publishTime: "2026-09-20 10:00",
|
||||
topmost: 0,
|
||||
})),
|
||||
};
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
// 统计口径 = 直属 + 子孙栏目:树经 on-tree-data 下发(含 W2 虚拟根),子孙 51/52 须并入计数
|
||||
(globalThis as any).__treeEmitData([
|
||||
{
|
||||
id: 0,
|
||||
name: "演示学校",
|
||||
virtual: true,
|
||||
children: [
|
||||
{
|
||||
id: 5,
|
||||
name: "新闻中心",
|
||||
type: 1,
|
||||
children: [
|
||||
{ id: 51, name: "校园时讯", type: 1 },
|
||||
{ id: 52, name: "通知公告", type: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
(globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 });
|
||||
await flush();
|
||||
|
||||
const box = el.querySelector(".category-content-summary");
|
||||
expect(box).toBeTruthy();
|
||||
// 完整的文章操作表不再出现在栏目页(唯一操作台是 /article)
|
||||
expect(el.querySelector(".article-list-panel")).toBeNull();
|
||||
expect(box!.querySelector(".ivu-table")).toBeNull();
|
||||
// 概览内不得出现文章操作入口(发布/下架/置顶/删除按钮);状态说明文字不算
|
||||
const affordances = [...box!.querySelectorAll("button, a")].map(n => (n.textContent || "").trim());
|
||||
expect(affordances.some(t => ["发布", "下架", "取消置顶", "置顶", "删除"].includes(t))).toBe(false);
|
||||
|
||||
// 统计查询:直属+子孙(逗号分隔,后端 List<Integer> 契约)、全状态(不带 status)、只要最近 5 条
|
||||
expect(pageMock).toHaveBeenCalled();
|
||||
const q = pageMock.mock.calls[0][0];
|
||||
expect(q).toMatchObject({ siteId: 1, categoryIds: "5,51,52", page: 1, pageSize: 5 });
|
||||
expect(q.status).toBeUndefined();
|
||||
|
||||
expect(box!.textContent).toContain("共 12 篇");
|
||||
// 只读 5 条(第 6 条不渲染)
|
||||
expect(box!.querySelectorAll(".ccs-row")).toHaveLength(5);
|
||||
expect(box!.textContent).toContain("云海大学第1次党代会筹备推进会");
|
||||
});
|
||||
|
||||
it("T-D-03 概览「查看全部 →」跳 /article 深链(带 categoryId 预筛选)", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
(globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 });
|
||||
await flush();
|
||||
|
||||
const more = [...el.querySelectorAll("a, button")].find(b => b.textContent?.includes("查看全部"));
|
||||
expect(more).toBeTruthy();
|
||||
more!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article?categoryId=5");
|
||||
});
|
||||
|
||||
it("未选中节点:右侧显示空态引导", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
expect(el.textContent).toContain("选择左侧栏目");
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoryWorkbench - BatchAddCategory(批量添加栏目)", () => {
|
||||
/** 打开批量添加弹窗并填入多行名称(Modal transfer 到 body,按 .ivu-modal 定位) */
|
||||
async function openBatchWith(names: string, props: any = {}) {
|
||||
const onSuccess = vi.fn();
|
||||
const host = defineComponent({
|
||||
setup: () => () =>
|
||||
h(BatchAddCategory, {
|
||||
visible: true,
|
||||
defaultPid: 5,
|
||||
parentName: "新闻中心",
|
||||
defaultType: 1,
|
||||
onSuccess,
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
const el = mountApp(host);
|
||||
await flush();
|
||||
|
||||
const textarea = document.querySelector(".ivu-modal textarea") as HTMLTextAreaElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
|
||||
setter.call(textarea, names);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
const submit = [...document.querySelectorAll(".ivu-modal button")].find(b => b.textContent?.includes("确认"));
|
||||
submit!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush(8);
|
||||
return { onSuccess, el };
|
||||
}
|
||||
|
||||
it("多行名称逐条循环调 save,按行序写 sortNo=1..n,成功后回调", async () => {
|
||||
saveRejection.value = -1;
|
||||
const { onSuccess } = await openBatchWith("通知公告\n规章制度\n三务公开");
|
||||
|
||||
expect(saveMock).toHaveBeenCalledTimes(3);
|
||||
expect(saveMock.mock.calls[0][0]).toMatchObject({ parentId: 5, name: "通知公告", type: 1, sortNo: 1 });
|
||||
expect(saveMock.mock.calls[1][0]).toMatchObject({ name: "规章制度", sortNo: 2 });
|
||||
expect(saveMock.mock.calls[2][0]).toMatchObject({ name: "三务公开", sortNo: 3 });
|
||||
expect(onSuccess).toHaveBeenCalledWith(102);
|
||||
});
|
||||
|
||||
it("空行自动忽略;某行失败不影响后续行,结束统一告警", async () => {
|
||||
saveRejection.value = 1; // 第 2 条拒绝
|
||||
const warnSpy = vi.spyOn(Message, "warning");
|
||||
const { onSuccess } = await openBatchWith("通知公告\n\n规章制度\n三务公开");
|
||||
|
||||
expect(saveMock).toHaveBeenCalledTimes(3);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
saveRejection.value = -1;
|
||||
});
|
||||
|
||||
it("未输入名称时不发请求", async () => {
|
||||
const callsBefore = saveMock.mock.calls.length;
|
||||
await openBatchWith(" ");
|
||||
expect(saveMock.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("W2 栏目树修订(T-W2-06..10)", () => {
|
||||
const selectRoot = async () => {
|
||||
(globalThis as any).__treeEmit({ id: 0, name: "演示学校", virtual: true, children: [] });
|
||||
await flush();
|
||||
};
|
||||
|
||||
it("T-W2-06: 工具栏收敛——搜索/收起展开按钮移除(仅剩 4 个操作)", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
expect(el.querySelector(".search-node-li")).toBeNull();
|
||||
expect(el.querySelectorAll(".opera-ul li")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("T-W2-07: 树桩收到带根数据——treeApi 返回首节点 id=0、name=学校名", async () => {
|
||||
mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
const props = (globalThis as any).__treeProps;
|
||||
expect(props?.treeApi).toBeTypeOf("function");
|
||||
const wrapped = await props.treeApi();
|
||||
expect(wrapped[0]).toMatchObject({ id: 0, name: "演示学校", virtual: true });
|
||||
expect(wrapped[0].children).toEqual([]);
|
||||
});
|
||||
|
||||
it("T-W2-08: 根上新增=顶级栏目(保存 payload parentId===0)", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
await selectRoot();
|
||||
const addLi = el.querySelector(".add-node-li");
|
||||
addLi!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
// Modal 经 transfer 渲染到 body(不在挂载根内);首个 input 是禁用的"上级栏目"展示框,须按 placeholder 定位名称框
|
||||
const input = document.querySelector('.ivu-modal input[placeholder="栏目名称"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, "测试顶级栏目");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
const saveBtn = [...document.querySelectorAll(".ivu-modal button")].find(b => b.textContent?.includes("保存"));
|
||||
saveBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(saveMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveMock.mock.calls[0][0]).toMatchObject({ parentId: 0, name: "测试顶级栏目" });
|
||||
});
|
||||
|
||||
it("T-W2-09: 根上编辑/删除被拒——仅警告、零请求", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
await selectRoot();
|
||||
const warnSpy = vi.spyOn(Message, "warning");
|
||||
|
||||
const lis = [...el.querySelectorAll(".opera-ul li")];
|
||||
// 工具栏顺序:新增/编辑/排序/删除(搜索与收起展开已移除)
|
||||
lis[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(saveMock).not.toHaveBeenCalled();
|
||||
|
||||
lis[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(MyConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("T-W2-10: 站点切换根 label 跟随——切到子站后根 name=子站名", async () => {
|
||||
mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
const before = await (globalThis as any).__treeProps.treeApi();
|
||||
expect(before[0].name).toBe("演示学校");
|
||||
|
||||
currentStore!.setCurrentSite({ id: 2, code: "jdxy", name: "机械学院" } as any);
|
||||
await flush();
|
||||
const wrapped = await (globalThis as any).__treeProps.treeApi();
|
||||
expect(wrapped[0].name).toBe("机械学院");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 首页布局编辑器(v2 工作项 B → docs/20 F2 三栏升级)挂载回归:
|
||||
* T-B-09 quick_links/notice_bar 属性面板行为不变;
|
||||
* F2 新增:三栏结构(序列/预览/属性)、预览视口三档切换、类型卡 SVG 缩略图(R-40)、
|
||||
* 展示形态 RadioCard、首用自动编排(无 home_layout + 有列表栏目 → 自动生成)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
|
||||
|
||||
const treeMock = vi.hoisted(() => ({ send: vi.fn() }));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
categoryApi: { tree: () => treeMock },
|
||||
}));
|
||||
|
||||
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
/** 排空微任务 + 一个宏任务(view-ui-plus 的 Select 标签回填走 nextTick/setTimeout) */
|
||||
async function settle(frames = 4) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIG = JSON.stringify({
|
||||
logo: "/a.png",
|
||||
home_layout: {
|
||||
version: 1,
|
||||
blocks: [
|
||||
{ id: "q1", type: "quick_links", items: [{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }] },
|
||||
{ id: "n1", type: "notice_bar", text: "国庆放假安排", url: "/notice/gq", level: "warn" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const TREE: CategoryTreeNode[] = [
|
||||
{ id: 11, name: "校园时讯", type: 1, children: [] },
|
||||
{ id: 12, name: "媒体聚焦", type: 1, children: [] },
|
||||
] as unknown as CategoryTreeNode[];
|
||||
|
||||
async function mountEditor(
|
||||
configJson: string,
|
||||
opts?: { siteId?: number; portalUrl?: string; siteIdRef?: ReturnType<typeof ref<number | undefined>> },
|
||||
) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const model = ref(configJson);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h(HomeLayoutEditor, {
|
||||
modelValue: model.value,
|
||||
siteId: opts?.siteIdRef ? opts.siteIdRef.value : opts?.siteId,
|
||||
portalUrl: opts?.portalUrl,
|
||||
"onUpdate:modelValue": (v: string) => (model.value = v),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
await settle(6);
|
||||
return { el, model };
|
||||
}
|
||||
|
||||
/** 取属性面板中指定 label 的字段容器 */
|
||||
function fieldOf(el: HTMLElement, label: string): HTMLElement | null {
|
||||
return (
|
||||
Array.from(el.querySelectorAll<HTMLElement>(".hle-field")).find(
|
||||
f => f.querySelector(".hle-field-label")?.textContent?.trim() === label,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
/** 第 index 行的删除按钮 */
|
||||
function del(field: HTMLElement, index: number): HTMLElement {
|
||||
return field.querySelectorAll<HTMLElement>(".hle-link-del")[index];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
treeMock.send.mockReset();
|
||||
treeMock.send.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach(fn => fn());
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09,F2 改造后回归)", () => {
|
||||
it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => {
|
||||
const { el, model } = await mountEditor(CONFIG);
|
||||
// 序列行摘要走 blockSummary 新增分支
|
||||
expect(el.textContent).toContain("1 个入口");
|
||||
expect(el.textContent).toContain("醒目提示");
|
||||
|
||||
// 选中 quick_links 行 → 属性面板出现入口列表编辑器
|
||||
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
|
||||
await settle();
|
||||
const field = fieldOf(el as HTMLElement, "入口列表");
|
||||
expect(field).not.toBeNull();
|
||||
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
|
||||
expect(field!.querySelectorAll(".hle-link-row input").length).toBe(2);
|
||||
const add = field!.querySelector(".hle-link-add") as HTMLElement;
|
||||
expect(add.textContent).toContain("添加入口");
|
||||
|
||||
add.click();
|
||||
await settle();
|
||||
expect(field!.querySelectorAll(".hle-link-row").length).toBe(2);
|
||||
// 新增行是空行:回写布局时空行由 cleanBlock 剔除,故提交形态仍只 1 项
|
||||
const blocks = JSON.parse(model.value).home_layout.blocks;
|
||||
expect(blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
|
||||
// 其余键无损
|
||||
expect(JSON.parse(model.value).logo).toBe("/a.png");
|
||||
|
||||
// 删掉尾部的空行 → 回到 1 行,提交形态不变
|
||||
del(field!, 1).click();
|
||||
await settle();
|
||||
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
|
||||
|
||||
// 再删唯一入口行 → 空列表(提交时 items 不落键,由后端校验拦下「至少 1 个入口」)
|
||||
del(field!, 0).click();
|
||||
await settle();
|
||||
expect(field!.querySelectorAll(".hle-link-row").length).toBe(0);
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].items).toBeUndefined();
|
||||
});
|
||||
|
||||
it("quick_links:12 项上限时添加按钮禁用", async () => {
|
||||
const items = Array.from({ length: 12 }, (_, i) => ({ name: `入口${i}`, url: `/e${i}` }));
|
||||
const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "q1", type: "quick_links", items }] } });
|
||||
const { el } = await mountEditor(config);
|
||||
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
|
||||
await settle();
|
||||
const field = fieldOf(el as HTMLElement, "入口列表")!;
|
||||
expect(field.querySelectorAll(".hle-link-row").length).toBe(12);
|
||||
expect((field.querySelector(".hle-link-add") as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("notice_bar:正文输入放宽到 200 字、跳转可选、level 为 info/warn 选择器", async () => {
|
||||
const { el } = await mountEditor(CONFIG);
|
||||
(el.querySelectorAll(".hle-row")[1] as HTMLElement).click();
|
||||
await settle();
|
||||
const text = fieldOf(el as HTMLElement, "公告正文")!.querySelector("input")!;
|
||||
expect(text.getAttribute("maxlength")).toBe("200");
|
||||
expect(fieldOf(el as HTMLElement, "跳转地址")).not.toBeNull();
|
||||
const levelField = fieldOf(el as HTMLElement, "提示级别")!;
|
||||
expect(levelField.querySelector(".ivu-select")).not.toBeNull();
|
||||
expect(levelField.textContent).toContain("醒目");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(docs/20)", () => {
|
||||
it("三栏结构:序列 / 实时预览 iframe / 属性面板;视口三档与刷新按钮齐备", async () => {
|
||||
const { el } = await mountEditor(CONFIG, { portalUrl: "http://x/portal/demo/" });
|
||||
expect(el.querySelector(".hle-sequence")).toBeTruthy();
|
||||
expect(el.querySelector(".hle-props")).toBeTruthy();
|
||||
const preview = el.querySelector(".hle-preview")!;
|
||||
expect(preview).toBeTruthy();
|
||||
expect(preview.querySelector("iframe")).toBeTruthy();
|
||||
expect(preview.querySelector("iframe")!.getAttribute("src")).toBe("http://x/portal/demo/");
|
||||
// 视口三档
|
||||
expect(preview.textContent).toContain("桌面 1200");
|
||||
expect(preview.textContent).toContain("平板 768");
|
||||
expect(preview.textContent).toContain("手机 390");
|
||||
const refresh = [...preview.querySelectorAll("button")].find(b => b.textContent?.includes("刷新预览"));
|
||||
expect(refresh).toBeTruthy();
|
||||
});
|
||||
|
||||
it("添加抽屉:每个类型卡配 SVG 缩略示意图(R-40,注册表全量)", async () => {
|
||||
const { el } = await mountEditor(CONFIG);
|
||||
const addBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("添加区块"))!;
|
||||
addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await settle();
|
||||
// Drawer 渲染到 body 级传送门
|
||||
const drawer = document.querySelector(".hle-drawer") || document.body;
|
||||
const cards = drawer.querySelectorAll(".hle-type-card");
|
||||
expect(cards.length).toBeGreaterThanOrEqual(6);
|
||||
cards.forEach(c => {
|
||||
expect(c.querySelector(".hle-type-thumb svg")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("展示形态 = RadioCard 可视化选择(5 张缩略图 + 文字,非纯文字下拉)", async () => {
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
|
||||
});
|
||||
const { el, model } = await mountEditor(config);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const styleField = fieldOf(el as HTMLElement, "展示形态")!;
|
||||
expect(styleField.querySelector(".hle-style-cards")).toBeTruthy();
|
||||
expect(styleField.querySelectorAll(".hle-style-card").length).toBe(5);
|
||||
expect(styleField.querySelectorAll(".hle-style-card svg").length).toBe(5);
|
||||
// 点击第 2 张卡(news_grid)→ 选中态 + 回写布局
|
||||
(styleField.querySelectorAll(".hle-style-card")[1] as HTMLElement).click();
|
||||
await settle();
|
||||
expect(styleField.querySelectorAll(".hle-style-card")[1].className).toContain("active");
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].style).toBe("news_grid");
|
||||
});
|
||||
|
||||
it("首用自动编排:无 home_layout 键 + 站点有列表栏目 → 自动生成初始序列(D-4)", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const noLayoutConfig = JSON.stringify({ logo: "/a.png" });
|
||||
const { model } = await mountEditor(noLayoutConfig, { siteId: 1 });
|
||||
const layout = JSON.parse(model.value).home_layout;
|
||||
expect(layout).toBeTruthy();
|
||||
expect(layout.blocks.length).toBeGreaterThanOrEqual(4); // ticker + 2 栏目区块 + gallery
|
||||
expect(layout.blocks[0].type).toBe("ticker");
|
||||
expect(layout.blocks.some((b: { type: string; title?: string }) => b.type === "article_list" && b.title === "校园时讯")).toBe(true);
|
||||
expect(layout.blocks.some((b: { type: string }) => b.type === "gallery")).toBe(true);
|
||||
// 其余键无损
|
||||
expect(JSON.parse(model.value).logo).toBe("/a.png");
|
||||
});
|
||||
|
||||
it("显式空布局(blocks=[])不触发自动编排(用户选择优先)", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const emptyLayout = JSON.stringify({ home_layout: { version: 1, blocks: [] } });
|
||||
const { model } = await mountEditor(emptyLayout, { siteId: 1 });
|
||||
expect(JSON.parse(model.value).home_layout.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("无列表栏目不触发自动编排", async () => {
|
||||
treeMock.send.mockResolvedValue([]);
|
||||
const { model } = await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteId: 1 });
|
||||
expect(JSON.parse(model.value).home_layout).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - 栏目树加载与首用编排解耦(docs/20 返工 #1)", () => {
|
||||
it("已有布局 + 有列表栏目 + siteId:栏目树仍无条件加载,数据栏目可勾选", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
|
||||
});
|
||||
const { el, model } = await mountEditor(config, { siteId: 1 });
|
||||
// 树加载发生(幂等标记下 treeMock 恰好 1 次调用)
|
||||
expect(treeMock.send).toHaveBeenCalledTimes(1);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
|
||||
expect(cats).not.toBeNull();
|
||||
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
|
||||
expect(cats.textContent).toContain("校园时讯");
|
||||
expect(cats.textContent).toContain("媒体聚焦");
|
||||
// 已有布局不被自动编排改写
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].id).toBe("a1");
|
||||
});
|
||||
|
||||
it("siteId 晚于挂载到位(父级异步种入):watch 补跑加载,数据栏目可用", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const siteIdRef = ref<number | undefined>(undefined);
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [], style: "card", size: 8 }] },
|
||||
});
|
||||
const { el } = await mountEditor(config, { siteIdRef });
|
||||
await settle();
|
||||
// siteId 未到位时不请求
|
||||
expect(treeMock.send).not.toHaveBeenCalled();
|
||||
siteIdRef.value = 1;
|
||||
await settle(6);
|
||||
expect(treeMock.send).toHaveBeenCalledTimes(1);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
|
||||
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
|
||||
});
|
||||
|
||||
it("watch + onMounted 双触发下幂等:同 siteId 只请求一次", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const siteIdRef = ref<number | undefined>(1);
|
||||
await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteIdRef });
|
||||
await settle(6);
|
||||
expect(treeMock.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - 区块序列序号徽标(docs/20 返工 #3)", () => {
|
||||
it("每行右上角显示连续序号(1 起始、含隐藏区块不重排),与校验报错「第 N 个区块」对齐", async () => {
|
||||
const config = JSON.stringify({
|
||||
home_layout: {
|
||||
version: 1,
|
||||
blocks: [
|
||||
{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 },
|
||||
{ id: "n1", type: "notice_bar", text: "公告", visible: false },
|
||||
{ id: "g1", type: "gallery" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { el } = await mountEditor(config);
|
||||
const nos = Array.from(el.querySelectorAll(".hle-row-no")).map(n => n.textContent!.trim());
|
||||
expect(nos).toEqual(["1", "2", "3"]);
|
||||
// 隐藏区块(第 2 行)保留序号、行整体置灰,编号不重排
|
||||
const rows = Array.from(el.querySelectorAll(".hle-row"));
|
||||
expect(rows[1].className).toContain("hidden");
|
||||
expect(rows[1].querySelector(".hle-row-no")!.textContent!.trim()).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - D 组件属性面板(docs/20 D-1/D-8)", () => {
|
||||
it("stats_band:统计项三列编辑器(名称/数值/单位)渲染并可增行", async () => {
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [
|
||||
{ id: "s1", type: "stats_band", stats: [{ label: "在校生", value: "3.2", unit: "万" }] },
|
||||
] },
|
||||
});
|
||||
const { el } = await mountEditor(config);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const field = fieldOf(el as HTMLElement, "统计项")!;
|
||||
expect(field.querySelector(".hle-stat-row")).toBeTruthy();
|
||||
expect((field.querySelector(".hle-stat-row input") as HTMLInputElement)).toBeTruthy();
|
||||
const add = field.querySelector(".hle-stat-add, .hle-link-add") as HTMLElement;
|
||||
add.click();
|
||||
await settle();
|
||||
expect(field.querySelectorAll(".hle-stat-row").length).toBe(2);
|
||||
});
|
||||
|
||||
it("gallery:布局形态下拉含瀑布流(campus_wall)选项且选中回写", async () => {
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [{ id: "g1", type: "gallery" }] },
|
||||
});
|
||||
const { el, model } = await mountEditor(config);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const field = fieldOf(el as HTMLElement, "布局形态")!;
|
||||
const select = field.querySelector(".ivu-select") as HTMLElement;
|
||||
expect(select).toBeTruthy();
|
||||
// 展开下拉点选 masonry
|
||||
select.querySelector(".ivu-select-selection").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await settle(8);
|
||||
const opt = [...document.querySelectorAll(".ivu-select-item")].find(n => n.textContent?.includes("瀑布流")) as HTMLElement;
|
||||
expect(opt).toBeTruthy();
|
||||
opt.click();
|
||||
await settle(8);
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].layout).toBe("masonry");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* SiteConfigForm 组件级回归(docs/20 返工 #4):
|
||||
* - 本分区无必填字段:全空 → validate() 通过(返回 null),不因「必填」拦保存;
|
||||
* - 全合法值 → validate() 通过;
|
||||
* - 填了但格式非法 → validate() 返回该字段的明确错误(「字段名:原因」),
|
||||
* 且表单内出现 iView 内联红框(.ivu-form-item-error),不再有「红星必填」式误导。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 homeLayoutEditor.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
|
||||
|
||||
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
/** 排空微任务 + 一个宏任务(iView 校验态/DOM 类名更新走 nextTick) */
|
||||
async function settle(frames = 4) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
async function mountForm(modelValue: string, groups: string[]) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const model = ref(modelValue);
|
||||
const childRef = ref<any>(null);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(SiteConfigForm, {
|
||||
ref: childRef,
|
||||
modelValue: model.value,
|
||||
groups,
|
||||
"onUpdate:modelValue": (v: string) => (model.value = v),
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
await settle(6);
|
||||
return { el, model, child: childRef };
|
||||
}
|
||||
|
||||
/** 模拟真实输入:走原生 input 事件(iView Input 据此回填 v-model 并触发 on-change 回写) */
|
||||
async function typeByPlaceholder(el: HTMLElement, placeholder: string, value: string) {
|
||||
const input = Array.from(el.querySelectorAll<HTMLInputElement>("input, textarea")).find(
|
||||
n => n.getAttribute("placeholder") === placeholder,
|
||||
);
|
||||
expect(input, `placeholder ${placeholder} 对应的输入框应存在`).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input!, value);
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
beforeEach(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach(fn => fn());
|
||||
});
|
||||
|
||||
describe("SiteConfigForm 校验(docs/20 返工 #4)", () => {
|
||||
it("全空(无必填字段)→ validate() 通过返回 null", async () => {
|
||||
const { child } = await mountForm("", ["display"]);
|
||||
const err = await child.value.validate();
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it("全合法值 → validate() 通过返回 null", async () => {
|
||||
const { el, child } = await mountForm("", ["display"]);
|
||||
await typeByPlaceholder(el, "门户顶栏展示的一句话标语(可选)", "云海大学欢迎您");
|
||||
await typeByPlaceholder(el, "留空默认「专业院系」", "院系导航");
|
||||
const err = await child.value.validate();
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it("合法 configJson 种入 + 手填非法 URL → 返回「字段名:原因」,出现内联红框", async () => {
|
||||
const { el, child } = await mountForm(JSON.stringify({ logo: "/yschcms/upload/2026/06/logo.png" }), ["footer"]);
|
||||
await typeByPlaceholder(el, "微博主页链接 https://weibo.com/…(留空不展示图标)", "不是链接");
|
||||
const err = await child.value.validate();
|
||||
expect(typeof err).toBe("string");
|
||||
expect(err).toContain("微博");
|
||||
expect(err).toContain("http");
|
||||
// 内联红框 + 字段下方错误文案存在
|
||||
expect(el.querySelector(".ivu-form-item-error .ivu-form-item-error-tip")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asset 字段非法值同样精确报错(种入非法 logo 值)", async () => {
|
||||
// REWORK-DECISION: 经控件 UI 填 asset 值的路径依赖上传(jsdom 不可行)与「手动粘贴」通道
|
||||
// (其「填入」按钮因 slot="append" 旧语法在 Vue 3 下不渲染,属本次返工范围外的存量缺陷,
|
||||
// 见返工报告遗留项),故从种入 configJson 驱动:等价覆盖「存在非法 asset 值 → 精确报错」。
|
||||
const { el, child } = await mountForm(JSON.stringify({ logo: "随便写的路径" }), ["display"]);
|
||||
const err = await child.value.validate();
|
||||
expect(typeof err).toBe("string");
|
||||
expect(err).toContain("站点 Logo");
|
||||
expect(err).toContain("上传路径");
|
||||
expect(el.querySelector(".ivu-form-item-error")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* docs/13 方案 C 站点分层(URL 站点段 + 站点身份条):
|
||||
* ① 深链子站 URL → store.currentSite 同步为子站并可感知(徽章/色带);
|
||||
* ② 非法/跨校/主站编码当子站段 → 告警并打回同页主站形态;
|
||||
* ③ URL 无站点段即主站(localStorage 记忆不再作导航决策);
|
||||
* ④ 身份条下拉一步切换:只改 URL、停留当前功能页、点后收起;
|
||||
* ⑤ 子站形态菜单高亮剥段匹配(菜单 page 保持主站形态)。
|
||||
*
|
||||
* 沿用手写 createApp 挂载 + vi.mock(vue-router) 桩风格(与 categoryWorkbench.spec.ts 一致);
|
||||
* route 用 reactive 包装以驱动 SchoolLayout 的 watch(fullPath)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import ViewUIPlus, { Message } from "view-ui-plus";
|
||||
|
||||
const box = vi.hoisted(() => ({
|
||||
route: null as any,
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
go: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRoute: () => box.route,
|
||||
useRouter: () => ({ push: box.push, replace: box.replace, go: box.go, getRoutes: () => [] }),
|
||||
}));
|
||||
|
||||
import SchoolLayout from "@/components/SchoolLayout.vue";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
/** 站点卡皮肤包名取 user.sites[].theme(后端 AuthUserVo.sites = List<SiteVo>,字段已具备,零后端改动) */
|
||||
const SITES = [
|
||||
{ id: 1, code: "demo", name: "云海大学门户", path: "demo", parentSiteId: 0, theme: "scholar-blue", schId: 1001, schName: "云海大学" },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", path: "demo/jdxy", parentSiteId: 1, theme: "campus-glow", schId: 1001, schName: "云海大学" },
|
||||
{ id: 3, code: "wtxy", name: "文学院", path: "demo/wtxy", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
];
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
let currentStore: ReturnType<typeof useCmsStore> | null = null;
|
||||
|
||||
function setRoute(path: string, params: Record<string, string>, meta: any = { title: "文章列表", modId: 11 }) {
|
||||
box.route = reactive({ path, fullPath: path, params, query: {}, hash: "", meta });
|
||||
}
|
||||
|
||||
/** 挂载学校壳;restoredSite 模拟刷新首帧从 localStorage 恢复的站点残留 */
|
||||
function mountLayout(restoredSite?: any) {
|
||||
const pinia = createPinia();
|
||||
const store = useCmsStore(pinia);
|
||||
currentStore = store;
|
||||
store.user = { superAdmin: true, sites: SITES } as any;
|
||||
store.currentSchool = {
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
mainSiteId: 1,
|
||||
mainSiteCode: "demo",
|
||||
mainSiteName: "云海大学门户",
|
||||
} as any;
|
||||
store.currentSite = (restoredSite || { id: 1, code: "demo", name: "云海大学门户" }) as any;
|
||||
store.session.modList = [{ id: 11, name: "文章列表", modId: 11, page: "/article" }];
|
||||
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({ render: () => h(SchoolLayout) }));
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
app.component("router-view", defineComponent({ render: () => h("div", { class: "rv-stub" }) }));
|
||||
// BreadcrumbItem 传 to 时经 mixins-link 解析链接,需要 $router 桩(测试不引入真实路由实例)
|
||||
app.config.globalProperties.$router = {
|
||||
resolve: (to: any) => ({ href: "#" + (typeof to === "string" ? to : to?.path || "") }),
|
||||
push: box.push,
|
||||
replace: box.replace,
|
||||
};
|
||||
app.config.globalProperties.$route = box.route;
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
async function flush(frames = 5) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
currentStore = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("docs/13 站点分层:URL 站点段 ↔ 上下文同步", () => {
|
||||
it("① 深链子站 URL:currentSite 同步为子站,徽章「院系子站」+ 橙色带 + 面包屑站点段", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
expect(currentStore!.currentSite!.id).toBe(2);
|
||||
expect(currentStore!.currentSite!.code).toBe("jdxy");
|
||||
expect(el.querySelector(".site-identity .bdg")!.textContent).toContain("院系子站");
|
||||
expect(el.querySelector(".path-nav")!.className).toContain("band-sub");
|
||||
// 面包屑:学校列表 > 云海大学 > [院系子站] 机电学院 > 文章列表
|
||||
expect(el.textContent).toContain("云海大学");
|
||||
expect(el.textContent).toContain("机电学院");
|
||||
expect(el.textContent).toContain("文章列表");
|
||||
});
|
||||
|
||||
it("② 非法/跨校 siteCode:告警并打回同页主站形态,上下文归主站", async () => {
|
||||
const warn = vi.spyOn(Message, "warning");
|
||||
setRoute("/sch/demo/site/nope/category", { schCode: "demo", siteCode: "nope" }, { title: "栏目管理", modId: 2 });
|
||||
mountLayout();
|
||||
await flush();
|
||||
|
||||
expect(warn).toHaveBeenCalled();
|
||||
expect(box.replace).toHaveBeenCalledWith(expect.objectContaining({ path: "/sch/demo/category" }));
|
||||
expect(currentStore!.currentSite!.id).toBe(1);
|
||||
});
|
||||
|
||||
it("②b 把主站编码当子站段(/site/demo/)同样视为非法并打回", async () => {
|
||||
setRoute("/sch/demo/site/demo/article", { schCode: "demo", siteCode: "demo" });
|
||||
mountLayout();
|
||||
await flush();
|
||||
expect(box.replace).toHaveBeenCalledWith(expect.objectContaining({ path: "/sch/demo/article" }));
|
||||
});
|
||||
|
||||
it("③ URL 无站点段即主站:localStorage 里残留的子站上下文不再恢复", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout({ id: 2, code: "jdxy", name: "机电学院" });
|
||||
await flush();
|
||||
|
||||
expect(currentStore!.currentSite!.id).toBe(1);
|
||||
expect(el.querySelector(".path-nav")!.className).toContain("band-main");
|
||||
expect(el.querySelector(".site-identity .bdg")!.textContent).toContain("校主站");
|
||||
});
|
||||
|
||||
it("②c 切换 URL 到子站:同步跟随(同一壳内导航,五联动中的 store/徽章/色带)", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
expect(currentStore!.currentSite!.id).toBe(1);
|
||||
|
||||
const p = "/sch/demo/site/wtxy/article";
|
||||
box.route.path = p;
|
||||
box.route.fullPath = p;
|
||||
box.route.params = { schCode: "demo", siteCode: "wtxy" };
|
||||
await flush();
|
||||
|
||||
expect(currentStore!.currentSite!.id).toBe(3);
|
||||
expect(el.querySelector(".path-nav")!.className).toContain("band-sub");
|
||||
expect(el.textContent).toContain("文学院");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 站点身份条:SiteSwitcher 一步切换", () => {
|
||||
it("④ 展开下拉 → 点击子站行 → push 当前功能页的子站形态 URL 并收起", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
const dd = el.querySelector(".site-switcher-dd");
|
||||
expect(dd).toBeTruthy();
|
||||
// 分组结构:主站 1 行 + 院系子站 2 行
|
||||
expect(el.querySelectorAll(".site-switcher-dd .dd-item")).toHaveLength(3);
|
||||
expect(dd!.textContent).toContain("院系子站(2)");
|
||||
|
||||
const jdxy = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
|
||||
r.textContent?.includes("机电学院")
|
||||
)!;
|
||||
jdxy.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
// 停留当前功能页(article),只改 URL;store 由路由同步接管
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/article");
|
||||
expect(el.querySelector(".site-switcher-dd")).toBeNull();
|
||||
});
|
||||
|
||||
it("④b 子站态下切回主站:URL 省略站点段;Escape 收起面板", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/category", { schCode: "demo", siteCode: "jdxy" }, { title: "栏目管理", modId: 2 });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
const mainRow = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
|
||||
r.textContent?.includes("云海大学门户")
|
||||
)!;
|
||||
mainRow.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/category");
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(el.querySelector(".site-switcher-dd")).toBeTruthy();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
await flush();
|
||||
expect(el.querySelector(".site-switcher-dd")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 菜单高亮:剥段匹配", () => {
|
||||
it("⑤ 子站形态下菜单高亮命中;列表子页面面包屑链接带站点段(不落回主站)", async () => {
|
||||
setRoute(
|
||||
"/sch/demo/site/jdxy/article/edit",
|
||||
{ schCode: "demo", siteCode: "jdxy" },
|
||||
{ title: "新建文章", modId: 11, subOf: "/article" }
|
||||
);
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
const li = [...el.querySelectorAll(".left-menu li")].find(x => x.textContent?.includes("文章列表"));
|
||||
expect(li).toBeTruthy();
|
||||
expect(li!.className).toContain("active");
|
||||
// 面包屑「文章列表」为可点段(非末段),链接须带 /site/jdxy 以停留子站
|
||||
const crumb = [...el.querySelectorAll(".path-nav a")].find(a => a.textContent?.includes("文章列表"));
|
||||
expect(crumb).toBeTruthy();
|
||||
expect(crumb!.getAttribute("href")).toContain("/sch/demo/site/jdxy/article");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 站点上下文持久化:刷新首帧兜底(docs/15 T3)", () => {
|
||||
it("⑥ setCurrentSite 写入 localStorage;新 pinia restoreSite 恢复同值", () => {
|
||||
const pinia1 = createPinia();
|
||||
setActivePinia(pinia1);
|
||||
const s1 = useCmsStore(pinia1);
|
||||
s1.setCurrentSite({ id: 2, code: "jdxy", path: "demo/jdxy", name: "机电学院" } as any);
|
||||
expect(JSON.parse(localStorage.getItem("cms.currentSite")!).code).toBe("jdxy");
|
||||
|
||||
const pinia2 = createPinia();
|
||||
setActivePinia(pinia2);
|
||||
const s2 = useCmsStore(pinia2);
|
||||
s2.restoreSite();
|
||||
expect(s2.currentSite?.id).toBe(2);
|
||||
expect(s2.currentSite?.code).toBe("jdxy");
|
||||
});
|
||||
|
||||
it("⑥b setCurrentSite(null) 清除持久化;无残留时 restoreSite 归 null", () => {
|
||||
const pinia1 = createPinia();
|
||||
setActivePinia(pinia1);
|
||||
const s1 = useCmsStore(pinia1);
|
||||
s1.setCurrentSite({ id: 2, code: "jdxy", name: "机电学院" } as any);
|
||||
s1.setCurrentSite(null);
|
||||
expect(localStorage.getItem("cms.currentSite")).toBeNull();
|
||||
|
||||
const pinia2 = createPinia();
|
||||
setActivePinia(pinia2);
|
||||
const s2 = useCmsStore(pinia2);
|
||||
s2.restoreSite();
|
||||
expect(s2.currentSite).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 身份条次级入口与幂等切换(docs/15 T5)", () => {
|
||||
it("⑦ 主站态「前往站点管理」:URL 不带段", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
el.querySelector(".site-switcher-dd .dd-foot")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site");
|
||||
});
|
||||
|
||||
it("⑦b 子站态「前往站点管理」保持站点段;点当前站点行不重复 push(幂等)", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/banner", { schCode: "demo", siteCode: "jdxy" }, { title: "轮播管理", modId: 3 });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
// 幂等:点当前子站行,目标 URL === 当前 URL,不产生 push
|
||||
const jdxyRow = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
|
||||
r.textContent?.includes("机电学院")
|
||||
)!;
|
||||
jdxyRow.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).not.toHaveBeenCalled();
|
||||
|
||||
// 再展开 → 底部入口带段保持当前站点上下文
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
el.querySelector(".site-switcher-dd .dd-foot")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/site");
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== docs/22 v3:页头身份「学校名.站点名」 + 侧栏站点卡撤除 =====
|
||||
口径:页头 .school-cap(> .sc-sep + .sc-site)是唯一常驻身份点,主站不写站全名统一「主站」;
|
||||
切站入口收敛到面包屑站点身份条(T-B-* / SiteSwitcher 用例)与站点工作台卡片两处。
|
||||
原 v2 的卡内切站用例(T-C-01b)随卡撤除而删除。 */
|
||||
describe("P-C1 页头身份与侧栏卡撤除(docs/22 v3)", () => {
|
||||
it("T-C-01 主站态:侧栏 .menu-top-box 复归空档(卡已撤),页头 = 学校名.主站", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
// 站点卡撤除:侧栏顶部空档恢复为空 div(回到加卡之前的 layout.css 形态)
|
||||
const topBox = el.querySelector(".left-menu .menu-top-box");
|
||||
expect(topBox).toBeTruthy();
|
||||
expect(topBox!.children).toHaveLength(0);
|
||||
|
||||
// 页头身份:学校名.站点名 —— 主站档位统一「主站」,不写站全名
|
||||
expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.主站");
|
||||
expect(el.querySelector(".header-container .school-cap")!.textContent).toContain("云海大学");
|
||||
expect(el.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站");
|
||||
});
|
||||
|
||||
it("T-C-02 身份染色:子站态 .school-cap 挂 band-sub、.sc-site=站名;主站态 band-main、.sc-site=主站", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
||||
const sub = mountLayout();
|
||||
await flush();
|
||||
// 侧栏/面包屑色带档位保留(identityClass 仍在 .left-menu / .path-nav 上工作)
|
||||
expect(sub.querySelector(".left-menu")!.className).toContain("band-sub");
|
||||
expect(sub.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(true);
|
||||
expect(sub.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("机电学院");
|
||||
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const main = mountLayout();
|
||||
await flush();
|
||||
expect(main.querySelector(".left-menu")!.className).toContain("band-main");
|
||||
expect(main.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(false);
|
||||
expect(main.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站");
|
||||
});
|
||||
|
||||
it("T-C-03 标题带站点:页头与 document.title 均为「页面名 · 学校名.站点名」,切站后跟随", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
expect(document.title).toBe("文章列表 · 云海大学.机电学院");
|
||||
expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.机电学院");
|
||||
|
||||
// 同一壳内切回主站形态:标题与 document.title 同步为「学校名.主站」
|
||||
box.route.path = "/sch/demo/article";
|
||||
box.route.fullPath = "/sch/demo/article";
|
||||
box.route.params = { schCode: "demo" };
|
||||
await flush();
|
||||
expect(document.title).toBe("文章列表 · 云海大学.主站");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 站点管理页回归:docs/22 站点工作台(默认卡片视图)+ 表格视图保留。
|
||||
* 行「站点设置」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式;
|
||||
* 子站行带 URL 站点段(docs/13 adminPathOf 口径)。沿用手写 createApp 挂载桩风格。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus, { Tag } from "view-ui-plus";
|
||||
|
||||
// SiteManage 的 columns 渲染函数裸用 Tag(vite dev 由 unplugin-auto-import 注入,
|
||||
// vitest 管线不走该插件)——这里挂到全局作用域供其解析
|
||||
(globalThis as any).Tag = Tag;
|
||||
|
||||
const { siteListMock, themeListMock, routerPushMock } = vi.hoisted(() => ({
|
||||
siteListMock: vi.fn(),
|
||||
themeListMock: vi.fn(),
|
||||
routerPushMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const siteRow = {
|
||||
id: 1,
|
||||
code: "demo",
|
||||
path: "demo",
|
||||
parentSiteId: 0,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
name: "云海大学门户网站",
|
||||
theme: "default",
|
||||
configJson: '{"logo_text":"云海大学"}',
|
||||
status: 1,
|
||||
};
|
||||
|
||||
const themeRow = {
|
||||
name: "default",
|
||||
title: "高校蓝(默认)",
|
||||
description: "",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [],
|
||||
source: "builtin",
|
||||
inUseCount: 1,
|
||||
editable: true,
|
||||
usedBySiteNames: ["云海大学门户网站"],
|
||||
};
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
siteApi: { list: () => ({ send: siteListMock }) },
|
||||
themeApi: { list: () => ({ send: themeListMock }) },
|
||||
assetUrl: (u: string) => u,
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => ({ push: routerPushMock, replace: vi.fn() }),
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
}));
|
||||
|
||||
import SiteManage from "@/views/cms/SiteManage.vue";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
async function flush(frames = 6) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function mountPage(storeSetup?: (store: any) => void) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const pinia = createPinia();
|
||||
if (storeSetup) {
|
||||
storeSetup(useCmsStore(pinia));
|
||||
}
|
||||
const app = createApp(defineComponent({ render: () => h(SiteManage) }));
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/** 操作列图标按钮(iView 固定列克隆两份;按行序取第 rowIndex+1 个命中) */
|
||||
function clickRowAction(el: ParentNode, iconClass: string, rowIndex = 0) {
|
||||
const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][rowIndex];
|
||||
btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** docs/22 后默认视图为卡片;表格行操作用例先切到表格视图 */
|
||||
async function switchToTable(el: ParentNode) {
|
||||
const btn = [...el.querySelectorAll(".seg button")].find(b => b.textContent?.includes("表格"));
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
|
||||
themeListMock.mockResolvedValue(JSON.parse(JSON.stringify([themeRow])));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteManage 列表页(docs/16 整页化后)", () => {
|
||||
it("① 行「站点设置」跳站点设置中心(主站形态无站点段)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
await switchToTable(el);
|
||||
|
||||
const btn = clickRowAction(el, "icon-edit");
|
||||
expect(btn).toBeTruthy();
|
||||
await flush();
|
||||
|
||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/setting/1");
|
||||
});
|
||||
|
||||
it("② 子站行「站点设置」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => {
|
||||
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([
|
||||
siteRow,
|
||||
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机械学院" },
|
||||
])));
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
await switchToTable(el);
|
||||
|
||||
clickRowAction(el, "icon-edit", 1);
|
||||
await flush();
|
||||
|
||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/jdxy/site/setting/2");
|
||||
});
|
||||
|
||||
it("③「新建站点」按钮跳创建模式(site/new;超管无学校上下文回落首主站编码)", async () => {
|
||||
const el = mountPage(store => {
|
||||
store.user = { superAdmin: true } as any;
|
||||
});
|
||||
await flush();
|
||||
|
||||
const btn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("新建站点"));
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("站点工作台(docs/22 v3)", () => {
|
||||
/** 1 主站 + 2 子站:卡片工作台的最小分组样本 */
|
||||
function mockOneMainTwoSubs() {
|
||||
siteListMock.mockResolvedValue(
|
||||
JSON.parse(
|
||||
JSON.stringify([
|
||||
siteRow,
|
||||
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机电学院" },
|
||||
{ ...siteRow, id: 3, code: "wtxy", path: "demo/wtxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "文学院" },
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
it("T-SM-01 默认卡片视图:主站宽卡 1 + 子站网格 2,当前站唯一高亮;表格视图可切回", async () => {
|
||||
mockOneMainTwoSubs();
|
||||
const el = mountPage(store => {
|
||||
store.user = { superAdmin: true } as any;
|
||||
store.currentSite = { id: 2, code: "jdxy", name: "机电学院" } as any;
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(el.querySelectorAll(".main-card")).toHaveLength(1);
|
||||
expect(el.querySelectorAll(".site-card")).toHaveLength(2);
|
||||
expect(el.querySelectorAll(".site-card.is-cur")).toHaveLength(1);
|
||||
// 主站卡「子站」统计 = 过滤前真实规模(SiteVo 无栏目/文章字段,仅子站数必显)
|
||||
expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2");
|
||||
|
||||
// 切表格视图:卡片网格消失、ivu-table 出现
|
||||
await switchToTable(el);
|
||||
expect(el.querySelector(".card-grid")).toBeNull();
|
||||
expect(el.querySelector(".ivu-table")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("T-SM-02 搜索过滤:命中子站保留、主站宽卡不消失(层级锚点),子站统计不随过滤变化", async () => {
|
||||
mockOneMainTwoSubs();
|
||||
const el = mountPage(store => {
|
||||
store.user = { superAdmin: true } as any;
|
||||
store.currentSite = { id: 1, code: "demo", name: "云海大学门户网站" } as any;
|
||||
});
|
||||
await flush();
|
||||
|
||||
const input = el.querySelector(".seg-search input") as HTMLInputElement;
|
||||
input.value = "机电";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(el.querySelectorAll(".main-card")).toHaveLength(1);
|
||||
expect(el.querySelectorAll(".site-card")).toHaveLength(1);
|
||||
expect(el.querySelector(".site-card")!.textContent).toContain("机电学院");
|
||||
// rawSubs 按过滤前全量行统计:仍是 2 个子站
|
||||
expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* docs/13 方案 C 复检:真实 vue-router 集成(docs/15 §3 T1/T4)。
|
||||
* siteLayer.spec.ts 全程 router 桩(vi.mock vue-router),覆盖不到镜像层真实匹配与守卫重定向,
|
||||
* 本文件装载 routes.ts 全量路由 + router/index.ts 守卫补位:
|
||||
* ① 路由结构:/site/{code} 无 component 镜像层存在、镜像去 name、meta.modId 保留、空段 redirect 兜底;
|
||||
* ② 镜像层真实 resolve 命中(渲染层由 router-view 对无 component 记录跳过推进,此处验匹配链);
|
||||
* ③ 守卫:子站编码当学校段 → 定向主站前缀且余部原样保留(含站点段),并恢复学校上下文;
|
||||
* ④ legacyRedirect 旧链带 id 重定向。
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useCmsStore } from "@/store";
|
||||
import router from "@/router";
|
||||
import { routes } from "@/router/routes";
|
||||
|
||||
const SITES = [
|
||||
{ id: 1, code: "demo", name: "云海大学门户", parentSiteId: 0, schId: 1001, schName: "云海大学" },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
];
|
||||
|
||||
let store: ReturnType<typeof useCmsStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
store = useCmsStore(pinia);
|
||||
store.user = { superAdmin: true, sites: SITES } as any;
|
||||
});
|
||||
|
||||
describe("docs/13 路由结构(真实 routes 数组,docs/15 T1)", () => {
|
||||
it("① /site/{code} 无 component 镜像层存在;镜像去 name、meta.modId 保留、空段 redirect 到 article", () => {
|
||||
const sch = routes.find(r => r.path === "/sch/:schCode")!;
|
||||
const siteLayer = sch.children!.find(r => r.path === "site/:siteCode") as any;
|
||||
expect(siteLayer).toBeTruthy();
|
||||
expect(siteLayer.component).toBeUndefined();
|
||||
expect(siteLayer.children![0].path).toBe("");
|
||||
expect(siteLayer.children![0].redirect).toBeTruthy();
|
||||
const mirror = siteLayer.children!.find((c: any) => c.path === "category");
|
||||
expect(mirror!.name).toBeUndefined();
|
||||
expect((mirror!.meta as any).modId).toBe(2);
|
||||
});
|
||||
|
||||
it("①b 主站清单与镜像层不重名(防重复注册告警)", () => {
|
||||
expect(router.getRoutes().filter(r => r.name === "CategoryManage")).toHaveLength(1);
|
||||
expect(router.getRoutes().filter(r => r.name === "ArticleList")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("② 子站 URL 真实 resolve 命中镜像记录;主站 URL 命中主记录", () => {
|
||||
const sub = router.resolve("/sch/demo/site/jdxy/category");
|
||||
expect(sub.matched).toHaveLength(3);
|
||||
expect(sub.name).toBeUndefined();
|
||||
expect(sub.meta.modId).toBe(2);
|
||||
|
||||
const main = router.resolve("/sch/demo/category");
|
||||
expect(main.matched).toHaveLength(2);
|
||||
expect(main.name).toBe("CategoryManage");
|
||||
});
|
||||
|
||||
it("②c 事项库路由已随 V2.0 拆分移除(svc/item、svc/dict 不再注册,直达落入 404 重定向)", () => {
|
||||
// 事项管理能力已迁独立办事大厅(svc-hall 应用);CMS 前端仅保留展示壳,
|
||||
// 旧菜单路径由 UserBase 停用 + 通配重定向兜底。
|
||||
const item = router.resolve("/sch/demo/svc/item");
|
||||
expect(item.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 守卫与旧链兼容(真实 router.push,docs/15 T4)", () => {
|
||||
it("③ 子站编码当学校段 → 重定向主站前缀(余部保留)并恢复学校上下文", async () => {
|
||||
await router.push("/sch/jdxy/article");
|
||||
expect(router.currentRoute.value.path).toBe("/sch/demo/article");
|
||||
expect(store.currentSchool?.mainSiteCode).toBe("demo");
|
||||
});
|
||||
|
||||
it("③b 子站编码当学校段且 URL 已带站点段 → 站点段原样保留", async () => {
|
||||
await router.push("/sch/jdxy/site/jdxy/category");
|
||||
expect(router.currentRoute.value.path).toBe("/sch/demo/site/jdxy/category");
|
||||
expect(router.currentRoute.value.params.siteCode).toBe("jdxy");
|
||||
});
|
||||
|
||||
it("③c 学校编码不在用户可管理站点内 → 回工作台", async () => {
|
||||
await router.push("/sch/other-school/article");
|
||||
expect(router.currentRoute.value.path).toBe("/workbench");
|
||||
});
|
||||
|
||||
it("④ 旧链 /article/edit/:id → 当前学校上下文主站形态(id 后缀保留)", async () => {
|
||||
store.currentSchool = {
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
mainSiteId: 1,
|
||||
mainSiteCode: "demo",
|
||||
mainSiteName: "云海大学门户",
|
||||
} as any;
|
||||
await router.push("/article/edit/9");
|
||||
expect(router.currentRoute.value.path).toBe("/sch/demo/article/edit/9");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 站点设置中心(docs/16 主线A → docs/20 F1 Tab 化 + 渐进式向导)挂载回归:
|
||||
* ① 创建模式(渐进式向导):无任何「创建站点后开放」灰态;主按钮=「创建站点,下一步」;
|
||||
* 创建成功自动进入第二步(皮肤 Tab + Steps + 「保存并完成」);
|
||||
* 校级锁定本校主站时门户地址预览随编码联动;
|
||||
* ② 编辑模式:7 Tab 结构、基本信息回填、未注册皮肤告警保留原值;
|
||||
* ③ Tab 独立保存:保存基本信息 = 以服务端事实源 configJson 提交(其他 Tab 键不丢、不连带提交);
|
||||
* ④ 自定义样式独立 Tab(textarea 代码编辑器,非折叠面板)。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
const routeMock = vi.hoisted(() => ({ params: {} as Record<string, string>, query: {} as Record<string, string> }));
|
||||
const routerMock = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn() }));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
siteList: vi.fn(),
|
||||
siteSave: vi.fn(),
|
||||
themeList: vi.fn(),
|
||||
}));
|
||||
|
||||
const siteRow = {
|
||||
id: 1,
|
||||
code: "demo",
|
||||
path: "demo",
|
||||
parentSiteId: 0,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
name: "云海大学门户网站",
|
||||
theme: "default",
|
||||
configJson: '{"logo":"/cms/upload/logo.png","gallery_title":"线上展厅","home_layout":{"version":1,"blocks":[{"id":"b1","type":"links"}]}}',
|
||||
status: 1,
|
||||
};
|
||||
|
||||
const themeRow = {
|
||||
name: "default",
|
||||
title: "高校蓝(默认)",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||
};
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
siteApi: {
|
||||
list: () => ({ send: mocks.siteList }),
|
||||
save: (data: any) => ({ send: () => mocks.siteSave(data) }),
|
||||
},
|
||||
themeApi: { list: () => ({ send: mocks.themeList }) },
|
||||
assetUrl: (u: string) => u,
|
||||
uploadUrl: () => "/api/attachment/upload",
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => routerMock,
|
||||
useRoute: () => routeMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePicker.vue", () => ({
|
||||
default: defineComponent({ props: ["themes", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-picker-stub") }),
|
||||
}));
|
||||
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
|
||||
default: defineComponent({ props: ["theme", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-param-stub") }),
|
||||
}));
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
|
||||
}));
|
||||
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["modelValue", "groups", "siteId"],
|
||||
// 返工 #4 起 validate 契约:null=通过;失败返回首个错误文案
|
||||
methods: { validate: async () => null, ownFields: () => ({}) },
|
||||
render: () => h("div", "config-form-stub"),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/views/cms/homeLayout/HomeLayoutEditor.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["modelValue", "siteId"],
|
||||
emits: ["update:modelValue", "save"],
|
||||
methods: { validate: () => null },
|
||||
render: () => h("div", "layout-editor-stub"),
|
||||
}),
|
||||
}));
|
||||
|
||||
import SiteSetting from "@/views/cms/SiteSetting.vue";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
async function flush(frames = 6) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function mountPage(storeSetup?: (store: any) => void) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const pinia = createPinia();
|
||||
if (storeSetup) {
|
||||
// 挂载前把上下文注入同一 pinia(defineStore 幂等,组件内拿到同一实例)
|
||||
storeSetup(useCmsStore(pinia));
|
||||
}
|
||||
const app = createApp(defineComponent({ render: () => h(SiteSetting) }));
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
routeMock.params = {};
|
||||
routeMock.query = {};
|
||||
mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
|
||||
mocks.themeList.mockResolvedValue(JSON.parse(JSON.stringify([themeRow])));
|
||||
mocks.siteSave.mockImplementation(async (data: any) => ({ ...siteRow, ...data, id: data.id ?? 99 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteSetting 创建模式(docs/20 F1 渐进式向导)", () => {
|
||||
it("第一步仅基本信息表单:无任何灰态锁定提示,主按钮=创建站点,下一步", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).not.toContain("创建站点后开放");
|
||||
expect(el.textContent).toContain("新建站点");
|
||||
expect(el.textContent).toContain("创建站点,下一步");
|
||||
expect(el.querySelector('input[placeholder="URL 路径用,如 demo"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
|
||||
expect(input).toBeTruthy();
|
||||
setInputValue(input, "jdxy");
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("/portal/jdxy/");
|
||||
});
|
||||
|
||||
it("校级建子站:上级锁定本校主站,预览=上级地址/编码(原向导 lockParent 语义)", async () => {
|
||||
const el = mountPage(store => {
|
||||
store.user = { superAdmin: false } as any;
|
||||
store.enterSchool(
|
||||
{ schId: 1001, schName: "云海大学", mainSiteId: 1, mainSiteCode: "demo", mainSiteName: "云海大学门户网站" },
|
||||
[{ id: 1, code: "demo", path: "demo", name: "云海大学门户网站" }]
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
|
||||
setInputValue(input, "jdxy");
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("/portal/demo/jdxy/");
|
||||
// 上级锁定为本校主站(Select disabled)
|
||||
const selectEl = el.querySelector(".ivu-select-disabled");
|
||||
expect(selectEl).toBeTruthy();
|
||||
});
|
||||
|
||||
it("创建成功:自动进入第二步(皮肤 Tab + Steps + 保存并完成),全 Tab 开放", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
|
||||
setInputValue(input, "jdxy");
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
|
||||
setInputValue(nameInput, "机械学院门户");
|
||||
await flush();
|
||||
|
||||
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点,下一步"))!;
|
||||
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush(10);
|
||||
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
const payload = mocks.siteSave.mock.calls[0][0];
|
||||
expect(payload.code).toBe("jdxy");
|
||||
expect(payload.name).toBe("机械学院门户");
|
||||
expect(routerMock.replace).toHaveBeenCalledWith("/sch/jdxy/site/setting/99");
|
||||
// 向导第二步:Steps 可见 + 皮肤 Tab 激活 + 完成按钮;无灰态
|
||||
expect(el.textContent).toContain("皮肤与外观");
|
||||
expect(el.textContent).toContain("保存并完成");
|
||||
expect(el.textContent).toContain("跳过,稍后配置");
|
||||
expect(el.textContent).not.toContain("创建站点后开放");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SiteSetting 编辑模式(site/setting/:id,docs/20 F1 7 Tab)", () => {
|
||||
it("7 个 Tab 标题齐备(基本信息…自定义样式),基本信息回填,无灰态提示", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
["基本信息", "皮肤与外观", "首页布局", "基础展示", "页脚信息", "SEO 与域名", "自定义样式"].forEach(t =>
|
||||
expect(el.textContent).toContain(t)
|
||||
);
|
||||
expect(el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo");
|
||||
expect(el.textContent || "").not.toContain("未在注册表中");
|
||||
expect(el.textContent).toContain("云海大学门户网站");
|
||||
expect(el.textContent).not.toContain("创建站点后开放");
|
||||
});
|
||||
|
||||
it("未注册皮肤保留原值并弹出告警(不静默归一 default)", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }])));
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("未在注册表中");
|
||||
expect(el.textContent).toContain("skin-x");
|
||||
});
|
||||
|
||||
it("保存基本信息:以服务端事实源 configJson 提交(home_layout 等其他 Tab 键随行不丢)", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
|
||||
setInputValue(nameInput, "改名后的门户");
|
||||
await flush();
|
||||
|
||||
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!;
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
const payload = mocks.siteSave.mock.calls[0][0];
|
||||
expect(payload.id).toBe(1);
|
||||
expect(payload.name).toBe("改名后的门户");
|
||||
expect(payload.theme).toBe("default");
|
||||
const config = JSON.parse(payload.configJson);
|
||||
expect(config.logo).toBe("/cms/upload/logo.png");
|
||||
expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留
|
||||
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局 Tab 未动
|
||||
});
|
||||
|
||||
it("T-T-05: 皮肤 Tab 只留选皮肤(无参数微调),保存不写 theme_params", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
// ThemeParamForm 已被桩化为 theme-param-stub:站点设置页不再出现该桩
|
||||
expect(el.textContent).not.toContain("theme-param-stub");
|
||||
expect(el.textContent).not.toContain("参数微调");
|
||||
expect(el.textContent).toContain("theme-picker-stub");
|
||||
|
||||
const saveBtn = [...el.querySelectorAll("button")].find(b =>
|
||||
b.textContent?.includes("保存皮肤与外观")
|
||||
)!;
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson);
|
||||
expect(config).not.toHaveProperty("theme_params"); // 参数值不再由站点提交
|
||||
expect(config.logo).toBe("/cms/upload/logo.png"); // 其余键仍随事实源保留
|
||||
});
|
||||
|
||||
it("F1:自定义样式为独立 Tab 的代码编辑器(textarea + 字符计数),非折叠面板", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const cssTextarea = el.querySelector("textarea");
|
||||
expect(cssTextarea).toBeTruthy();
|
||||
expect(el.textContent).toContain("自定义样式");
|
||||
expect(el.textContent).toContain("/10240 字符");
|
||||
// 独立保存按钮
|
||||
const cssSaveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存自定义样式"))!;
|
||||
expect(cssSaveBtn).toBeTruthy();
|
||||
|
||||
cssSaveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson);
|
||||
expect(config).not.toHaveProperty("custom_css"); // 空样式 = 删除键
|
||||
expect(config.home_layout).toBeTruthy(); // 其他 Tab 键不受影响
|
||||
});
|
||||
});
|
||||
|
||||
describe("SiteSetting 假脏防护(docs/20 返工 #2)", () => {
|
||||
it("冷加载不标脏:7 个 Tab 标题均无 ●(种值触发的 watcher 落在 seeding 窗口内)", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const labels = Array.from(el.querySelectorAll(".ivu-tabs-tab")).map(t => t.textContent!.trim());
|
||||
expect(labels.length).toBe(7);
|
||||
labels.forEach(t => expect(t.includes("●")).toBe(false));
|
||||
});
|
||||
|
||||
it("真实编辑才标脏:改站点名称 → 基本信息 ●;保存成功后 ● 消失", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
const labels = () => Array.from(el.querySelectorAll(".ivu-tabs-tab")).map(t => t.textContent!.trim());
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
|
||||
setInputValue(nameInput, "改名后的门户");
|
||||
await flush();
|
||||
expect(labels().find(t => t.includes("基本信息"))!.includes("●")).toBe(true);
|
||||
|
||||
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!;
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
expect(labels().find(t => t.includes("基本信息"))!.includes("●")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* F5(docs/20)页脚/基础展示视觉位置示意:
|
||||
* ① 页脚分组底部渲染 FooterPreview 结构示意图(六处位置标注,已填内容回显);
|
||||
* ② asset 字段的 aspect 占位框(favicon 1:1 / 二维码 4:3,无值虚线框、有值比例预览)。
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
assetUrl: (u: string) => u,
|
||||
uploadUrl: () => "/api/attachment/upload",
|
||||
}));
|
||||
|
||||
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
async function settle(frames = 4) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
const FOOTER_CONFIG = JSON.stringify({
|
||||
contact: "电话 0571-88888888",
|
||||
footer_addresses: "潭山校区:南湖路 237 号\n淮河校区:大学路 1 号",
|
||||
qr_wechat: "/cms/upload/qr-wechat.png",
|
||||
social_weibo: "https://weibo.com/xxx",
|
||||
copyright: "XX学校 · 版权所有 © 2026",
|
||||
beian: "浙ICP备XXXXXXXX号-1",
|
||||
logo: "/cms/upload/logo.png",
|
||||
});
|
||||
|
||||
async function mountForm(configJson: string, groups?: string[]) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const model = ref(configJson);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h(SiteConfigForm, {
|
||||
modelValue: model.value,
|
||||
groups,
|
||||
"onUpdate:modelValue": (v: string) => (model.value = v),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
await settle();
|
||||
return el;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach(fn => fn());
|
||||
});
|
||||
|
||||
describe("F5 视觉位置示意(docs/20)", () => {
|
||||
it("页脚分组:底部渲染结构示意图,六处位置标注 + 已填内容回显", async () => {
|
||||
const el = await mountForm(FOOTER_CONFIG, ["footer"]);
|
||||
const preview = el.querySelector(".footer-preview")!;
|
||||
expect(preview).toBeTruthy();
|
||||
const text = preview.textContent || "";
|
||||
// 已填字段实时回显内容(而非占位标注)
|
||||
expect(text).toContain("电话 0571-88888888");
|
||||
expect(text).toContain("潭山校区:南湖路 237 号");
|
||||
expect(text).toContain("XX学校 · 版权所有 © 2026");
|
||||
expect(text).toContain("浙ICP备XXXXXXXX号-1");
|
||||
expect(preview.querySelector('img[src="/cms/upload/qr-wechat.png"]')).toBeTruthy();
|
||||
// 未填字段显示位置标注(微博二维码空 → 占位)
|
||||
expect(text).toContain("微博二维码");
|
||||
expect(text).toContain("社媒图标行");
|
||||
});
|
||||
|
||||
it("页脚全空配置:六处位置标注完整(① 联系方式/② 地址/③ 二维码/④ 社媒/⑤ 版权/⑥ 备案)", async () => {
|
||||
const el = await mountForm("{}", ["footer"]);
|
||||
const text = el.querySelector(".footer-preview")!.textContent || "";
|
||||
expect(text).toContain("① 联系方式");
|
||||
expect(text).toContain("② 多校区地址");
|
||||
expect(text).toContain("微信二维码");
|
||||
expect(text).toContain("③ 4:3");
|
||||
expect(text).toContain("④ 社媒图标行");
|
||||
expect(text).toContain("⑤ 版权文字");
|
||||
expect(text).toContain("⑥ 备案号");
|
||||
});
|
||||
|
||||
it("基础展示分组:无示意图(仅页脚有)", async () => {
|
||||
const el = await mountForm(FOOTER_CONFIG, ["display"]);
|
||||
expect(el.querySelector(".footer-preview")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("aspect 占位框:二维码无值时 4:3 虚线占位、favicon 无值时 1:1 占位", async () => {
|
||||
const el = await mountForm("{}", ["display", "footer"]);
|
||||
const holders = el.querySelectorAll(".iaf-holder");
|
||||
expect(holders.length).toBe(3); // favicon 1:1 + 微信/微博 4:3
|
||||
expect(el.querySelector(".iaf-holder-1x1")).toBeTruthy();
|
||||
expect(el.querySelectorAll(".iaf-holder-4x3").length).toBe(2);
|
||||
expect(holders[0].textContent).toContain("1:1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveEditableTheme } from "@/utils/themeEdit";
|
||||
|
||||
/**
|
||||
* 任务三(2026-09-08):站点编辑弹窗主题归一纯函数。
|
||||
* 旧行为把「不在皮肤包列表中的主题」静默归一 default,保存即悄悄改写站点换肤;
|
||||
* 新行为保留原值交服务端拒绝,仅标记告警。
|
||||
*/
|
||||
describe("resolveEditableTheme 站点编辑主题归一(任务三)", () => {
|
||||
it("注册中的主题原值保留且不告警", () => {
|
||||
expect(resolveEditableTheme("scholar-blue", ["default", "scholar-blue"])).toEqual({
|
||||
theme: "scholar-blue",
|
||||
unregistered: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("docs/20 E:下架主题解析为保留主题 + colorScheme 预设(不误报未注册)", () => {
|
||||
expect(resolveEditableTheme("classic-red", ["default", "scholar-blue", "campus-glow"])).toEqual({
|
||||
theme: "default",
|
||||
unregistered: false,
|
||||
aliasColorScheme: "classic-red",
|
||||
});
|
||||
expect(resolveEditableTheme("poly-blue", ["default", "scholar-blue", "campus-glow"])).toEqual({
|
||||
theme: "scholar-blue",
|
||||
unregistered: false,
|
||||
aliasColorScheme: "poly",
|
||||
});
|
||||
});
|
||||
|
||||
it("未注册主题保留原值并标记 unregistered(不再归一 default)", () => {
|
||||
expect(resolveEditableTheme("ghost-theme", ["default", "scholar-blue"])).toEqual({
|
||||
theme: "ghost-theme",
|
||||
unregistered: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("空主题归一 default(站点未选过皮肤的场景)", () => {
|
||||
expect(resolveEditableTheme(undefined, ["default", "scholar-blue"])).toEqual({
|
||||
theme: "default",
|
||||
unregistered: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("列表为空(加载失败)时保留原值且不误报,交服务端校验兜底", () => {
|
||||
expect(resolveEditableTheme("mint-breeze", [])).toEqual({
|
||||
theme: "mint-breeze",
|
||||
unregistered: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24 + 任务二/四 2026-09-08):
|
||||
* ① 内置卡按钮为"预览+编辑"(任务四:内置包可改显示名/描述);
|
||||
* ② 被用外部包删除禁用且 tooltip 含站点名;
|
||||
* ③ 上传改为表单弹窗(zip + 命名),提交走手动上传通道;
|
||||
* ④ 内置编辑弹窗不开放参数 schema 区、保存不带 params(后端强校验拒改);
|
||||
* ⑤ 添加皮肤包表单标识名为空时拦截。
|
||||
* ⑥ 工作项 T(T-T-06):编辑弹窗内嵌参数微调(内置包亦开放),回显/提交包级 paramValues,
|
||||
* 未触碰不带 paramValues(null=不改),卡片显示「已微调 M 项」。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus, { Message } from "view-ui-plus";
|
||||
|
||||
const { listMock, uploadMock, removeMock, deriveMock, updateMetaMock } = vi.hoisted(() => ({
|
||||
listMock: vi.fn(),
|
||||
uploadMock: vi.fn(),
|
||||
removeMock: vi.fn(),
|
||||
deriveMock: vi.fn(),
|
||||
updateMetaMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const themes = [
|
||||
{
|
||||
name: "default",
|
||||
title: "云端默认",
|
||||
description: "",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||
// 工作项 T:包级参数值(仅含被微调过的 key)
|
||||
paramValues: { primary: "#0a5c36" },
|
||||
source: "builtin",
|
||||
inUseCount: 2,
|
||||
editable: true,
|
||||
usedBySiteNames: ["第一中学门户", "机电学院"],
|
||||
},
|
||||
{
|
||||
name: "my-skin",
|
||||
title: "我的皮肤",
|
||||
description: "",
|
||||
screenshotUrl: "/api/theme/my-skin/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#b01a2e" }],
|
||||
source: "external",
|
||||
inUseCount: 1,
|
||||
editable: true,
|
||||
usedBySiteNames: ["删除保护门户"],
|
||||
},
|
||||
{
|
||||
name: "free-skin",
|
||||
title: "空闲皮肤",
|
||||
description: "",
|
||||
screenshotUrl: "/api/theme/free-skin/asset/screenshot.png",
|
||||
params: [],
|
||||
source: "external",
|
||||
inUseCount: 0,
|
||||
editable: true,
|
||||
usedBySiteNames: [],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
themeApi: {
|
||||
list: () => ({ send: listMock }),
|
||||
upload: (file: File, meta?: unknown) => {
|
||||
uploadMock(file, meta);
|
||||
return { send: async () => ({}) };
|
||||
},
|
||||
remove: (name: string) => {
|
||||
removeMock(name);
|
||||
return { send: async () => ({}) };
|
||||
},
|
||||
derive: (source: string, name: string, meta?: unknown) => {
|
||||
deriveMock(source, name, meta);
|
||||
return { send: async () => ({}) };
|
||||
},
|
||||
updateMeta: (name: string, data: unknown) => {
|
||||
updateMetaMock(name, data);
|
||||
return { send: async () => ({}) };
|
||||
},
|
||||
reload: () => ({ send: async () => ({}) }),
|
||||
},
|
||||
siteApi: { list: () => ({ send: async () => [] }) },
|
||||
assetUrl: (u: string) => u,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["sitePath", "theme", "themeParams", "fill"],
|
||||
setup: (props: any) => () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
"data-preview-stub": "true",
|
||||
"data-fill": String(props.fill !== undefined && props.fill !== false),
|
||||
"data-params": JSON.stringify(props.themeParams || {}),
|
||||
},
|
||||
"preview-stub"
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* T-T-06:参数微调迁皮肤包级后,编辑弹窗内嵌 ThemeParamForm。
|
||||
* 桩件把当前表单模型回显在 data-model 上,并给一个「改主色」按钮模拟用户改动。
|
||||
*/
|
||||
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["theme", "modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
setup: (props: any, { emit }: any) => () =>
|
||||
h(
|
||||
"div",
|
||||
{ "data-param-stub": "true", "data-model": JSON.stringify(props.modelValue || {}) },
|
||||
[
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
class: "stub-tweak",
|
||||
onClick: () => emit("update:modelValue", { ...(props.modelValue || {}), primary: "#111111" }),
|
||||
},
|
||||
"改主色"
|
||||
),
|
||||
]
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||
|
||||
import ThemeManage from "@/views/cms/ThemeManage.vue";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
async function flush(frames = 6) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({ render: () => h(ThemeManage) }));
|
||||
app.use(createPinia());
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function clickButtonByText(root: ParentNode, text: string): HTMLElement | undefined {
|
||||
const btn = [...root.querySelectorAll("button")].find(b => b.textContent?.includes(text));
|
||||
btn?.click();
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** 页面有多个 Modal 常驻 DOM,按特征文案定位目标弹窗 */
|
||||
function modalContaining(text: string): HTMLElement | undefined {
|
||||
return [...document.querySelectorAll(".ivu-modal")].find(m => m.textContent?.includes(text));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listMock.mockResolvedValue(JSON.parse(JSON.stringify(themes)));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||
it("T-W1-22: 内置卡按钮为'预览+编辑'(任务四起内置可改元数据),无删除", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const cards = [...el.querySelectorAll(".card")];
|
||||
expect(cards).toHaveLength(3);
|
||||
const builtin = cards[0];
|
||||
expect(builtin.textContent).toContain("内置");
|
||||
const btns = [...builtin.querySelectorAll(".actions button")].map(b => b.textContent?.trim());
|
||||
expect(btns).toEqual(["预览", "编辑"]);
|
||||
});
|
||||
|
||||
it("T-W1-23: 被用外部包删除禁用且 tooltip 含站点名;空闲外部包可删", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const cards = [...el.querySelectorAll(".card")];
|
||||
const used = cards[1]; // my-skin,inUseCount=1
|
||||
const usedDelete = [...used.querySelectorAll(".actions button")].find(b => b.textContent?.includes("删除"));
|
||||
expect(usedDelete).toBeTruthy();
|
||||
expect(usedDelete!.getAttribute("disabled")).not.toBeNull();
|
||||
|
||||
// tooltip 内容含站点名(transfer 到 body)
|
||||
usedDelete!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("删除保护门户");
|
||||
|
||||
const free = cards[2];
|
||||
const freeDelete = [...free.querySelectorAll(".actions button")].find(b => b.textContent?.includes("删除"));
|
||||
expect(freeDelete!.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
|
||||
it("T-W1-24: 上传表单弹窗——未选文件时上传禁用;选文件提交调用 upload 并刷新列表", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
expect(listMock).toHaveBeenCalledTimes(1);
|
||||
const successSpy = vi.spyOn(Message, "success");
|
||||
|
||||
clickButtonByText(el, "上传皮肤包");
|
||||
await flush();
|
||||
|
||||
// 弹窗渲染到 body:未选文件时上传按钮禁用
|
||||
const uploadModal = modalContaining("皮肤包 zip");
|
||||
expect(uploadModal).toBeTruthy();
|
||||
const submit = [...uploadModal!.querySelectorAll(".ivu-modal-footer button")].find(b =>
|
||||
b.textContent?.includes("上传")
|
||||
);
|
||||
expect(submit).toBeTruthy();
|
||||
expect(submit!.getAttribute("disabled")).not.toBeNull();
|
||||
|
||||
// 选择文件(Upload before-upload 通道)→ 按钮解锁 → 提交
|
||||
const input = uploadModal!.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(input).toBeTruthy();
|
||||
const file = new File(["zip"], "pkg.zip", { type: "application/zip" });
|
||||
Object.defineProperty(input!, "files", { value: [file] });
|
||||
input!.dispatchEvent(new Event("change"));
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("已选择:pkg.zip");
|
||||
|
||||
submit!.click();
|
||||
await flush();
|
||||
expect(uploadMock).toHaveBeenCalledTimes(1);
|
||||
expect(uploadMock.mock.calls[0][0]).toBe(file);
|
||||
expect(successSpy).toHaveBeenCalled();
|
||||
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("任务四: 内置包编辑弹窗不开放参数 schema 编辑区,保存调用 updateMeta 不带 params", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const builtin = [...el.querySelectorAll(".card")][0];
|
||||
clickButtonByText(builtin, "编辑");
|
||||
await flush();
|
||||
|
||||
// 内置提示条存在、无 ColorPicker(参数 schema 编辑区不渲染;包级参数值是另一区,见 T-T-06)
|
||||
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
|
||||
expect(metaModal).toBeTruthy();
|
||||
expect(document.body.textContent).toContain("内置包内容(模板/资产)不可修改");
|
||||
expect(metaModal!.querySelectorAll(".ivu-color-picker").length).toBe(0);
|
||||
|
||||
clickButtonByText(metaModal!, "保存");
|
||||
await flush();
|
||||
expect(updateMetaMock).toHaveBeenCalledWith(
|
||||
"default",
|
||||
expect.objectContaining({ title: "云端默认", params: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it("T-T-06: 编辑弹窗内嵌参数微调,回显包级 paramValues(缺项回落默认),保存提交 paramValues", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
clickButtonByText([...el.querySelectorAll(".card")][0], "编辑");
|
||||
await flush();
|
||||
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
|
||||
const stub = metaModal!.querySelector('[data-param-stub]');
|
||||
expect(stub).toBeTruthy();
|
||||
// paramValuesOf:已微调的 primary 取包值,未声明微调的走 theme.json 默认
|
||||
expect(JSON.parse(stub!.getAttribute("data-model")!)).toEqual({ primary: "#0a5c36" });
|
||||
|
||||
// 改主色 → 保存:只提交「与默认值不同」的 key(=「已微调」口径,与后端整体替换语义对齐)
|
||||
clickButtonByText(metaModal!, "改主色");
|
||||
await flush();
|
||||
clickButtonByText(metaModal!, "保存");
|
||||
await flush();
|
||||
expect(updateMetaMock).toHaveBeenCalledWith("default", expect.objectContaining({ paramValues: { primary: "#111111" } }));
|
||||
});
|
||||
|
||||
it("T-T-06: 未触碰参数区时保存不带 paramValues(后端 null=不改,防止改名顺带清空微调)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
clickButtonByText([...el.querySelectorAll(".card")][2], "编辑"); // free-skin(外部包,无微调)
|
||||
await flush();
|
||||
const metaModal = modalContaining("编辑皮肤:free-skin");
|
||||
expect(metaModal).toBeTruthy();
|
||||
expect(JSON.parse(metaModal!.querySelector('[data-param-stub]')!.getAttribute("data-model")!)).toEqual({});
|
||||
clickButtonByText(metaModal!, "保存");
|
||||
await flush();
|
||||
const payload = updateMetaMock.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect("paramValues" in payload).toBe(false);
|
||||
});
|
||||
|
||||
it("T-T-06: 卡片显示「已微调 M 项」,无微调的包显示未微调", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
const cards = [...el.querySelectorAll(".card")];
|
||||
expect(cards[0].textContent).toContain("参数 1 个 · 已微调 1 项");
|
||||
expect(cards[1].textContent).toContain("未微调"); // my-skin 无 paramValues
|
||||
expect(cards[2].textContent).toContain("参数 0 个");
|
||||
});
|
||||
|
||||
it("任务四: 添加皮肤包表单——标识名为空被拦截,不调用 derive", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
const warnSpy = vi.spyOn(Message, "warning");
|
||||
|
||||
clickButtonByText(el, "添加皮肤包");
|
||||
await flush();
|
||||
const addModal = modalContaining("复制自");
|
||||
expect(addModal).toBeTruthy();
|
||||
clickButtonByText(addModal!, "添加");
|
||||
await flush();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(deriveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("2026-09-10: 预览弹窗全屏展示(fullscreen + 无 footer),预览框 fill 占满余量", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const builtin = [...el.querySelectorAll(".card")][0];
|
||||
clickButtonByText(builtin, "预览");
|
||||
await flush();
|
||||
|
||||
const previewModal = modalContaining("内容源站点");
|
||||
expect(previewModal).toBeTruthy();
|
||||
expect(previewModal!.className).toContain("ivu-modal-fullscreen");
|
||||
expect(previewModal!.className).toContain("ivu-modal-fullscreen-no-footer");
|
||||
const stub = previewModal!.querySelector("[data-preview-stub]");
|
||||
expect(stub).toBeTruthy();
|
||||
expect(stub!.getAttribute("data-fill")).toBe("true");
|
||||
});
|
||||
|
||||
it("T-T-06 佐证: 预览转发包级 paramValues(后端 /api/theme/preview 只认 p_*,不转发则预览丢包色)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
clickButtonByText([...el.querySelectorAll(".card")][0], "预览"); // default:paramValues={primary:#0a5c36}
|
||||
await flush();
|
||||
const stub = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!;
|
||||
expect(JSON.parse(stub.getAttribute("data-params")!)).toEqual({ primary: "#0a5c36" });
|
||||
|
||||
clickButtonByText([...el.querySelectorAll(".card")][2], "预览"); // free-skin:未微调
|
||||
await flush();
|
||||
const free = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!;
|
||||
expect(JSON.parse(free.getAttribute("data-params")!)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* 路径参数型 POST 接口的 siteId 透传回归(publish 曾因 postJSON 丢第三参,
|
||||
* 请求裸发 POST /api/article/publish/{id},后端报「缺少必填参数:siteId」)。
|
||||
* alova 实例与 host.config(顶层 document 依赖)均 mock,node 环境纯断言。
|
||||
* host.config 用 vi.hoisted 可变对象:assetUrl 用例需要切换 serviceHost 模拟生产 /yschcms。
|
||||
*/
|
||||
const confState = vi.hoisted(() => ({
|
||||
conf: { baseHost: "", ssoHost: "", serviceHost: "", isMock: false, isDebug: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/assets/config/host.config", () => ({
|
||||
default: confState.conf,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/alova/alova-iview", () => ({
|
||||
alovaInstance: {
|
||||
Get: vi.fn(),
|
||||
Post: vi.fn(() => ({})),
|
||||
Put: vi.fn(() => ({})),
|
||||
Delete: vi.fn(() => ({})),
|
||||
},
|
||||
}));
|
||||
|
||||
import { articleApi, assetUrl, themeApi, themePreviewUrl } from "./cms";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
|
||||
const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" };
|
||||
|
||||
describe("cms.ts 路径参数型 POST 的 query 透传", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(alovaInstance.Post).mockClear();
|
||||
});
|
||||
|
||||
it("publish: siteId 以 query 参数随 Post config 发出", () => {
|
||||
articleApi.publish(5, 2);
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/publish/2", undefined, {
|
||||
headers: JSON_HEADERS,
|
||||
params: { siteId: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("offline: 同款透传", () => {
|
||||
articleApi.offline(5, 3);
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/offline/3", undefined, {
|
||||
headers: JSON_HEADERS,
|
||||
params: { siteId: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("topmost: siteId 与 enable 一并透传", () => {
|
||||
articleApi.topmost(5, 3, true);
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/topmost/3", undefined, {
|
||||
headers: JSON_HEADERS,
|
||||
params: { siteId: 5, enable: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("save/delete: siteId 走 body,不带 params(守卫勿误伤)", () => {
|
||||
articleApi.delete({ id: 2, siteId: 5 });
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/delete", { id: 2, siteId: 5 }, {
|
||||
headers: JSON_HEADERS,
|
||||
params: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("assetUrl 站内资产路径补 serviceHost 前缀(线上主题截图破图回归)", () => {
|
||||
afterEach(() => {
|
||||
confState.conf.serviceHost = "";
|
||||
confState.conf.isMock = false;
|
||||
});
|
||||
|
||||
it("生产:/api 裸路径拼上 context-path(/yschcms)", () => {
|
||||
confState.conf.serviceHost = "/yschcms";
|
||||
expect(assetUrl("/api/theme/default/asset/screenshot.png")).toBe(
|
||||
"/yschcms/api/theme/default/asset/screenshot.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("dev/mock:serviceHost 为空串时原样返回", () => {
|
||||
expect(assetUrl("/api/theme/default/asset/screenshot.png")).toBe(
|
||||
"/api/theme/default/asset/screenshot.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("绝对地址(http/data/blob)不拼前缀", () => {
|
||||
confState.conf.serviceHost = "/yschcms";
|
||||
expect(assetUrl("https://cdn.example.com/x.png")).toBe("https://cdn.example.com/x.png");
|
||||
expect(assetUrl("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
|
||||
expect(assetUrl("blob:http://localhost/uuid")).toBe("blob:http://localhost/uuid");
|
||||
});
|
||||
|
||||
it("空值原样返回;isMock 时不拼 serviceHost", () => {
|
||||
expect(assetUrl("")).toBe("");
|
||||
confState.conf.serviceHost = "/yschcms";
|
||||
confState.conf.isMock = true;
|
||||
expect(assetUrl("/api/theme/default/asset/screenshot.png")).toBe(
|
||||
"/api/theme/default/asset/screenshot.png"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("themeApi W1 端点契约(T-W1-21)", () => {
|
||||
const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" };
|
||||
|
||||
it("upload 走 multipart Post /api/theme/upload", () => {
|
||||
vi.mocked(alovaInstance.Post).mockClear();
|
||||
const file = new File(["zip-bytes"], "pkg.zip", { type: "application/zip" });
|
||||
themeApi.upload(file);
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/theme/upload", expect.any(FormData));
|
||||
});
|
||||
|
||||
it("derive/updateMeta/remove/reload 的方法与路径", () => {
|
||||
vi.mocked(alovaInstance.Post).mockClear();
|
||||
vi.mocked(alovaInstance.Put).mockClear();
|
||||
vi.mocked(alovaInstance.Delete).mockClear();
|
||||
|
||||
themeApi.derive("default", "my-skin");
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/theme/derive", { source: "default", name: "my-skin" }, {
|
||||
headers: JSON_HEADERS,
|
||||
params: undefined,
|
||||
});
|
||||
|
||||
themeApi.updateMeta("my-skin", { title: "我的皮肤" });
|
||||
expect(alovaInstance.Put).toHaveBeenCalledWith("/api/theme/my-skin/meta", { title: "我的皮肤" }, {
|
||||
headers: JSON_HEADERS,
|
||||
});
|
||||
|
||||
themeApi.remove("my-skin");
|
||||
expect(alovaInstance.Delete).toHaveBeenCalledWith("/api/theme/my-skin");
|
||||
|
||||
themeApi.reload();
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/theme/reload", undefined, {
|
||||
headers: JSON_HEADERS,
|
||||
params: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("themePreviewUrl 参数语义(T-W4-17:W4 起 sitePath)", () => {
|
||||
it("query 携带 sitePath(嵌套路径 URL 编码)与 p_ 前缀参数", () => {
|
||||
const url = themePreviewUrl("sch-3/jdxy", "t-demo-red", { primary: "#b01a2e" });
|
||||
expect(url).toContain("sitePath=sch-3%2Fjdxy");
|
||||
expect(url).toContain("theme=t-demo-red");
|
||||
expect(url).toContain("p_primary=%23b01a2e");
|
||||
expect(url).not.toContain("siteCode=");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
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,
|
||||
PersonVo,
|
||||
SchoolCard,
|
||||
SchoolOption,
|
||||
SiteVo,
|
||||
ThemeVo,
|
||||
} from "@/types/cms";
|
||||
|
||||
/**
|
||||
* CMS 管理端 API(手写 class 风格,同 Notice front 约定)。
|
||||
* baseURL = conf.serviceHost(dev 为空串走 vite 代理;生产 = 源 + /yschcms 上下文)。
|
||||
*/
|
||||
|
||||
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 PERSON = "/api/person";
|
||||
const THEME = "/api/theme";
|
||||
|
||||
/** GET(query 参数) */
|
||||
function get<T>(url: string, params?: IObj): Method {
|
||||
return alovaInstance.Get<T>(url, { params });
|
||||
}
|
||||
|
||||
/** POST JSON(@RequestBody);params 为追加到 URL 的 query 参数(alova Post config 同 Get) */
|
||||
function postJSON<T>(url: string, data?: unknown, params?: IObj): Method {
|
||||
return alovaInstance.Post<T>(url, data, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 上传地址(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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端下发的站内资产路径补 serviceHost 前缀(如主题截图 /api/theme/{name}/asset/screenshot.png)。
|
||||
* 生产 context-path=/yschcms,裸 /api 绝对路径会打到网关根上 404(破图);
|
||||
* dev/mock 下 serviceHost 为空串,拼接后与原路径一致不受影响。
|
||||
*/
|
||||
export function assetUrl(url: string): string {
|
||||
if (!url || /^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
return `${conf.isMock ? "" : conf.serviceHost}${url}`;
|
||||
}
|
||||
|
||||
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 已随工作项 T 退役(参数值改存皮肤包级 paramValues),
|
||||
* 服务端忽略该字段——保留仅为与 @Deprecated 的后端 DTO 同形,新代码不要再传 */
|
||||
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, { siteId });
|
||||
}
|
||||
|
||||
offline(siteId: number, id: number) {
|
||||
return postJSON<void>(`${ARTICLE}/offline/${id}`, undefined, { siteId });
|
||||
}
|
||||
|
||||
topmost(siteId: number, id: number, enable: boolean) {
|
||||
return postJSON<void>(`${ARTICLE}/topmost/${id}`, undefined, { 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> & { unusedCount: 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 名师风采人物(docs/20 D-7) */
|
||||
class PersonApi {
|
||||
list(siteId: number) {
|
||||
return get<PersonVo[]>(`${PERSON}/list`, { siteId });
|
||||
}
|
||||
|
||||
save(data: Partial<PersonVo>) {
|
||||
return postJSON<PersonVo>(`${PERSON}/save`, data);
|
||||
}
|
||||
|
||||
delete(data: DeleteRequest) {
|
||||
return postJSON<void>(`${PERSON}/delete`, data);
|
||||
}
|
||||
}
|
||||
|
||||
class ThemeApi {
|
||||
/** 可用主题列表(含 params schema,建站向导/站点编辑共用,方案 §5.2) */
|
||||
list() {
|
||||
return get<ThemeVo[]>(`${THEME}/list`);
|
||||
}
|
||||
/** W1:上传合规 zip(multipart,超管;免重启立即注册)。
|
||||
* 任务四:meta.title/description 选填,落位前写入包内 theme.json(留空沿用包内声明) */
|
||||
upload(file: File, meta?: { title?: string; description?: string }) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (meta?.title) fd.append("title", meta.title);
|
||||
if (meta?.description !== undefined && meta.description !== null) {
|
||||
fd.append("description", meta.description);
|
||||
}
|
||||
return alovaInstance.Post<ThemeVo>(`${THEME}/upload`, fd);
|
||||
}
|
||||
|
||||
/** W1:从现有包派生新皮肤(复制包体+改名);任务四支持同步命名(title/description 选填) */
|
||||
derive(source: string, name: string, meta?: { title?: string; description?: string }) {
|
||||
return postJSON<ThemeVo>(`${THEME}/derive`, { source, name, ...meta });
|
||||
}
|
||||
|
||||
/** W1 + 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
|
||||
* 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认)。
|
||||
* 工作项 T:paramValues=包级参数微调值,省略=不改,{}=清空恢复 theme.json 默认值 */
|
||||
updateMeta(name: string, data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
params?: unknown[];
|
||||
paramValues?: Record<string, string>;
|
||||
}) {
|
||||
return alovaInstance.Put<ThemeVo>(`${THEME}/${name}/meta`, data, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
/** W1:删除外部包(被用站点拒绝;内置不可删) */
|
||||
remove(name: string) {
|
||||
return alovaInstance.Delete<{ ok: boolean }>(`${THEME}/${name}`);
|
||||
}
|
||||
|
||||
/** W1:热重载(运维直放目录后生效) */
|
||||
reload() {
|
||||
return postJSON<ThemeVo[]>(`${THEME}/reload`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀)。
|
||||
* W4:内容源站点参数 sitePath(嵌套路径语义,后端 siteSnapshotByPath 解析)。 */
|
||||
export function themePreviewUrl(sitePath: string, theme: string, themeParams: Record<string, string> = {}): string {
|
||||
const params = new URLSearchParams({ sitePath, 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 personApi = new PersonApi();
|
||||
export const linkApi = new LinkApi();
|
||||
export const themeApi = new ThemeApi();
|
||||
|
||||
export default {
|
||||
authApi,
|
||||
baseApi,
|
||||
schoolApi,
|
||||
siteApi,
|
||||
categoryApi,
|
||||
articleApi,
|
||||
attachmentApi,
|
||||
bannerApi,
|
||||
personApi,
|
||||
linkApi,
|
||||
themeApi,
|
||||
uploadUrl,
|
||||
portalBase,
|
||||
themePreviewUrl,
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseConf } from "@/types/Basics";
|
||||
|
||||
/**
|
||||
* 配置优先级(高 → 低):
|
||||
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
|
||||
* /<contextPath>/api/env-config?target=<部署目录名>(默认 admin,projman 配置管理维护)写入
|
||||
* ② import.meta.env.VITE_*:仅本地开发/演示(vite dev --mode dev 读 .env.dev、--mode mock 读 .env.mock);
|
||||
* build 产物不读任何 .env.*(.env.base/.env.prod 已删),VITE_* 恒为 undefined,本层自动跳过,
|
||||
* 产物不含任何环境值
|
||||
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
|
||||
* ssoHost 为空 → 登录跳转不可用——与后端未就绪状态一致,可接受。
|
||||
*/
|
||||
const env = import.meta.env;
|
||||
|
||||
// ③ 同域推导兜底:context 段取当前路径首段(/{contextPath}/admin/ → contextPath,即后端上下文),
|
||||
// 与服务端 context-path 对齐(本地 /cms、base/prod /yschcms);本地 dev/mock 在根路径运行(无段),
|
||||
// serviceHost 留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头)
|
||||
const seg = window.location.pathname.split("/").filter(Boolean);
|
||||
const origin = window.location.origin;
|
||||
|
||||
let conf: BaseConf = {
|
||||
baseHost: origin,
|
||||
ssoHost: "",
|
||||
serviceHost: seg.length >= 2 ? `${origin}/${seg[0]}` : "",
|
||||
isMock: false,
|
||||
isDebug: env.DEV,
|
||||
};
|
||||
|
||||
// 逐键覆盖:只覆盖来源里实际出现且非空的键,未出现的键沿用底层值
|
||||
const applyConf = (target: any, source: any) => {
|
||||
if (!source || typeof source !== "object") return;
|
||||
Object.keys(source).forEach(key => {
|
||||
if (source[key] !== undefined && source[key] !== null && source[key] !== "") {
|
||||
(target as any)[key] = source[key];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ② 本地开发/演示(build 产物中恒 undefined → 自动跳过)
|
||||
applyConf(conf, {
|
||||
baseHost: env.VITE_BASE_HOST,
|
||||
ssoHost: env.VITE_SSO_HOST,
|
||||
serviceHost: env.VITE_SERVICE_HOST,
|
||||
isMock: env.VITE_IS_MOCK === "true",
|
||||
});
|
||||
|
||||
// ① 线上运行时配置(最高优先,覆盖前面所有)
|
||||
applyConf(conf, (window as any).__APP_CONFIG__);
|
||||
|
||||
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
|
||||
if ("https:" === document.location.protocol) {
|
||||
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,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
|
||||
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
|
||||
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
|
||||
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
|
||||
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 701 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,231 @@
|
||||
/* 本文件由 scripts/gen-yh-theme.mjs 生成(color-mix 已构建期静态化),勿手改派生色值。
|
||||
定稿:07-seam-pure.html(浅色接缝条·纯享版);挂接:main.ts 置于 icons.css 之后。
|
||||
档位开关挂在 <html>:data-theme=blue|ocean|ink|violet|teal × side-deep|side-light × grad-on|grad-off × side-plain */
|
||||
|
||||
/* ============ 变量体系(:root 默认档 = blue + side-deep + grad-on + side-follow) ============ */
|
||||
:root{
|
||||
--brand:#577afb; --brand-2:#7b8ffd; --brand-dark:#4264e4;
|
||||
--brand-soft:#eef2ff; --brand-a:rgba(87,122,251,0.34); --glow:rgba(87,122,251,0.42);
|
||||
--topbar-bg:linear-gradient(100deg,var(--brand) 0%,var(--brand-2) 100%);
|
||||
--green:#20a162; --green-d:#188451;
|
||||
--red:#e54d42; --red-soft:#fef1f0;
|
||||
/* 第一波(P1)沿用现状 56/56,紧凑化 52/44 留 P2(方案 §8-D2) */
|
||||
--topbar-h:56px; --crumb-h:56px; --side-w:200px; --r:10px; --r-s:7px;
|
||||
--page-bg:#f3f5fa; --panel:#fff; --line:#e9edf5; --line-2:#f0f3f9;
|
||||
--text:#1e2635; --text-2:#5a6478; --text-3:#98a1b2;
|
||||
--sh:0 1px 2px rgba(16,24,40,.04),0 14px 30px -22px rgba(16,24,40,.4);
|
||||
--side-bg:linear-gradient(180deg,#5e7ffb 0%,#415bbb 100%);
|
||||
--side-text:#c7d2ea; --side-icon:#95a6cb;
|
||||
--side-line:rgba(255,255,255,.08);
|
||||
--side-hover:rgba(255,255,255,.09); --side-hover-text:#fff;
|
||||
--side-active-bg:rgba(255,255,255,.18); --side-active-text:#fff;
|
||||
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
|
||||
--strip-bg:linear-gradient(180deg,#e9eefe 0,#fff 100%);
|
||||
}
|
||||
|
||||
/* blue · brand #577afb */
|
||||
html[data-theme="blue"]{
|
||||
--brand:#577afb; --brand-2:#7b8ffd; --brand-dark:#4264e4;
|
||||
--brand-soft:#eef2ff; --brand-a:rgba(87,122,251,0.34); --glow:rgba(87,122,251,0.42);
|
||||
--strip-bg:linear-gradient(180deg,#e9eefe 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="blue"].side-deep{--side-bg:#425dbf}
|
||||
html[data-theme="blue"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#5e7ffb 0%,#415bbb 100%)}
|
||||
html[data-theme="blue"].side-light{--side-bg:#f3f6ff}
|
||||
html[data-theme="blue"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e9eefe 0%,#f0f3fa 100%)}
|
||||
html[data-theme="blue"].grad-off{--topbar-bg:#577afb}
|
||||
|
||||
/* ocean · brand #2f7ce0 */
|
||||
html[data-theme="ocean"]{
|
||||
--brand:#2f7ce0; --brand-2:#4a9bf5; --brand-dark:#2568c4;
|
||||
--brand-soft:#eaf3fe; --brand-a:rgba(47,124,224,0.34); --glow:rgba(47,124,224,0.42);
|
||||
--strip-bg:linear-gradient(180deg,#e4eefb 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="ocean"].side-deep{--side-bg:#255fab}
|
||||
html[data-theme="ocean"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#3781e1 0%,#255da8 100%)}
|
||||
html[data-theme="ocean"].side-light{--side-bg:#f0f6fd}
|
||||
html[data-theme="ocean"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e4eefb 0%,#eff3f9 100%)}
|
||||
html[data-theme="ocean"].grad-off{--topbar-bg:#2f7ce0}
|
||||
|
||||
/* ink · brand #3d4d63 */
|
||||
html[data-theme="ink"]{
|
||||
--brand:#3d4d63; --brand-2:#6d82a1; --brand-dark:#2e3c4f;
|
||||
--brand-soft:#eef1f6; --brand-a:rgba(61,77,99,0.34); --glow:rgba(61,77,99,0.5);
|
||||
--strip-bg:linear-gradient(180deg,#e6e8eb 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="ink"].side-deep{--side-bg:#303d51}
|
||||
html[data-theme="ink"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#455469 0%,#2f3c50 100%)}
|
||||
html[data-theme="ink"].side-light{--side-bg:#f1f3f4}
|
||||
html[data-theme="ink"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e6e8eb 0%,#eff2f5 100%)}
|
||||
html[data-theme="ink"].grad-off{--topbar-bg:#3d4d63}
|
||||
|
||||
/* violet · brand #6d5ae0 */
|
||||
html[data-theme="violet"]{
|
||||
--brand:#6d5ae0; --brand-2:#8f7cf2; --brand-dark:#5a48c9;
|
||||
--brand-soft:#f1effe; --brand-a:rgba(109,90,224,0.34); --glow:rgba(109,90,224,0.42);
|
||||
--strip-bg:linear-gradient(180deg,#eceafb 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="violet"].side-deep{--side-bg:#5246ab}
|
||||
html[data-theme="violet"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#7361e1 0%,#5045a8 100%)}
|
||||
html[data-theme="violet"].side-light{--side-bg:#f5f3fd}
|
||||
html[data-theme="violet"].side-light.grad-on{--side-bg:linear-gradient(180deg,#eceafb 0%,#f1f2f9 100%)}
|
||||
html[data-theme="violet"].grad-off{--topbar-bg:#6d5ae0}
|
||||
|
||||
/* teal · brand #0d9488 */
|
||||
html[data-theme="teal"]{
|
||||
--brand:#0d9488; --brand-2:#18b5a5; --brand-dark:#0b7c72;
|
||||
--brand-soft:#e8f7f5; --brand-a:rgba(13,148,136,0.36); --glow:rgba(13,148,136,0.42);
|
||||
--strip-bg:linear-gradient(180deg,#e0f1f0 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="teal"].side-deep{--side-bg:#0d706c}
|
||||
html[data-theme="teal"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#17988d 0%,#0d6e6a 100%)}
|
||||
html[data-theme="teal"].side-light{--side-bg:#eef8f7}
|
||||
html[data-theme="teal"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e0f1f0 0%,#eef4f7 100%)}
|
||||
html[data-theme="teal"].grad-off{--topbar-bg:#0d9488}
|
||||
|
||||
/* ---- 深浅档非底色 token(与品牌色联动的用 var 派生;浅色档 line/hover/active 用默认档实测值,
|
||||
主题间差异肉眼不可辨,保持单档以压复杂度——同定稿 side-light 组) ---- */
|
||||
html.side-light{
|
||||
--side-text:#4a5568; --side-icon:#98a2b3;
|
||||
--side-line:#ccd6f2;
|
||||
--side-hover:#e3e8f8; --side-hover-text:#1e2635;
|
||||
--side-active-bg:#e6ebfe; --side-active-text:var(--brand-dark);
|
||||
--side-bar:var(--brand); --side-scroll:#d3d9e3;
|
||||
}
|
||||
html.grad-off{--topbar-bg:var(--brand)}
|
||||
|
||||
/* ---- 侧栏与品牌色解耦(side-follow 关) ---- */
|
||||
html.side-plain.side-deep{
|
||||
--side-bg:#2c3444; --side-text:#c2cbd9; --side-icon:#909dae;
|
||||
--side-line:rgba(255,255,255,.08);
|
||||
--side-hover:rgba(255,255,255,.08); --side-hover-text:#fff;
|
||||
--side-active-bg:rgba(255,255,255,.16); --side-active-text:#fff;
|
||||
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
|
||||
}
|
||||
html.side-plain.side-light{
|
||||
--side-bg:#f7f8fa; --side-text:#4a5568; --side-icon:#98a2b3;
|
||||
--side-line:#e6eaf0; --side-hover:#eff1f5;
|
||||
--side-active-bg:#eceef3; --side-active-text:#2f3a4c;
|
||||
--side-bar:#8b95a8; --side-scroll:#d3d9e3;
|
||||
}
|
||||
html.side-plain.grad-on.side-deep{--side-bg:linear-gradient(180deg,#333c4d 0%,#272e3c 100%)}
|
||||
html.side-plain.grad-on.side-light{--side-bg:linear-gradient(180deg,#fafbfc 0%,#f2f4f7 100%)}
|
||||
html.side-plain{--strip-bg:linear-gradient(180deg,#f2f4f8 0,#fff 100%)}
|
||||
|
||||
/* ---- 顶栏:.header-container ---- */
|
||||
.layout-wrap .header-container{background:var(--topbar-bg);box-shadow:0 1px 3px rgba(16,24,40,.14);transition:background .28s}
|
||||
.layout-wrap .header-container h4{font-weight:600;letter-spacing:.4px}
|
||||
.layout-wrap .header-container .delimiter{background:rgba(255,255,255,.28)}
|
||||
/* 顶栏 logo 徽标(模板新增节点) */
|
||||
.logo-badge{width:30px;height:30px;flex:0 0 auto;border-radius:9px;background:#fff;display:grid;place-items:center;box-shadow:0 2px 7px rgba(0,0,0,.16);margin-right:10px}
|
||||
.logo-badge svg,.logo-badge img{width:22px;height:22px;display:block}
|
||||
/* 用户胶囊 */
|
||||
.layout-wrap .header-aside{padding:0 8px 0 5px;height:36px;border-radius:999px;transition:background .16s}
|
||||
.layout-wrap .header-aside:hover{background:rgba(255,255,255,.16)}
|
||||
|
||||
/* ---- 侧栏:.left-menu(皮肤覆盖 layout.css 浅色档;深浅由 --side-* 驱动) ---- */
|
||||
.layout-wrap .left-menu{background:var(--side-bg);border-right:1px solid var(--side-line);color:var(--side-text);transition:background .28s}
|
||||
.layout-wrap .left-menu::-webkit-scrollbar{width:8px}
|
||||
.layout-wrap .left-menu::-webkit-scrollbar-thumb{background:var(--side-scroll);border-radius:8px}
|
||||
.layout-wrap .left-menu ul{padding:8px 0 18px}
|
||||
.layout-wrap .left-menu li{margin:1px 8px;border-radius:var(--r-s);transition:background .15s,color .15s}
|
||||
.layout-wrap .left-menu li a{
|
||||
display:block;height:40px;line-height:40px;padding:0 12px;border-radius:var(--r-s);
|
||||
color:var(--side-text);font-size:13px;border-right:none;transition:background .15s,color .15s;position:relative;
|
||||
}
|
||||
.layout-wrap .left-menu li a:hover{background:var(--side-hover);color:var(--side-hover-text)}
|
||||
.layout-wrap .left-menu li a:hover .iconfont,.layout-wrap .left-menu li:hover .iconfont{color:var(--side-hover-text)}
|
||||
.layout-wrap .left-menu li.active{background:var(--side-active-bg);border-right:0}
|
||||
.layout-wrap .left-menu li.active a{color:var(--side-active-text);font-weight:600;background:transparent;border-right:none}
|
||||
.layout-wrap .left-menu li.active a .iconfont{color:var(--side-active-text)}
|
||||
.layout-wrap .left-menu li.active::before{
|
||||
content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);
|
||||
width:3px;height:20px;border-radius:0 3px 3px 0;background:var(--side-bar);
|
||||
}
|
||||
.layout-wrap .left-menu li.child-item{margin:0 8px}
|
||||
.layout-wrap .left-menu li.child-item a{height:36px;line-height:36px;font-size:12.5px}
|
||||
.layout-wrap .left-menu li.child-item.active::before{height:16px}
|
||||
/* 菜单图标 mask 类(icons.css 用 background-color 上色,这里给品牌感近色) */
|
||||
.layout-wrap .left-menu .iconfont{margin-right:8px}
|
||||
.layout-wrap .left-menu .arrow-up,.layout-wrap .left-menu .arrow-down{border-color:var(--side-icon)}
|
||||
|
||||
/* ---- 面包屑:.path-nav → 浅色接缝条(方案 §7.3 融合站点身份带) ---- */
|
||||
.layout-wrap .path-nav{
|
||||
height:var(--crumb-h);background:var(--strip-bg);border-bottom:1px solid var(--line);
|
||||
color:var(--text-3);box-sizing:border-box;
|
||||
}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb-item-link{color:var(--text-2)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb>span:last-child .ivu-breadcrumb-item-link,.layout-wrap .path-nav .ivu-breadcrumb>span:last-child{color:var(--text);font-weight:600}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)}
|
||||
.layout-wrap .path-nav .crumb-home{color:var(--brand)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0}
|
||||
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变。
|
||||
v3:站点身份已由页头「学校名.站点名」承担,原先 band-sub 让位身份橙的覆盖已删,
|
||||
否则子站态上面包屑顶部常驻一条橙色横杠,与页头身份重复,且被误读成边框。 */
|
||||
.layout-wrap .path-nav::before{
|
||||
content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1;
|
||||
}
|
||||
.layout-wrap .path-nav::after{display:none}
|
||||
|
||||
/* ---- 内容区:灰底 + 白卡 ---- */
|
||||
.base-wrap-main{background:var(--page-bg)}
|
||||
.base-wrap-main .main-container{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
|
||||
box-shadow:var(--sh);margin:0 18px 24px;padding:16px 20px 24px;box-sizing:border-box;
|
||||
}
|
||||
/* 栏目工作台左树卡 */
|
||||
.base-wrap-main .tree-container{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--sh)}
|
||||
|
||||
/* ---- 搜索区分区化(卡片内 filterbar 语义) ---- */
|
||||
.base-wrap-main .search-wrap{
|
||||
background:#fbfcfe;border:1px solid var(--line-2);border-radius:var(--r-s);
|
||||
padding:10px 12px;height:auto;min-height:52px;box-sizing:border-box;margin-bottom:12px;
|
||||
}
|
||||
|
||||
/* ---- 表格/操作列皮肤(三蓝归一 #5979EA/#2d8cf0 → var(--brand)) ---- */
|
||||
.ivu-table-header th,.ivu-table-fixed-header th{background:#f7f9fc !important;color:var(--text-2)}
|
||||
.ivu-table td{border-bottom:1px solid var(--line-2)}
|
||||
.ivu-table-body tr:hover td,.ivu-table tbody tr:hover td{background:var(--brand-soft) !important}
|
||||
.base-wrap-main .ivu-table .operating-link{color:var(--brand)}
|
||||
.base-wrap-main .ivu-table .operating-link.disabled{color:#aaa}
|
||||
.ivu-table-cell .operating-btn:hover{background:var(--brand);color:#fff}
|
||||
|
||||
/* ---- view-ui-plus 品牌态 ---- */
|
||||
.ivu-btn-primary{background-color:var(--brand);border-color:var(--brand)}
|
||||
.ivu-btn-primary:hover,.ivu-btn-primary.active{background-color:var(--brand-dark);border-color:var(--brand-dark)}
|
||||
.ivu-btn-success{background-color:var(--green);border-color:var(--green)}
|
||||
.ivu-btn-success:hover,.ivu-btn-success.active{background-color:var(--green-d);border-color:var(--green-d)}
|
||||
.ivu-btn-error{background-color:var(--red);border-color:var(--red)}
|
||||
.ivu-btn-error:hover,.ivu-btn-error.active{background-color:#c93f36;border-color:#c93f36}
|
||||
.ivu-btn-text{color:var(--brand)}
|
||||
.ivu-btn-text:hover{color:var(--brand-dark)}
|
||||
.ivu-tabs-ink-bar{background-color:var(--brand)}
|
||||
.ivu-tabs-tab:hover,.ivu-tabs-tab-active{color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item-active{background:var(--brand);border-color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item-active a,.base-wrap-main .ivu-page-item-active a:hover{color:#fff !important}
|
||||
.base-wrap-main .ivu-page-item:hover{border-color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item:hover a,.base-wrap-main .ivu-page-item:hover i{color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-prev:hover i,.base-wrap-main .ivu-page-next:hover i{border-color:var(--brand);color:var(--brand)}
|
||||
.ivu-select-item-selected{color:var(--brand)}
|
||||
.ivu-select-item-selected:hover{background:var(--brand-soft)}
|
||||
.ivu-checkbox-checked .ivu-checkbox-inner{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-radio-checked .ivu-radio-inner{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-checkbox-wrapper:hover .ivu-checkbox-inner,.ivu-radio-wrapper:hover .ivu-radio-inner{border-color:var(--brand)}
|
||||
.ivu-input:focus,.ivu-input-focused,.ivu-select-visible .ivu-select-selection{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand-a)}
|
||||
.ivu-input:hover,.ivu-select-selection:hover{border-color:#c6d0e2}
|
||||
.ivu-switch-checked{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-tree-title:hover{background-color:var(--brand-soft)}
|
||||
.ivu-tree-title-selected,a.ivu-tree-title-selected:hover{background-color:var(--brand-soft);color:var(--brand)}
|
||||
.ivu-spin-dot{background-color:var(--brand)}
|
||||
.ivu-modal-header-inner{font-weight:600;color:var(--text)}
|
||||
a{color:var(--brand)}
|
||||
a:hover{color:var(--brand-dark)}
|
||||
|
||||
/* ---- 滚动条(定稿全局) ---- */
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-thumb{background:#c9d0dd;border-radius:8px}
|
||||
::-webkit-scrollbar-thumb:hover{background:#aeb8c9}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
|
||||
/* ---- 工作台/主题管理全屏页深色顶栏吃主题变量(方案 §7.3) ---- */
|
||||
.wb-header{background:var(--topbar-bg) !important}
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Modal v-model="visible" title="批量添加栏目" :width="520" :mask-closable="false">
|
||||
<div class="batch-body">
|
||||
<Form :label-width="90">
|
||||
<Form-Item label="上级栏目:">
|
||||
<span class="batch-val">{{ parentName || '根栏目' }}</span>
|
||||
</Form-Item>
|
||||
<Form-Item label="栏目类型:">
|
||||
<RadioGroup v-model="typeValue">
|
||||
<!-- 外链必须逐个填地址,不提供批量 -->
|
||||
<Radio :label="1">文章列表</Radio>
|
||||
<Radio :label="2">单页</Radio>
|
||||
</RadioGroup>
|
||||
</Form-Item>
|
||||
<Form-Item label="栏目名称:">
|
||||
<Input v-model="rawNames" type="textarea" :rows="8" placeholder="一行一个栏目名称,保存时按行顺序写入排序号" />
|
||||
</Form-Item>
|
||||
</Form>
|
||||
<p class="batch-tip">将按行逐个创建栏目;某行失败不影响其余行,结束后统一提示结果。</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" @click="submit">确认</Button>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/* UserBase BatchAddGroup 同款交互:textarea 一行一个名称批量建组;
|
||||
CMS 无后端批量接口,前端循环调 categoryApi.save(用户约定方案),按行序写 sortNo=1..n。 */
|
||||
import { ref, watch } from "vue";
|
||||
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<{
|
||||
/** 上级栏目 id(0=根;来自当前选中节点,虚拟根 id=0 天然合法) */
|
||||
defaultPid?: number | null
|
||||
/** 上级栏目名称(展示用) */
|
||||
parentName?: string
|
||||
defaultType?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ success: [lastSavedId?: number] }>()
|
||||
|
||||
const store = useCmsStore();
|
||||
const loading = ref(false)
|
||||
const rawNames = ref('')
|
||||
const typeValue = ref<number>(1)
|
||||
|
||||
function normalizeNames() {
|
||||
return rawNames.value
|
||||
.split(/\r?\n/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!store.siteId) {
|
||||
Message.warning('请先选择站点');
|
||||
return;
|
||||
}
|
||||
const names = normalizeNames();
|
||||
if (!names.length) {
|
||||
Message.warning('请输入栏目名称');
|
||||
return;
|
||||
}
|
||||
const parentId = props.defaultPid ?? 0;
|
||||
loading.value = true;
|
||||
const failed: string[] = [];
|
||||
let lastSavedId: number | undefined;
|
||||
try {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name = names[i];
|
||||
try {
|
||||
const saved = await categoryApi
|
||||
.save({
|
||||
siteId: store.siteId,
|
||||
parentId,
|
||||
name,
|
||||
type: typeValue.value,
|
||||
sortNo: i + 1,
|
||||
status: 1,
|
||||
})
|
||||
.send();
|
||||
lastSavedId = saved?.id ?? lastSavedId;
|
||||
} catch (e: any) {
|
||||
failed.push(name);
|
||||
console.warn(`栏目「${name}」创建失败`, e?.message);
|
||||
}
|
||||
}
|
||||
if (failed.length) {
|
||||
Message.warning(`创建完成:成功 ${names.length - failed.length} 条,失败 ${failed.length} 条(${failed.join('、')})`);
|
||||
} else {
|
||||
Message.success(`成功创建 ${names.length} 个栏目!`);
|
||||
}
|
||||
emit('success', lastSavedId);
|
||||
visible.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) {
|
||||
rawNames.value = '';
|
||||
typeValue.value = props.defaultType === 2 ? 2 : 1;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.batch-body {
|
||||
.batch-val {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.batch-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 区块类型缩略示意图(docs/20 F2 / R-40):添加抽屉类型卡 + 属性面板形态 RadioCard 共用。
|
||||
* 纯内联 SVG 线框示意(本地资产,无外部 CDN——皮肤包契约红线仅约束门户,管理端同样不引);
|
||||
* 每种 type/variant 一幅极简骨架图,新组件类型 = 在 THUMBS 补一支绘制函数(注册表驱动配套)。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 区块类型(BLOCK_TYPES.type) */
|
||||
type: string;
|
||||
/** article_list 展示形态(style),其余类型忽略 */
|
||||
variant?: string;
|
||||
}>(),
|
||||
{ variant: "" },
|
||||
);
|
||||
|
||||
/** 图底色板(示意线框用,与皮肤无关) */
|
||||
const C = {
|
||||
band: "#dce6f5",
|
||||
block: "#e8eef8",
|
||||
strong: "#9db8e0",
|
||||
text: "#c3cfe2",
|
||||
accent: "#5b8dd9",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="block-type-thumb" viewBox="0 0 120 64" preserveAspectRatio="xMidYMid meet" role="img" aria-hidden="true">
|
||||
<!-- article_list 五形态 -->
|
||||
<template v-if="type === 'article_list'">
|
||||
<!-- card:头条图 + 摘要 + 行列表 -->
|
||||
<template v-if="variant === 'card' || !variant">
|
||||
<rect x="6" y="6" width="34" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="45" y="9" width="44" height="5" rx="1.5" :fill="C.strong" />
|
||||
<rect x="45" y="18" width="66" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="45" y="24" width="56" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="34" width="108" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="50" width="104" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="58" width="92" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- news_grid:色带底 + 双栏行 -->
|
||||
<template v-else-if="variant === 'news_grid'">
|
||||
<rect x="0" y="0" width="120" height="64" :fill="C.band" />
|
||||
<rect x="8" y="8" width="104" height="48" rx="2" fill="#ffffff" />
|
||||
<rect x="14" y="14" width="40" height="4" rx="2" :fill="C.accent" />
|
||||
<rect x="14" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="14" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="14" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="64" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="64" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="64" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- media_focus:来源徽标 + 行 -->
|
||||
<template v-else-if="variant === 'media_focus'">
|
||||
<rect x="6" y="8" width="24" height="8" rx="2" :fill="C.accent" />
|
||||
<rect x="36" y="11" width="60" height="3" rx="1.5" :fill="C.strong" />
|
||||
<rect x="6" y="24" width="20" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="32" y="27" width="70" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="40" width="18" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="30" y="43" width="64" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="54" width="34" height="4" rx="1.5" :fill="C.band" />
|
||||
</template>
|
||||
<!-- compact:纯文字行 -->
|
||||
<template v-else-if="variant === 'compact'">
|
||||
<rect x="8" y="10" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="10" width="84" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="21" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="21" width="76" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="32" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="32" width="88" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="43" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="43" width="72" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="54" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="54" width="80" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- image_wall:图片格阵 -->
|
||||
<template v-else-if="variant === 'image_wall'">
|
||||
<rect x="6" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="35" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="64" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="93" y="8" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="6" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="35" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="64" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="93" y="34" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ticker:横条 + 滚动标题 -->
|
||||
<template v-else-if="type === 'ticker'">
|
||||
<rect x="0" y="20" width="120" height="24" :fill="C.accent" />
|
||||
<rect x="6" y="28" width="26" height="8" rx="2" fill="#ffffff" opacity="0.9" />
|
||||
<rect x="38" y="30" width="34" height="4" rx="2" fill="#ffffff" opacity="0.75" />
|
||||
<rect x="78" y="30" width="30" height="4" rx="2" fill="#ffffff" opacity="0.6" />
|
||||
</template>
|
||||
|
||||
<!-- gallery:居中标题 + 图墙 -->
|
||||
<template v-else-if="type === 'gallery'">
|
||||
<rect x="46" y="4" width="28" height="5" rx="2" :fill="C.strong" />
|
||||
<rect x="38" y="11" width="44" height="2.5" rx="1.25" :fill="C.text" />
|
||||
<rect x="8" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="37" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="95" y="20" width="19" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="8" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="37" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="95" y="44" width="19" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
</template>
|
||||
|
||||
<!-- links:胶囊按钮行 -->
|
||||
<template v-else-if="type === 'links'">
|
||||
<rect x="46" y="6" width="28" height="5" rx="2" :fill="C.strong" />
|
||||
<rect x="10" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="38" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="94" y="22" width="18" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="24" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="52" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="80" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
</template>
|
||||
|
||||
<!-- quick_links:图标宫格 -->
|
||||
<template v-else-if="type === 'quick_links'">
|
||||
<circle cx="26" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="18" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="60" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="52" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="94" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="86" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="26" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="18" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="60" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="52" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="94" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="86" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
|
||||
<!-- notice_bar:通栏提示条 -->
|
||||
<template v-else-if="type === 'notice_bar'">
|
||||
<rect x="0" y="22" width="120" height="20" :fill="C.band" />
|
||||
<rect x="0" y="22" width="120" height="1" :fill="C.strong" />
|
||||
<rect x="0" y="41" width="120" height="1" :fill="C.strong" />
|
||||
<rect x="8" y="29" width="20" height="6" rx="2" :fill="C.accent" />
|
||||
<rect x="34" y="31" width="70" height="3" rx="1.5" :fill="C.strong" />
|
||||
</template>
|
||||
|
||||
<!-- D 系新组件(docs/20 D-1~D-9):D 步上线时同步补绘 -->
|
||||
<!-- D-1 stats_band:大数字横排 -->
|
||||
<template v-else-if="type === 'stats_band'">
|
||||
<rect x="0" y="10" width="120" height="44" :fill="C.band" />
|
||||
<text x="24" y="34" font-size="13" font-weight="700" :fill="C.accent">3.2万</text>
|
||||
<rect x="20" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
|
||||
<text x="60" y="34" font-size="13" font-weight="700" :fill="C.accent">2800</text>
|
||||
<rect x="56" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
|
||||
<text x="94" y="34" font-size="13" font-weight="700" :fill="C.accent">12</text>
|
||||
<rect x="90" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- D-2 icon_nav:六边形图标阵 -->
|
||||
<template v-else-if="type === 'icon_nav'">
|
||||
<polygon points="26,6 38,13 38,27 26,34 14,27 14,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="16" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="60,6 72,13 72,27 60,34 48,27 48,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="50" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="94,6 106,13 106,27 94,34 82,27 82,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="84" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="26,46 38,53 38,67 26,74 14,67 14,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="16" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="60,46 72,53 72,67 60,74 48,67 48,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="50" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="94,46 106,53 106,67 94,74 82,67 82,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="84" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- D-3 featured_topics:专题大卡 -->
|
||||
<template v-else-if="type === 'featured_topics'">
|
||||
<rect x="6" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="12" y="40" width="30" height="4" rx="2" fill="#ffffff" />
|
||||
<rect x="12" y="48" width="18" height="4" rx="2" :fill="C.accent" />
|
||||
<rect x="62" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="68" y="40" width="30" height="4" rx="2" fill="#ffffff" />
|
||||
<rect x="68" y="48" width="18" height="4" rx="2" :fill="C.accent" />
|
||||
</template>
|
||||
<!-- D-5 video_tour:视频封面 + 播放键 -->
|
||||
<template v-else-if="type === 'video_tour'">
|
||||
<rect x="6" y="8" width="108" height="48" rx="3" :fill="C.band" />
|
||||
<circle cx="60" cy="32" r="12" fill="#ffffff" opacity="0.9" />
|
||||
<polygon points="56,25 70,32 56,39" :fill="C.accent" />
|
||||
</template>
|
||||
<!-- D-6 events:大日期块 + 活动列表 -->
|
||||
<template v-else-if="type === 'events'">
|
||||
<rect x="6" y="8" width="26" height="26" rx="2" :fill="C.accent" />
|
||||
<rect x="10" y="13" width="18" height="5" rx="1.5" fill="#ffffff" opacity="0.9" />
|
||||
<rect x="38" y="12" width="50" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="38" y="21" width="66" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="40" width="26" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="38" y="44" width="50" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="38" y="53" width="66" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- D-7 people:圆头像卡 -->
|
||||
<template v-else-if="type === 'people'">
|
||||
<circle cx="24" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="12" y="43" width="24" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="14" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="60" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="48" y="43" width="24" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="50" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="96" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="84" y="43" width="24" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="86" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- D-8 campus_wall:瀑布流(错落图) -->
|
||||
<template v-else-if="type === 'campus_wall'">
|
||||
<rect x="8" y="6" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="37" y="6" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="6" width="24" height="32" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="95" y="6" width="19" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="8" y="40" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="37" y="30" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="44" width="24" height="14" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="95" y="34" width="19" height="24" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
</template>
|
||||
<!-- D-9 badges:徽章横排 -->
|
||||
<template v-else-if="type === 'badges'">
|
||||
<circle cx="26" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="18" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="60" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="52" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="94" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="86" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
|
||||
<!-- 兜底:区块头 + 行 -->
|
||||
<template v-else>
|
||||
<rect x="8" y="8" width="32" height="5" rx="2" :fill="C.strong" />
|
||||
<rect x="8" y="22" width="104" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="32" width="96" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="52" width="88" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.block-type-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fbfcfe;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -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,264 @@
|
||||
<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'
|
||||
import { initialSelectId } from '@/utils/categoryTree'
|
||||
|
||||
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;
|
||||
|
||||
// W2 虚拟根(D5):默认下钻到虚拟根下首个真实栏目,绝不落到根 id=0;
|
||||
// 缓存恢复优先于下钻(B5:刷新后仍选中缓存的 REAL 节点)
|
||||
const cached = findNodeById(list, getCachedSelectedNodeId());
|
||||
if (cached) return cached.id;
|
||||
|
||||
return initialSelectId(list) ?? 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,171 @@
|
||||
<script setup lang="ts">
|
||||
import { assetUrl } from "@/api/module/cms";
|
||||
/**
|
||||
* F5(docs/20)页脚结构示意图:页脚 Tab 内的固定示意图,标注各字段在门户页脚中的视觉位置——
|
||||
* 品牌区(站名+联系方式+多校区地址行)/ 二维码区(微信+微博,4:3)/ 社媒图标行 / 版权备案行。
|
||||
* 已填字段实时回显内容(填完即知页脚长什么样);空字段以灰字占位标注。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
contact?: string;
|
||||
footerAddresses?: string;
|
||||
qrWechat?: string;
|
||||
qrWeibo?: string;
|
||||
socials?: { label: string; value?: string }[];
|
||||
copyright?: string;
|
||||
beian?: string;
|
||||
}>();
|
||||
|
||||
const addressLines = (props.footerAddresses || "")
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="footer-preview">
|
||||
<div class="fp-caption">页脚结构示意(下图与字段一一对应,已填内容实时回显)</div>
|
||||
<div class="fp-frame" role="img" aria-label="页脚结构示意图">
|
||||
<div class="fp-brand">
|
||||
<div class="fp-logo">
|
||||
<span class="fp-logo-mark">LOGO</span>
|
||||
<span class="fp-site">XX学校</span>
|
||||
</div>
|
||||
<p class="fp-line" :class="{ filled: contact }">
|
||||
{{ contact || "① 联系方式(页脚品牌区)" }}
|
||||
</p>
|
||||
<p v-for="(l, i) in addressLines" :key="i" class="fp-line small filled">{{ l }}</p>
|
||||
<p v-if="!addressLines.length" class="fp-line small">② 多校区地址(每行一个校区)</p>
|
||||
</div>
|
||||
<div class="fp-qr">
|
||||
<div class="fp-qr-box">
|
||||
<img v-if="qrWechat" :src="assetUrl(qrWechat)" alt="微信二维码预览" />
|
||||
<span v-else>微信二维码<br />③ 4:3</span>
|
||||
</div>
|
||||
<div class="fp-qr-box">
|
||||
<img v-if="qrWeibo" :src="assetUrl(qrWeibo)" alt="微博二维码预览" />
|
||||
<span v-else>微博二维码<br />③ 4:3</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fp-social">
|
||||
<span class="fp-social-label">④ 社媒图标行:</span>
|
||||
<span v-for="s in socials" :key="s.label" class="fp-social-item" :class="{ on: s.value }">
|
||||
{{ s.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fp-bottom">
|
||||
<span :class="{ filled: copyright }">{{ copyright || "⑤ 版权文字" }}</span>
|
||||
<span :class="{ filled: beian }" class="fp-beian">{{ beian || "⑥ 备案号" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.footer-preview {
|
||||
margin-top: 6px;
|
||||
.fp-caption {
|
||||
color: #8a93a6;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fp-frame {
|
||||
border: 1px solid #e9edf5;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #1f3a5f 0%, #16294a 100%);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
padding: 18px 22px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
.fp-brand {
|
||||
.fp-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
.fp-logo-mark {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.35);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.fp-site {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.fp-line {
|
||||
margin: 2px 0;
|
||||
&.small {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
&.filled {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
.fp-qr {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
.fp-qr-box {
|
||||
width: 84px;
|
||||
height: 63px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.4);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
.fp-social {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
.fp-social-label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.fp-social-item {
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1px 10px;
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
&.on {
|
||||
opacity: 1;
|
||||
border-color: rgba(255, 255, 255, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
.fp-bottom {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
.filled {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* D-2(docs/20)内置图标选择器:图标网格预览 + 点选即绑定图标标识符(§3.3 定案 2 综合方案 A+C)。
|
||||
* 数据源 icons.ts(与门户骨架 fragment 同 key);再次点击已选图标 = 清除(回 null = 首字徽章兜底)。
|
||||
*/
|
||||
import { ICON_DEFS } from "@/views/cms/homeLayout/icons";
|
||||
|
||||
const model = defineModel<string | null>("modelValue", { default: null });
|
||||
|
||||
function pick(key: string) {
|
||||
model.value = model.value === key ? null : key;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="icon-grid">
|
||||
<button
|
||||
v-for="icon in ICON_DEFS"
|
||||
:key="icon.key"
|
||||
type="button"
|
||||
class="icon-cell"
|
||||
:class="{ active: model === icon.key }"
|
||||
:title="`${icon.label}(${icon.key})`"
|
||||
@click="pick(icon.key)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path v-for="(d, i) in icon.paths" :key="i" :d="d" />
|
||||
</svg>
|
||||
<span class="icon-name">{{ icon.label }}</span>
|
||||
</button>
|
||||
<p class="icon-tip">未选图标时入口渲染「名称首字徽章」;再次点击已选图标可清除</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
.icon-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
border: 1px solid #e2e8f2;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 7px 2px 5px;
|
||||
cursor: pointer;
|
||||
color: #515a6e;
|
||||
transition: border-color 0.2s, color 0.2s, box-shadow 0.2s;
|
||||
&:hover {
|
||||
border-color: #5cadff;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
&.active {
|
||||
border-color: #2d8cf0;
|
||||
color: #2d8cf0;
|
||||
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.12);
|
||||
}
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.icon-name {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
.icon-tip {
|
||||
grid-column: 1 / -1;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { assetUrl, uploadUrl } from "@/api/module/cms";
|
||||
|
||||
/**
|
||||
* 图片类配置字段控件(docs/16 D7):上传 + 手动粘贴路径双通道。
|
||||
* v-model 绑定资产路径(/ 开头相对路径或 http(s) URL);上传成功自动回填(服务端返回相对 url)。
|
||||
* 缩略预览 + 清空;格式校验由外层 FormItem 规则(ASSET_RE)承担,控件只负责取值。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
/** 上传归属站点(附件场景引用;缺省不携带) */
|
||||
siteId?: number;
|
||||
/** 附件场景(空=不带 scene;svc 场景已迁办事大厅,CMS 内为 article/banner 等) */
|
||||
scene?: string;
|
||||
placeholder?: string;
|
||||
/** F5(docs/20):预览/占位框比例("1:1" favicon / "4:3" 二维码;缺省 8:5 通用横图),
|
||||
* 无值时按比例显示虚线占位框,帮助确认最终在门户中的尺寸感 */
|
||||
aspect?: "1:1" | "4:3";
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
|
||||
|
||||
const manualOpen = ref(false);
|
||||
const manualPath = ref("");
|
||||
const uploading = ref(false);
|
||||
|
||||
const previewSrc = computed(() => (props.modelValue ? assetUrl(props.modelValue) : ""));
|
||||
|
||||
const action = computed(() => {
|
||||
const base = uploadUrl();
|
||||
const params: string[] = [];
|
||||
if (props.siteId) params.push(`siteId=${props.siteId}`);
|
||||
if (props.scene) params.push(`scene=${props.scene}`);
|
||||
return params.length ? `${base}?${params.join("&")}` : base;
|
||||
});
|
||||
|
||||
function onUploaded(res: any) {
|
||||
uploading.value = false;
|
||||
if (res && res.code === 0 && res.data && res.data.url) {
|
||||
emit("update:modelValue", res.data.url);
|
||||
manualOpen.value = false;
|
||||
} else {
|
||||
Message.error(res?.msg || "图片上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onUploadError() {
|
||||
uploading.value = false;
|
||||
Message.error("图片上传失败");
|
||||
}
|
||||
|
||||
function confirmManual() {
|
||||
const v = manualPath.value.trim();
|
||||
if (!v) {
|
||||
Message.warning("请先粘贴图片路径");
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", v);
|
||||
manualPath.value = "";
|
||||
manualOpen.value = false;
|
||||
}
|
||||
|
||||
function clearValue() {
|
||||
emit("update:modelValue", "");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="image-asset-field">
|
||||
<div class="iaf-row">
|
||||
<Upload
|
||||
v-if="!modelValue"
|
||||
:action="action"
|
||||
:show-upload-list="false"
|
||||
:on-success="onUploaded"
|
||||
:on-error="onUploadError"
|
||||
accept="image/*"
|
||||
>
|
||||
<Button size="small" icon="ios-cloud-upload-outline" :loading="uploading">上传图片</Button>
|
||||
</Upload>
|
||||
<span v-else class="iaf-thumb" :class="aspect ? 'iaf-thumb-' + aspect.replace(':', 'x') : ''">
|
||||
<img :src="previewSrc" alt="" />
|
||||
<span class="iaf-thumb-del" title="清除" @click="clearValue">
|
||||
<Icon type="ios-close" />
|
||||
</span>
|
||||
</span>
|
||||
<!-- F5:无值时按字段比例显示虚线占位框(尺寸感示意) -->
|
||||
<span v-if="!modelValue && aspect" class="iaf-holder" :class="'iaf-holder-' + aspect!.replace(':', 'x')">
|
||||
{{ aspect }} 位
|
||||
</span>
|
||||
<Button size="small" type="text" class="iaf-toggle" @click="manualOpen = !manualOpen">
|
||||
{{ manualOpen ? "收起" : "手动粘贴路径" }}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
v-if="manualOpen"
|
||||
v-model="manualPath"
|
||||
size="small"
|
||||
class="iaf-manual"
|
||||
:placeholder="placeholder || '粘贴已上传图片的路径,如 /yschcms/upload/…/logo.png'"
|
||||
>
|
||||
<Button slot="append" size="small" type="primary" @click="confirmManual">填入</Button>
|
||||
</Input>
|
||||
<p v-else-if="modelValue" class="iaf-path">{{ modelValue }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.image-asset-field {
|
||||
.iaf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.iaf-toggle {
|
||||
color: #999;
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
.iaf-thumb {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
img {
|
||||
width: 64px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dcdee2;
|
||||
background: #f8f8f9;
|
||||
}
|
||||
&.iaf-thumb-1x1 img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
&.iaf-thumb-4x3 img {
|
||||
width: 88px;
|
||||
height: 66px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.iaf-thumb-del {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #ed4014;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
&:hover .iaf-thumb-del {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.iaf-holder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #cfd8e6;
|
||||
border-radius: 4px;
|
||||
color: #b4bfce;
|
||||
font-size: 11px;
|
||||
background: #fafbfd;
|
||||
&.iaf-holder-1x1 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
&.iaf-holder-4x3 {
|
||||
width: 88px;
|
||||
height: 66px;
|
||||
}
|
||||
}
|
||||
.iaf-manual {
|
||||
max-width: 420px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.iaf-path {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</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,145 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* RichTextEditor 组件行为(审计批次 6:ArticleEdit 复用后,正文上传契约成为两个页面共用底座,
|
||||
* 需要独立护栏)。覆盖:
|
||||
* ① 上传 FormData 参数:siteId / scene / articleId 按传入拼装,未传的键不出现;
|
||||
* ② 上传成功 → emit uploaded(附件 id) 且 insertFn 收到 (url, fileName, url);
|
||||
* ③ 上传失败(code≠0)→ 不 emit uploaded;
|
||||
* ④ 工具栏配置仍排除视频组与全屏(Notice 趟坑口径)。
|
||||
*
|
||||
* 无 @vue/test-utils 依赖:沿用项目既有 createApp 手写挂载 + vi.mock 桩范式。
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h } from "vue";
|
||||
|
||||
const confState = vi.hoisted(() => ({
|
||||
conf: { baseHost: "", ssoHost: "", serviceHost: "", isMock: false, isDebug: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/assets/config/host.config", () => ({ default: confState.conf }));
|
||||
|
||||
/** wangEditor 桩:把传进来的 defaultConfig 挂到全局,测试借此直接驱动 customUpload */
|
||||
vi.mock("@wangeditor/editor-for-vue", () => ({
|
||||
Toolbar: defineComponent({
|
||||
props: ["editor", "defaultConfig", "mode"],
|
||||
setup(props) {
|
||||
(globalThis as any).__toolbarConfig = props.defaultConfig;
|
||||
return () => h("div", { class: "toolbar-stub" });
|
||||
},
|
||||
}),
|
||||
Editor: defineComponent({
|
||||
props: ["modelValue", "defaultConfig", "mode"],
|
||||
emits: ["onCreated", "update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
(globalThis as any).__editorConfig = props.defaultConfig;
|
||||
(globalThis as any).__editorEmitUpdate = (v: string) => emit("update:modelValue", v);
|
||||
(globalThis as any).__editorCreated = emit;
|
||||
return () => h("div", { class: "editor-stub" });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import RichTextEditor from "@/components/RichTextEditor.vue";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
function mountEditor(props: Record<string, unknown>, onUploaded?: (id: number) => void) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp({
|
||||
render: () => h(RichTextEditor, { modelValue: "", ...props, onUploaded }),
|
||||
});
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/** 直接调用组件内部构造的 customUpload(等同 wangEditor 选中图片后的回调) */
|
||||
async function runUpload(file: File): Promise<{ inserted: string[] }> {
|
||||
const inserted: string[] = [];
|
||||
const cfg = (globalThis as any).__editorConfig;
|
||||
await cfg.MENU_CONF.uploadImage.customUpload(file, (url: string) => inserted.push(url));
|
||||
return { inserted };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
vi.unstubAllGlobals();
|
||||
delete (globalThis as any).__editorConfig;
|
||||
delete (globalThis as any).__toolbarConfig;
|
||||
});
|
||||
|
||||
describe("RichTextEditor - 正文上传与事件契约", () => {
|
||||
it("① 上传 FormData 带 siteId/scene/articleId(scene 透传契约)", async () => {
|
||||
const seen: FormData[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init: any) => {
|
||||
seen.push(init.body as FormData);
|
||||
return { json: async () => ({ code: 0, data: { id: 77, url: "/cms/upload/a.png", fileName: "a.png" } }) };
|
||||
})
|
||||
);
|
||||
mountEditor({ siteId: 5, scene: "svc_item_asset" }, () => {});
|
||||
await runUpload(new File(["x"], "a.png", { type: "image/png" }));
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].get("file")).toBeInstanceOf(File);
|
||||
expect(seen[0].get("siteId")).toBe("5");
|
||||
expect(seen[0].get("scene")).toBe("svc_item_asset");
|
||||
expect(seen[0].get("articleId")).toBeNull();
|
||||
});
|
||||
|
||||
it("①b 文章页口径:传 articleId 时随之提交(编辑已有文章)", async () => {
|
||||
const seen: FormData[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init: any) => {
|
||||
seen.push(init.body as FormData);
|
||||
return { json: async () => ({ code: 0, data: { id: 78, url: "/cms/upload/b.png", fileName: "b.png" } }) };
|
||||
})
|
||||
);
|
||||
mountEditor({ siteId: 5, articleId: 12 }, () => {});
|
||||
await runUpload(new File(["x"], "b.png", { type: "image/png" }));
|
||||
|
||||
expect(seen[0].get("siteId")).toBe("5");
|
||||
expect(seen[0].get("articleId")).toBe("12");
|
||||
expect(seen[0].get("scene")).toBeNull();
|
||||
});
|
||||
|
||||
it("② 上传成功:emit uploaded(id) 且 insertFn 收到 (url, fileName, url)", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
json: async () => ({ code: 0, data: { id: 99, url: "/cms/upload/c.png", fileName: "c.png" } }),
|
||||
}))
|
||||
);
|
||||
const ids: number[] = [];
|
||||
mountEditor({ siteId: 5, scene: "svc_item_asset" }, id => ids.push(id));
|
||||
const { inserted } = await runUpload(new File(["x"], "c.png", { type: "image/png" }));
|
||||
|
||||
expect(ids).toEqual([99]);
|
||||
expect(inserted).toEqual(["/cms/upload/c.png"]);
|
||||
});
|
||||
|
||||
it("③ 上传失败(code≠0):不 emit uploaded、不插入图片", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ json: async () => ({ code: 2, msg: "scene 非法" }) }))
|
||||
);
|
||||
const ids: number[] = [];
|
||||
mountEditor({ siteId: 5, scene: "bad_scene" }, id => ids.push(id));
|
||||
const { inserted } = await runUpload(new File(["x"], "d.png", { type: "image/png" }));
|
||||
|
||||
expect(ids).toEqual([]);
|
||||
expect(inserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("④ 工具栏仍排除视频组与全屏(Notice 趟坑口径不回退)", () => {
|
||||
mountEditor({ siteId: 5 }, () => {});
|
||||
expect((globalThis as any).__toolbarConfig.excludeKeys).toEqual(["group-video", "fullScreen"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
/**
|
||||
* 通用富文本编辑器(事项库 C-5:从 ArticleEdit 内联 wangEditor 抽取的公共组件;
|
||||
* 审计批次 6 起 ArticleEdit 亦复用本组件,消除两份实现)。
|
||||
* v-model 绑定 HTML;图片 customUpload 直传 /api/attachment/upload(fetch + credentials),
|
||||
* 上传成功 emit uploaded(attachmentId) 供调用方收集(保存时绑定归属)。
|
||||
* 销毁时 editor.destroy()(wangEditor 必须显式销毁防内存泄漏)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
siteId?: number;
|
||||
/** 附件场景(article_asset 正文资产等;空=不带 scene;svc 场景已迁办事大厅) */
|
||||
scene?: string;
|
||||
/** 文章 id(文章编辑器上传时的归属参数;无文章 id 的页面不传) */
|
||||
articleId?: number;
|
||||
placeholder?: string;
|
||||
/** 建议 ≥320(长文显示不全修复口径:容器随内容增高,短文铺满 min-height) */
|
||||
height?: number;
|
||||
/** slate 内容区最小高度(CSS 值);缺省用事项页口径,文章页传入自己的口径保持原观感 */
|
||||
slateMinHeight?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", v: string): void;
|
||||
(e: "uploaded", attachmentId: number): void;
|
||||
}>();
|
||||
|
||||
const editorRef = shallowRef();
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: props.placeholder || "请输入内容……",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (props.siteId) {
|
||||
fd.append("siteId", String(props.siteId));
|
||||
}
|
||||
if (props.articleId) {
|
||||
fd.append("articleId", String(props.articleId));
|
||||
}
|
||||
if (props.scene) {
|
||||
fd.append("scene", props.scene);
|
||||
}
|
||||
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) {
|
||||
emit("uploaded", res.data.id);
|
||||
insertFn(res.data.url, res.data.fileName || "", res.data.url);
|
||||
} else {
|
||||
Message.error(res.msg || "图片上传失败");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[rich-editor] 图片上传失败", e);
|
||||
Message.error("图片上传失败");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy();
|
||||
});
|
||||
|
||||
// 外部回显值变化同步进编辑器(编辑页 load 后异步回填)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (editorRef.value && v !== editorRef.value.getHtml()) {
|
||||
editorRef.value.setHtml(v || "<p><br></p>");
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rich-editor"
|
||||
:style="{ minHeight: (height || 360) + 'px', '--rich-editor-slate-min': slateMinHeight || 'max(240px, calc(100vh - 560px))' }"
|
||||
>
|
||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||
<Editor
|
||||
class="editor-content"
|
||||
:model-value="modelValue"
|
||||
:defaultConfig="editorConfig"
|
||||
mode="default"
|
||||
style="height: 100%"
|
||||
@onCreated="handleCreated"
|
||||
@update:model-value="(v: string) => emit('update:modelValue', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rich-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
z-index: 100;
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #dcdee2;
|
||||
}
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
overflow: visible;
|
||||
// 长文显示不全修复(2026-09-10)同款口径:文本容器高度随内容自动增长,
|
||||
// 整篇平铺由页面滚动;空态/短文 slate 区 min-height 铺满(调用方可用 slateMinHeight 覆写口径)
|
||||
:deep(.w-e-text-container) {
|
||||
height: auto !important;
|
||||
}
|
||||
:deep([data-slate-editor]) {
|
||||
min-height: var(--rich-editor-slate-min) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,571 @@
|
||||
<script setup lang="ts">
|
||||
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
|
||||
* header 左侧仅超管可见「↩返回学校列表」图标(校级账号不显示);
|
||||
* 面包屑 = 学校列表(超管)> 校名 > 站点身份条 > 页面。
|
||||
* 站点分层(docs/13 方案 C):
|
||||
* URL 为站点上下文唯一事实源——主站 /sch/{code}/{page},子站 /sch/{code}/site/{siteCode}/{page};
|
||||
* 站点身份条(SiteSwitcher)一步切换并停留当前功能页,上下文由 syncSiteFromRoute 同步;
|
||||
* 菜单 page 保持主站形态,高亮剥段匹配、跳转按当前站点补段。
|
||||
* 学校切换仍在壳外完成:/workbench 工作台选校。
|
||||
* 菜单/高亮/子页面面包屑逻辑与原 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 { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
|
||||
import SiteSwitcher from "@/components/SiteSwitcher.vue";
|
||||
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
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 isSuper = computed(() => store.isSuperAdmin);
|
||||
|
||||
/** 当前落点是否院系子站(store 契约):决定身份条徽章、侧栏/面包屑身份色带与页头站点名(v2 §C.3 #2/#3) */
|
||||
const isSubSite = computed(() => store.isSubSite);
|
||||
/** 身份档位类:同名类挂在 .left-menu 与 .path-nav 两处壳层节点,主站不染色(默认为主站、偏离才提示) */
|
||||
const identityClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
|
||||
|
||||
/* 页头身份(docs/22 v3):学校名.站点名 —— 一处文字同时回答「哪个学校 + 哪个站点」。
|
||||
子站取站点名(云海大学.机电学院),主站固定写「主站」(云海大学.主站);
|
||||
沿用既有 .shell-site 胶囊语汇(band-sub 橙 / band-main 中性白),不引新色值。 */
|
||||
const siteName = computed(() => store.currentSite?.name || "");
|
||||
const schoolName = computed(() => school.value?.schName || school.value?.mainSiteName || "");
|
||||
/** 页头站点档位文案:主站不写站全名(会很长),统一「主站」 */
|
||||
const siteLabel = computed(() => (isSubSite.value ? siteName.value : "主站"));
|
||||
/** 完整身份串:学校名.站点名(学校未就绪时退化为站点名) */
|
||||
const identityText = computed(() => {
|
||||
const label = siteLabel.value;
|
||||
if (!label) return "";
|
||||
return schoolName.value ? `${schoolName.value}.${label}` : label;
|
||||
});
|
||||
const shellTitle = computed(() =>
|
||||
identityText.value ? `${appName.value} · ${identityText.value}` : appName.value
|
||||
);
|
||||
const docTitle = computed(() => {
|
||||
const t = String((route.meta as any)?.title || "");
|
||||
// 多标签页可分辨:文档标题同样带「学校名.站点名」,与页头口径一致
|
||||
if (!identityText.value) return t;
|
||||
return t ? `${t} · ${identityText.value}` : identityText.value;
|
||||
});
|
||||
watch(docTitle, v => { if (v) document.title = v; }, { immediate: true });
|
||||
|
||||
/** 菜单/面包屑 page(主站形态完整路径)→ 当前站点形态:子站插入 /site/{code} 段(docs/13 §4)。
|
||||
* 非 /sch/ 前缀(外链模块)原样返回,防拼坏 */
|
||||
const withSiteSeg = (page: string): string => {
|
||||
const code = school.value?.mainSiteCode;
|
||||
if (!page || !code || !page.startsWith("/sch/")) return page;
|
||||
return adminPathOf(code, store.siteSegCode, schoolSubPath(page));
|
||||
};
|
||||
|
||||
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[] = [];
|
||||
// 菜单 page 为主站形态,匹配前剥掉站点中缀(docs/13 §4 剥段匹配)
|
||||
const toLogic = stripSiteSeg(to.path || "");
|
||||
|
||||
// 侧边栏子菜单组自动展开:当前高亮落在某组子项时展开该组(改善直达体验)
|
||||
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: withSiteSeg(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 === toLogic) ||
|
||||
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: withSiteSeg(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 = stripSiteSeg(r.path);
|
||||
navPath.value = [{ name: (r.meta as any).title, page: withSiteSeg(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;
|
||||
}
|
||||
// 子站上下文下菜单目标补站点段(主站形态 → 当前站点形态)
|
||||
const target = withSiteSeg(page);
|
||||
if (target === route.path) {
|
||||
router.go(0);
|
||||
} else {
|
||||
router.push(target);
|
||||
}
|
||||
};
|
||||
|
||||
const linkUserInfo = () => {
|
||||
window.location.href = baseHost.value + "/v/userinfo";
|
||||
};
|
||||
const logout = () => {
|
||||
let url = window.location.href;
|
||||
url = "/logout?service=" + url;
|
||||
window.location.href = ssoHost.value + url;
|
||||
};
|
||||
/** 返回首页:旧 BschHome 应用已下线(线上 /BschHome/front/index.html 返回 404),统一跳用户中心 userhome */
|
||||
const linkHomePage = () => {
|
||||
window.location.href = (baseHost.value || "") + "/userhome/front/index.html";
|
||||
};
|
||||
const childChange = (index: number) => {
|
||||
const arr = modList.value || [];
|
||||
if (arr[index]) {
|
||||
arr[index].childState = !arr[index].childState;
|
||||
}
|
||||
};
|
||||
|
||||
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
|
||||
* 兜底保留 modId 对照。图标为 ysch-icons(CSS mask 类,见 styles/icons.css,与 Notice 同一套);
|
||||
* 友情链接/附件库内无完全对应语义,分别取 cshxd(路牌指引)、sjlm(文档层叠)近似 */
|
||||
const getMenuIcon = (item?: any): string => {
|
||||
const page: string = item?.page || "";
|
||||
if (page === "/article" || page.startsWith("/article")) return "icon-wenzhang";
|
||||
if (page === "/category") return "icon-liebiao";
|
||||
if (page === "/banner") return "icon-tupian";
|
||||
if (page === "/link") return "icon-cshxd";
|
||||
if (page === "/attachment") return "icon-sjlm";
|
||||
if (page === "/site") return "icon-zhandian";
|
||||
// 事项库 R2(§7.5):办事事项取"申请"图标,服务字典取列表图标(前缀分支,优先于 modId 对照)
|
||||
switch (item?.modId ?? item?.id) {
|
||||
case 1: return "icon-wenzhang";
|
||||
case 2: return "icon-liebiao";
|
||||
case 3: return "icon-tupian";
|
||||
case 4: return "icon-cshxd";
|
||||
case 5: return "icon-sjlm";
|
||||
case 6: return "icon-zhandian";
|
||||
case 21: return "icon-shenqing";
|
||||
case 22: return "icon-liebiao";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
/* ===== 学校上下文(docs/09 §3.3)===== */
|
||||
function goWorkbench() {
|
||||
router.push("/workbench");
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 站点段 → store 同步(docs/13 §3:URL 是站点上下文唯一事实源)。
|
||||
* 无 siteCode = 主站形态;有则按 code 匹配本校站点(parentSiteId 归属校验),
|
||||
* 非法/跨校 siteCode 打回同页主站形态并告警。
|
||||
* 启动引导期(user 未装载 → schoolSites 为空)跳过,由 user 就绪后的 watch 兜底重跑。
|
||||
*/
|
||||
function syncSiteFromRoute() {
|
||||
const sc = school.value;
|
||||
if (!sc || !store.user) return;
|
||||
const sites = (store.schoolSites || []) as any[];
|
||||
if (!sites.length) return;
|
||||
const { site: target, invalid } = resolveRouteSite(sites, sc.mainSiteId, route.params.siteCode as string);
|
||||
if (invalid) {
|
||||
Message.warning("站点不存在或无权限,已返回主站");
|
||||
router.replace({ path: stripSiteSeg(route.path), query: route.query, hash: route.hash });
|
||||
return;
|
||||
}
|
||||
if (target && store.currentSite?.id !== target.id) {
|
||||
store.setCurrentSite({
|
||||
id: target.id,
|
||||
code: target.code,
|
||||
path: target.path || target.code,
|
||||
name: target.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文,再按 URL 同步站点 */
|
||||
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)
|
||||
);
|
||||
}
|
||||
syncSiteFromRoute();
|
||||
routeChange(route);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeMount(() => {
|
||||
routeChange(route);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
syncSiteFromRoute();
|
||||
routeChange(route);
|
||||
}
|
||||
);
|
||||
|
||||
// 深链启动期:学校上下文/菜单晚于首次 routeChange 到达,就绪后重算面包屑与高亮
|
||||
watch(
|
||||
() => [store.currentSchool?.mainSiteCode, store.session.modList.length],
|
||||
() => {
|
||||
syncSiteFromRoute();
|
||||
routeChange(route);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrap">
|
||||
<header class="header-container fix">
|
||||
<!-- 云海 logo 徽标(07-seam-pure 定稿顶栏,皮肤见 yh-theme.css .logo-badge) -->
|
||||
<span class="logo-badge fl"><img src="@/assets/images/yunhai-logo.svg" alt="云海" /></span>
|
||||
<div class="fl align-middle icon-box">
|
||||
<div title="首页" class="icon-btn" @click="linkHomePage">
|
||||
<i class="iconfont icon-shouye"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<!-- 返回学校列表:仅超管显示(校级账号固定本校,无需返回入口) -->
|
||||
<a
|
||||
v-if="isSuper"
|
||||
class="fl align-middle back-workbench"
|
||||
href="javascript:void(0);"
|
||||
title="返回学校列表"
|
||||
@click="goWorkbench"
|
||||
>
|
||||
<Icon type="ios-arrow-back" :size="20" />
|
||||
</a>
|
||||
<div v-if="isSuper" class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle" :title="shellTitle">
|
||||
{{ appName }}<template v-if="identityText"
|
||||
><span class="h-delimiter"></span
|
||||
><span class="school-cap" :class="identityClass"
|
||||
>{{ schoolName }}<span class="sc-sep">.</span
|
||||
><span class="sc-site">{{ siteLabel }}</span></span
|
||||
></template
|
||||
>
|
||||
</h4>
|
||||
<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>
|
||||
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
|
||||
<div class="theme-cap fr"><ThemeSwitcher /></div>
|
||||
</header>
|
||||
<div class="left-menu" :class="identityClass" v-if="!isAlone">
|
||||
<!-- 站点卡已于 docs/22 v3 撤除(与站点工作台重复);身份改由页头「学校名.站点名」承担 -->
|
||||
<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>
|
||||
<i v-if="getMenuIcon(item)" class="iconfont" :class="getMenuIcon(item)"></i>
|
||||
{{ 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)">
|
||||
<i v-if="getMenuIcon(item)" class="iconfont" :class="getMenuIcon(item)"></i>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="clear-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="path-nav" :class="identityClass" :style="pathNavStyle">
|
||||
<Breadcrumb separator=">">
|
||||
<!-- 首页段(常驻第一段):旧 BschHome 已下线,统一跳用户中心 userhome -->
|
||||
<a href="javascript:;" @click="linkHomePage">
|
||||
<BreadcrumbItem>
|
||||
<span class="crumb-home">
|
||||
<i class="iconfont icon-category"></i>
|
||||
首页
|
||||
</span>
|
||||
</BreadcrumbItem>
|
||||
</a>
|
||||
<!-- 超管:校名前多一段可点「学校列表」,与 header 返回图标同效(回工作台切换学校) -->
|
||||
<BreadcrumbItem v-if="isSuper" to="/workbench">学校列表</BreadcrumbItem>
|
||||
<!-- 面包屑第二段 = 学校上下文(常驻,不承载站点信息) -->
|
||||
<BreadcrumbItem v-if="school">
|
||||
<Icon type="ios-school" />
|
||||
{{ school.schName || school.mainSiteName }}
|
||||
</BreadcrumbItem>
|
||||
<!-- 站点身份条(docs/13 §1):徽章+站点名▼,下拉一步切换;不传 to(否则 mixins-link 抢走点击) -->
|
||||
<BreadcrumbItem v-if="store.currentSite">
|
||||
<SiteSwitcher />
|
||||
</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>
|
||||
|
||||
<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 + '/userhome/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 常驻入口(校级账号不渲染)。
|
||||
与「首页」按钮同规格的可点方块(26×26 + hover 底)+ 左右 10px 呼吸 ——
|
||||
原先是裸 18×18 图标夹在两条分隔线之间(x=126/146),既无点击热区、又比首页按钮小一档;
|
||||
图标同时从 ios-undo(字形像「拐角」)换成 ios-arrow-back。 */
|
||||
.back-workbench {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin: 0 10px;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.16s;
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.no-site-tip {
|
||||
height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 顶栏浮动作增补:logo 徽标垂直居中(56-30)/2=13px;配色胶囊同高对齐。
|
||||
接缝条与站点身份带(原 .path-nav::after 3px 色带)已由 yh-theme.css 统一接管
|
||||
(band-sub 时 ::before 让位身份橙),本组件不再画色带,仅保留 band-* 类名作档位 */
|
||||
.header-container .logo-badge {
|
||||
margin: 13px 10px 0 14px;
|
||||
}
|
||||
.theme-cap {
|
||||
height: 56px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* W2(D6):左树高度链修复——UserBase App.vue:222-224 同款覆盖式。
|
||||
layout.css 全局 .main-wrapper 只有 min-height:100% 无 height,
|
||||
子级 .tree-wrapper{height:100%} 因父链高度 auto 解析失败,栏目树缩在一起。
|
||||
scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点 */
|
||||
.base-wrap-main .main-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 高度链顶层:layout.css 只定义到 .base-wrap-main{height:calc(100% - 56px)},
|
||||
其百分比基于 .layout-wrap——本组件根节点,无高度定义则整条链解析失败
|
||||
(UserBase 同名壳在 layout.vue 全局样式中补了这一层,此前 CMS 缺失) */
|
||||
.layout-wrap {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 左侧菜单图标与文字间距(ysch-icons mask 类,写法与 Notice layout.vue 一致) */
|
||||
.left-menu a .iconfont {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 页头身份(docs/22 v3):云海CMS | 学校名.站点名
|
||||
h4 截断——学校名+站名可能很长,完整串在 :title 与 document.title。
|
||||
胶囊色沿用原 .shell-site 取值(主站 rgba(255,255,255,.18) / 子站橙 rgba(255,153,0,.32)),不引新色值。 */
|
||||
.header-container h4 {
|
||||
max-width: 46vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.h-delimiter {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
margin: 0 10px;
|
||||
vertical-align: -2px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.school-cap {
|
||||
font-size: 13px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.sc-sep {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.sc-site {
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.school-cap.band-sub .sc-site {
|
||||
background: rgba(255, 153, 0, 0.32);
|
||||
}
|
||||
|
||||
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
|
||||
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
|
||||
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内)。
|
||||
scoped 特异性 .crumb-home .iconfont[data-v-x](0,2,1)> .path-nav .icon-category(0,2,0),可覆盖 */
|
||||
.crumb-home {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.crumb-home .iconfont {
|
||||
position: static;
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { adminPathOf, schoolSubPath } from "@/utils/sitePath";
|
||||
|
||||
/**
|
||||
* 站点身份条(docs/13 方案 C):面包屑站点格内的「徽章 + 站点名 + 下拉」。
|
||||
* 数据源 store.schoolSites(主站 + 本校院系子站,零后端改动);选中只改 URL,
|
||||
* 站点上下文由 SchoolLayout.syncSiteFromRoute 按 URL 段同步——见 docs/13 §1.3/§3。
|
||||
* 不用 iView Dropdown:分组头/徽章/✓/path 次级列的表达力不足且无可靠收起 API,自绘面板。
|
||||
*/
|
||||
const store = useCmsStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const open = ref(false);
|
||||
const anchor = ref<HTMLElement | null>(null);
|
||||
|
||||
const schoolCode = computed(() => store.currentSchool?.mainSiteCode || "");
|
||||
const mainSite = computed(
|
||||
() => store.schoolSites.find((s: any) => s.id === store.currentSchool?.mainSiteId) || null
|
||||
);
|
||||
const subSites = computed(() =>
|
||||
store.schoolSites.filter((s: any) => s.parentSiteId === store.currentSchool?.mainSiteId)
|
||||
);
|
||||
/** 当前落点是否子站:主站徽章/配色/落点判定统一走 store 契约 */
|
||||
const isSub = computed(() => store.isSubSite);
|
||||
const currentName = computed(() => store.currentSite?.name || "");
|
||||
|
||||
/** 当前功能页子路径:切换站点停留当前页(剥掉站点段与学校前缀) */
|
||||
const curSub = computed(() => schoolSubPath(route.path) || "/article");
|
||||
|
||||
function pick(site: any) {
|
||||
open.value = false;
|
||||
if (!site) return;
|
||||
const isMain = site.id === store.currentSchool?.mainSiteId;
|
||||
const target = adminPathOf(schoolCode.value, isMain ? null : site.code, curSub.value);
|
||||
if (target === route.path) return;
|
||||
router.push(target);
|
||||
}
|
||||
|
||||
/** 底部入口:管理站点集合(带段=保持当前站点上下文,语义与菜单「站点管理」一致) */
|
||||
function gotoManage() {
|
||||
open.value = false;
|
||||
router.push(adminPathOf(schoolCode.value, store.siteSegCode, "site"));
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!open.value) return;
|
||||
if (anchor.value && !anchor.value.contains(e.target as Node)) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") open.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="anchor" class="site-identity" :class="{ sub: isSub }">
|
||||
<span class="bdg" :class="isSub ? 'bdg-sub' : 'bdg-main'" @click.stop="open = !open">
|
||||
{{ isSub ? "院系子站" : "校主站" }}
|
||||
</span>
|
||||
<b class="site-name" @click.stop="open = !open">{{ currentName }}</b>
|
||||
<Icon type="ios-arrow-down" class="caret" @click.stop="open = !open" />
|
||||
|
||||
<div v-if="open" class="site-switcher-dd" @click.stop>
|
||||
<div class="dd-cap">切换管理站点(当前:<b>{{ currentName }}</b>)</div>
|
||||
<div class="dd-group">
|
||||
<div v-if="mainSite" class="dd-item" :class="{ active: !isSub }" @click="pick(mainSite)">
|
||||
<span class="check">{{ !isSub ? "✓" : "" }}</span>
|
||||
<span>{{ mainSite.name }}</span>
|
||||
<span class="bdg bdg-main">主站</span>
|
||||
<span class="code">{{ mainSite.path || mainSite.code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dd-group">
|
||||
<div class="dd-cap sub-cap">院系子站({{ subSites.length }})</div>
|
||||
<div v-if="!subSites.length" class="dd-empty">本校暂无院系子站(可在「站点管理」新建)</div>
|
||||
<div
|
||||
v-for="s in subSites"
|
||||
:key="s.id"
|
||||
class="dd-item"
|
||||
:class="{ active: store.currentSite?.id === s.id }"
|
||||
@click="pick(s)"
|
||||
>
|
||||
<span class="check">{{ store.currentSite?.id === s.id ? "✓" : "" }}</span>
|
||||
<span>{{ s.name }}</span>
|
||||
<span class="bdg bdg-sub">院系</span>
|
||||
<span class="code">{{ s.path || s.code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dd-foot" @click="gotoManage">前往「站点管理」维护站点 →</div>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 徽章:实心色块对齐已验收原型(bdg-main #577afb / bdg-sub #ff9900);
|
||||
iView Tag 预设为浅底描边样式,视觉与原型不一致,故自绘 */
|
||||
.bdg {
|
||||
font-size: 10px;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
color: #fff;
|
||||
line-height: 16px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bdg-main {
|
||||
background: #577afb;
|
||||
}
|
||||
.bdg-sub {
|
||||
background: #ff9900;
|
||||
}
|
||||
|
||||
.site-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.site-name {
|
||||
color: #4465d4;
|
||||
font-weight: bold;
|
||||
}
|
||||
.caret {
|
||||
font-size: 10px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
&.sub {
|
||||
background: #fff7ec;
|
||||
&:hover {
|
||||
background: #ffefd6;
|
||||
}
|
||||
.site-name {
|
||||
color: #d48806;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.site-switcher-dd {
|
||||
position: absolute;
|
||||
top: calc(100% + 12px);
|
||||
left: -8px;
|
||||
min-width: 300px;
|
||||
background: #fff;
|
||||
color: #272829;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 28px rgba(31, 42, 58, 0.2);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
text-align: left;
|
||||
animation: dd-in 0.15s ease-out;
|
||||
cursor: default;
|
||||
.dd-cap {
|
||||
padding: 10px 14px 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.sub-cap {
|
||||
padding: 2px 14px;
|
||||
}
|
||||
.dd-group {
|
||||
padding: 6px 0;
|
||||
& + .dd-group {
|
||||
border-top: 1px solid #f0f2f5;
|
||||
}
|
||||
}
|
||||
.dd-empty {
|
||||
padding: 6px 14px 8px;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.dd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: #eff4ff;
|
||||
}
|
||||
&.active {
|
||||
background: #eff4ff;
|
||||
color: #4465d4;
|
||||
font-weight: bold;
|
||||
}
|
||||
.check {
|
||||
color: #577afb;
|
||||
font-weight: bold;
|
||||
width: 11px;
|
||||
}
|
||||
.code {
|
||||
margin-left: auto;
|
||||
color: #c0c4cc;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
.dd-foot {
|
||||
border-top: 1px solid #f0f2f5;
|
||||
padding: 9px 14px;
|
||||
font-size: 12px;
|
||||
color: #577afb;
|
||||
cursor: pointer;
|
||||
background: #fafbfc;
|
||||
&:hover {
|
||||
background: #f0f3ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dd-in {
|
||||
from {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</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,115 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 配色切换器(yh-theme §6.5)行为回归:
|
||||
* 点主题色 → <html data-theme> 与 localStorage 同步;开关 → side-deep/side-light、grad-*、
|
||||
* side-plain 档位类名互斥切换;胶囊点击开合弹层。沿用 createApp 手写挂载桩风格。
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createApp, nextTick } from "vue";
|
||||
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
|
||||
import { THEME_STORAGE_KEY, loadThemeState } from "@/utils/theme";
|
||||
|
||||
let host: HTMLDivElement;
|
||||
let app: ReturnType<typeof createApp>;
|
||||
|
||||
function mount() {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
app = createApp(ThemeSwitcher);
|
||||
app.mount(host);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
const el = document.documentElement;
|
||||
el.removeAttribute("data-theme");
|
||||
["side-deep", "side-light", "grad-on", "grad-off", "side-plain"].forEach(c =>
|
||||
el.classList.remove(c)
|
||||
);
|
||||
app?.unmount();
|
||||
host?.remove();
|
||||
mount();
|
||||
});
|
||||
|
||||
function options(): HTMLButtonElement[] {
|
||||
return Array.from(host.querySelectorAll(".theme-opt"));
|
||||
}
|
||||
|
||||
function switches(): HTMLInputElement[] {
|
||||
return Array.from(host.querySelectorAll(".sw input"));
|
||||
}
|
||||
|
||||
/** 按 i 元素内联背景色定位主题按钮(swatch 色 = 定稿表) */
|
||||
function optionByColor(color: string): HTMLButtonElement {
|
||||
const el = options().find(b => (b.querySelector("i") as HTMLElement).style.background === color);
|
||||
if (!el) throw new Error(`option not found: ${color}`);
|
||||
return el;
|
||||
}
|
||||
|
||||
function click(el: HTMLElement) {
|
||||
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("ThemeSwitcher", () => {
|
||||
it("初始态:无存储 → 默认档 blue 高亮,active 类唯一", () => {
|
||||
const active = options().filter(b => b.classList.contains("active"));
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].textContent).toContain("原版靛蓝");
|
||||
});
|
||||
|
||||
it("点「云海蓝」→ html[data-theme=ocean] 且持久化", () => {
|
||||
click(optionByColor("rgb(47, 124, 224)"));
|
||||
expect(document.documentElement.dataset.theme).toBe("ocean");
|
||||
expect(loadThemeState().theme).toBe("ocean");
|
||||
expect(JSON.parse(localStorage.getItem(THEME_STORAGE_KEY)!).theme).toBe("ocean");
|
||||
});
|
||||
|
||||
it("关「侧栏深色」→ side-deep 移除、side-light 挂上(互斥)", () => {
|
||||
expect(document.documentElement.classList.contains("side-deep")).toBe(true);
|
||||
const [follow, deep] = switches();
|
||||
void follow;
|
||||
deep.checked = false;
|
||||
deep.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
nextTick();
|
||||
const cl = document.documentElement.classList;
|
||||
expect(cl.contains("side-deep")).toBe(false);
|
||||
expect(cl.contains("side-light")).toBe(true);
|
||||
expect(loadThemeState().sideDeep).toBe(false);
|
||||
});
|
||||
|
||||
it("关「侧栏跟随顶栏色」→ side-plain 挂上", () => {
|
||||
const [follow] = switches();
|
||||
follow.checked = false;
|
||||
follow.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
nextTick();
|
||||
expect(document.documentElement.classList.contains("side-plain")).toBe(true);
|
||||
expect(loadThemeState().sideFollow).toBe(false);
|
||||
});
|
||||
|
||||
it("胶囊点击开合弹层", async () => {
|
||||
const btn = host.querySelector(".theme-btn") as HTMLElement;
|
||||
const pop = host.querySelector(".theme-pop") as HTMLElement;
|
||||
expect(pop.classList.contains("open")).toBe(false);
|
||||
click(btn);
|
||||
await nextTick();
|
||||
expect(pop.classList.contains("open")).toBe(true);
|
||||
click(btn);
|
||||
await nextTick();
|
||||
expect(pop.classList.contains("open")).toBe(false);
|
||||
});
|
||||
|
||||
it("挂载前已有存储(teal+浅色+无渐变)→ 按钮态与 html 档恢复", () => {
|
||||
localStorage.setItem(
|
||||
THEME_STORAGE_KEY,
|
||||
JSON.stringify({ theme: "teal", sideDeep: false, gradOn: false, sideFollow: true })
|
||||
);
|
||||
app.unmount();
|
||||
mount();
|
||||
expect(document.documentElement.dataset.theme).toBe("teal");
|
||||
const active = options().find(b => b.classList.contains("active"));
|
||||
expect(active?.textContent).toContain("松石绿");
|
||||
const [, deep, grad] = switches();
|
||||
expect(deep.checked).toBe(false);
|
||||
expect(grad.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
/* 管理端配色切换器(yh-theme 定稿 07-seam-pure 顶栏「配色」胶囊,方案 §6.5)。
|
||||
* 状态唯一来源:utils/theme(localStorage 持久化 + <html> 档位类名),与门户皮肤包正交。 */
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import {
|
||||
loadThemeState,
|
||||
saveThemeState,
|
||||
applyThemeState,
|
||||
type ThemeName,
|
||||
type ThemeState,
|
||||
} from "@/utils/theme";
|
||||
|
||||
const THEME_META: { name: ThemeName; label: string; color: string }[] = [
|
||||
{ name: "blue", label: "原版靛蓝", color: "#577afb" },
|
||||
{ name: "ocean", label: "云海蓝", color: "#2f7ce0" },
|
||||
{ name: "ink", label: "石墨墨", color: "#3d4d63" },
|
||||
{ name: "violet", label: "靛紫", color: "#6d5ae0" },
|
||||
{ name: "teal", label: "松石绿", color: "#0d9488" },
|
||||
];
|
||||
|
||||
const wrapEl = ref<HTMLElement | null>(null);
|
||||
const open = ref(false);
|
||||
const state = ref<ThemeState>(loadThemeState());
|
||||
/* 挂载即把存储态落到 <html>(与 main.ts initTheme 同源幂等;组件晚于引导挂载时也自洽) */
|
||||
applyThemeState(state.value);
|
||||
|
||||
function commit(patch: Partial<ThemeState>) {
|
||||
state.value = { ...state.value, ...patch };
|
||||
applyThemeState(state.value);
|
||||
saveThemeState(state.value);
|
||||
}
|
||||
|
||||
function toggle(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
open.value = !open.value;
|
||||
}
|
||||
|
||||
/** 切换器外任意点击即收起(弹层内部点击由 wrap 判定放过) */
|
||||
function onDocClick(ev: MouseEvent) {
|
||||
if (open.value && wrapEl.value && !wrapEl.value.contains(ev.target as Node)) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("click", onDocClick, true));
|
||||
onBeforeUnmount(() => document.removeEventListener("click", onDocClick, true));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapEl" class="theme-wrap">
|
||||
<button type="button" class="theme-btn" :class="{ on: open }" @click="toggle">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.9-.9 1.9-1.8 0-.6-.3-1-.5-1.4-.2-.4-.4-.8-.4-1.3 0-.9.8-1.7 1.8-1.7H17a4 4 0 0 0 4-4c0-4.3-4-7.8-9-7.8z"/>
|
||||
<circle cx="7.6" cy="11.2" r="1.1"/><circle cx="11" cy="7.6" r="1.1"/><circle cx="15.4" cy="8.6" r="1.1"/>
|
||||
</svg>
|
||||
<span>配色</span>
|
||||
<svg class="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5.5 9.5l6.5 6.5 6.5-6.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="theme-pop" :class="{ open }">
|
||||
<p class="tp-head">主题色</p>
|
||||
<div class="tp-grid">
|
||||
<button
|
||||
v-for="t in THEME_META"
|
||||
:key="t.name"
|
||||
type="button"
|
||||
class="theme-opt"
|
||||
:class="{ active: state.theme === t.name }"
|
||||
@click="commit({ theme: t.name })"
|
||||
>
|
||||
<i :style="{ background: t.color }"></i>{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tp-sep"></div>
|
||||
<p class="tp-head">侧栏样式</p>
|
||||
<label class="tp-row">
|
||||
<span>侧栏跟随顶栏色</span>
|
||||
<span class="sw">
|
||||
<input type="checkbox" :checked="state.sideFollow" @change="commit({ sideFollow: ($event.target as HTMLInputElement).checked })" />
|
||||
<i></i>
|
||||
</span>
|
||||
</label>
|
||||
<label class="tp-row" style="margin-top: 9px">
|
||||
<span>侧栏深色(关=浅色)</span>
|
||||
<span class="sw">
|
||||
<input type="checkbox" :checked="state.sideDeep" @change="commit({ sideDeep: ($event.target as HTMLInputElement).checked })" />
|
||||
<i></i>
|
||||
</span>
|
||||
</label>
|
||||
<div class="tp-sep"></div>
|
||||
<label class="tp-row">
|
||||
<span>顶部过渡色(渐变)</span>
|
||||
<span class="sw">
|
||||
<input type="checkbox" :checked="state.gradOn" @change="commit({ gradOn: ($event.target as HTMLInputElement).checked })" />
|
||||
<i></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 皮肤规格照搬定稿 07-seam-pure.html .theme-wrap/.theme-btn/.theme-pop 段 */
|
||||
.theme-wrap {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 11px;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255, 255, 255, 0.34);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
transition: background 0.16s, border-color 0.16s;
|
||||
}
|
||||
.theme-btn:hover,
|
||||
.theme-btn.on {
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.theme-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.theme-btn svg circle {
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
.theme-btn .caret {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
opacity: 0.85;
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
.theme-btn.on .caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.theme-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 224px;
|
||||
background: #fff;
|
||||
color: var(--text, #1e2635);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line, #e9edf5);
|
||||
padding: 12px;
|
||||
box-shadow: 0 12px 34px -10px rgba(16, 24, 40, 0.28), 0 2px 6px rgba(16, 24, 40, 0.08);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-6px);
|
||||
transition: 0.18s;
|
||||
z-index: 40;
|
||||
}
|
||||
.theme-pop.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.tp-head {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 2px 8px;
|
||||
}
|
||||
.tp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.theme-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border: 1px solid var(--line, #e9edf5);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--text-2, #5a6478);
|
||||
font-size: 12px;
|
||||
transition: 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.theme-opt:hover {
|
||||
border-color: #cfd7e6;
|
||||
background: #fafbfe;
|
||||
}
|
||||
.theme-opt i {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.theme-opt.active {
|
||||
border-color: var(--brand, #577afb);
|
||||
color: var(--brand, #577afb);
|
||||
background: var(--brand-soft, #eef2ff);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tp-sep {
|
||||
height: 1px;
|
||||
background: var(--line, #e9edf5);
|
||||
margin: 12px 0 10px;
|
||||
}
|
||||
.tp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2, #5a6478);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 21px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sw input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
.sw i {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background: #d7dce6;
|
||||
transition: background 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sw i::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2.5px;
|
||||
left: 2.5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.28);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.sw input:checked + i {
|
||||
background: var(--brand, #577afb);
|
||||
}
|
||||
.sw input:checked + i::after {
|
||||
transform: translateX(17px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
/**
|
||||
* 工作台同款深色顶栏(超管平台级页面共用):
|
||||
* 标题 + 可选「返回」图标 + 头像/姓名(用户中心)+ 退出登录。
|
||||
* 使用方:SchoolWorkbench(无返回)、ThemeManage(backTo=/workbench)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
/** 传入则渲染返回图标按钮(如返回工作台) */
|
||||
backTo?: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const userInfo = computed(() => ({
|
||||
name: store.session.baseUserName || "管理员",
|
||||
avatar: store.session.baseUserAvatar,
|
||||
}));
|
||||
|
||||
function goBack() {
|
||||
if (props.backTo) {
|
||||
router.push(props.backTo);
|
||||
}
|
||||
}
|
||||
const linkUserInfo = () => {
|
||||
window.location.href = config.baseHost + "/v/userinfo";
|
||||
};
|
||||
const logout = () => {
|
||||
const url = "/logout?service=" + window.location.href;
|
||||
window.location.href = config.ssoHost + url;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="wb-header">
|
||||
<h4>{{ title }}</h4>
|
||||
<div class="wb-header-aside">
|
||||
<a v-if="backTo" href="javascript:void(0);" class="back" title="返回工作台" @click="goBack">
|
||||
<Icon type="ios-arrow-back" :size="20" />
|
||||
</a>
|
||||
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="wb-avatar" @click="linkUserInfo" />
|
||||
<a class="name" href="javascript:void(0);" @click="linkUserInfo">{{ userInfo.name }}</a>
|
||||
<span class="delimiter"></span>
|
||||
<a href="javascript:;" class="logout" @click="logout" title="退出登陆">
|
||||
<i class="iconfont icon-logout"></i>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wb-header {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
background: #1f2a3a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
h4 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.wb-header-aside {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
.name {
|
||||
color: #fff;
|
||||
}
|
||||
.back {
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.delimiter {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
display: inline-block;
|
||||
}
|
||||
.wb-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.logout {
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, watch } from "vue";
|
||||
import { useRoute, 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";
|
||||
import { adminPathOf } from "@/utils/sitePath";
|
||||
|
||||
/**
|
||||
* 文章列表面板(B5 栏目工作台组件化,自 ArticleList.vue 原样抽出):
|
||||
* - 无 props = 全局检索行为(ArticleList.vue 薄壳引用,路由/搜索参数兼容);
|
||||
* - fixedCategory + categoryId = 固定栏目模式:隐藏栏目下拉、查询强制带 categoryId,
|
||||
* categoryId 变化 watch 联动 reload(树节点切换;拖拽排序不改变 id 不触发)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
categoryId?: number;
|
||||
fixedCategory?: boolean;
|
||||
/** 表格高度额外扣减(固定栏目模式嵌入工作台时扣面板头高度) */
|
||||
extraHeight?: number;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
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({
|
||||
// P-D1:栏目页概览「查看全部 →」以 /article?categoryId={id} 深链进入,读 query 预筛选
|
||||
// (query 是字符串,转数字才能对上 Option 的 value 并回显下拉选中项)
|
||||
categoryId: Number((route.query?.categoryId as any)) || 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: !props.fixedCategory,
|
||||
isPage: true,
|
||||
otherH: props.extraHeight ?? 0,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 260,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", null, [
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
class: "operating-link",
|
||||
style: { cursor: "pointer" },
|
||||
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-fasong", () => 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 || props.fixedCategory) 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,
|
||||
// 后端 ArticlePageQuery 字段为 categoryIds(List<Integer>),传 categoryId 会绑定不上、
|
||||
// 退化成全站检索(栏目工作台"切节点表格不变"的根因)
|
||||
categoryIds: props.fixedCategory ? props.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();
|
||||
}
|
||||
|
||||
/** 固定栏目模式下新建文章带 categoryId 预选(ArticleEdit onMounted 回填) */
|
||||
function goEdit(id?: number) {
|
||||
const code = store.schoolCode;
|
||||
if (!code) {
|
||||
router.push("/workbench");
|
||||
return;
|
||||
}
|
||||
const query = props.fixedCategory && props.categoryId ? `?categoryId=${props.categoryId}` : "";
|
||||
// 站点段跟随当前上下文(docs/13):子站编辑页 URL 带 /site/{code}
|
||||
router.push(adminPathOf(code, store.siteSegCode, id ? `article/edit/${id}` : "article/edit") + query);
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 关键字即输即搜(bsch 规范 §2:300ms 防抖,无检索按钮)
|
||||
let keywordTimer: ReturnType<typeof setTimeout>;
|
||||
watch(
|
||||
() => search.keyword,
|
||||
() => {
|
||||
clearTimeout(keywordTimer);
|
||||
keywordTimer = setTimeout(() => {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}, 300);
|
||||
}
|
||||
);
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await run();
|
||||
});
|
||||
|
||||
// 树节点切换联动(B5 红线:仅 watch categoryId,拖拽排序不经过此通道)
|
||||
watch(() => props.categoryId, async () => {
|
||||
pageNum.value = 1;
|
||||
await run();
|
||||
});
|
||||
|
||||
// 站点/学校上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
|
||||
watch(() => store.siteId, async () => {
|
||||
await loadCategories();
|
||||
pageNum.value = 1;
|
||||
await run();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="article-list-panel">
|
||||
<!-- 固定栏目模式:新建入口由工作台面板头承担,避免双按钮重复 -->
|
||||
<div v-if="!fixedCategory" 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-if="!fixedCategory"
|
||||
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"
|
||||
:class="{ 'ml-12': !fixedCategory }"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
class="fr"
|
||||
placeholder="标题关键字"
|
||||
style="width: 200px"
|
||||
v-model="search.keyword"
|
||||
@on-clear="pageNum = 1; run()"
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
class="link-table table-bottom-box"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-wrap .search-wrap_sel {
|
||||
width: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Tag } from "view-ui-plus";
|
||||
import { articleApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { ArticlePageItem } from "@/types/cms";
|
||||
import { adminPathOf } from "@/utils/sitePath";
|
||||
|
||||
/**
|
||||
* 栏目内容概览(P-D1,v2 §B.3):/category 右侧列表型栏目的降级面板。
|
||||
* 定位=只读概览;文章的发布/下架/置顶/删除只在 /article(唯一操作台)做。
|
||||
*
|
||||
* 统计口径(手册 P-D1 定稿,勿自行改):
|
||||
* - 范围 = 选中栏目的**直属 + 全部子孙栏目**。后端 ArticlePageQuery.categoryIds 是不递归的 IN 查询,
|
||||
* 故子孙 id 由前端从栏目树展开(与门户 PortalPageRenderer.collectListDescendantIds 同语义)。
|
||||
* - 状态 = **全状态计数**(草稿/已发布/已下架全部计入,不带 status 过滤;系统内无"回收站"概念)。
|
||||
* - 一次 page 请求同时拿到总数(total)与最近 5 条(list,后端按置顶降序 + 创建时间降序)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
categoryId: number;
|
||||
categoryName?: string;
|
||||
/** 全量栏目树(CategoryManage 的 treeData,含 W2 虚拟根;按 id 定位,虚拟根 id=0 不会命中真实栏目) */
|
||||
tree?: any[];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const total = ref(0);
|
||||
const rows = ref<ArticlePageItem[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
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";
|
||||
|
||||
function findNode(list: any[], id: number): any {
|
||||
for (const n of list || []) {
|
||||
if (Number(n.id) === Number(id)) return n;
|
||||
const hit = findNode(n.children || [], id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectIds(node: any, acc: number[]): number[] {
|
||||
acc.push(Number(node.id));
|
||||
(node.children || []).forEach((c: any) => collectIds(c, acc));
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** 统计范围 id:树未就绪(或未命中)时退化为仅直属栏目 */
|
||||
const scopeIds = computed<number[]>(() => {
|
||||
const node = findNode(props.tree || [], props.categoryId);
|
||||
return node ? collectIds(node, []) : [Number(props.categoryId)];
|
||||
});
|
||||
const scopeKey = computed(() => scopeIds.value.join(","));
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await articleApi
|
||||
.page({
|
||||
siteId: store.siteId,
|
||||
categoryIds: scopeKey.value,
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
})
|
||||
.send();
|
||||
total.value = page?.total || 0;
|
||||
rows.value = (page?.list || []).slice(0, 5);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳 /article 深链预筛选(子栏目行按自身栏目过滤,父栏目"查看全部"按父栏目过滤) */
|
||||
function goArticles(categoryId?: number) {
|
||||
const code = store.schoolCode;
|
||||
if (!code) return;
|
||||
const id = categoryId ?? props.categoryId;
|
||||
router.push(adminPathOf(code, store.siteSegCode, "article") + `?categoryId=${id}`);
|
||||
}
|
||||
|
||||
// 树稍后到达(子孙变化)与切换栏目都重取;站点切换由 CategoryManage 清选中后重建本组件
|
||||
watch(scopeKey, run);
|
||||
onMounted(run);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="category-content-summary">
|
||||
<div class="ccs-stat fix">
|
||||
<span class="ccs-total">共 <b>{{ total }}</b> 篇</span>
|
||||
<span class="ccs-scope">
|
||||
{{ categoryName || "本栏目" }}及子栏目({{ scopeIds.length }} 个栏目)· 草稿/已发布/已下架全部计入
|
||||
</span>
|
||||
<Button class="fr" size="small" @click="goArticles()">查看全部 →</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="ccs-empty">加载中…</div>
|
||||
<ul v-else-if="rows.length" class="ccs-list">
|
||||
<li v-for="row in rows" :key="row.id" class="ccs-row fix">
|
||||
<a class="ccs-title operating-link" :title="row.title" @click="goArticles(row.categoryId)">{{ row.title }}</a>
|
||||
<Tag class="ccs-tag" :color="statusColor(row.status)">{{ statusText(row.status) }}</Tag>
|
||||
<span class="ccs-time">{{ row.publishTime || row.createTime || "-" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="ccs-empty">
|
||||
<p>本栏目及子栏目下暂无文章</p>
|
||||
<p class="ccs-empty-tip">可在上方「新建文章」直接发布到本栏目,或到「文章列表」页检索全站内容。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.category-content-summary {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 4px 16px 16px;
|
||||
}
|
||||
|
||||
.ccs-stat {
|
||||
padding: 14px 0 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.ccs-total {
|
||||
font-size: 14px;
|
||||
color: #515a6e;
|
||||
|
||||
b {
|
||||
font-size: 20px;
|
||||
color: #2d8cf0;
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ccs-scope {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
.ccs-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ccs-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ccs-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ccs-tag {
|
||||
flex: none;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.ccs-time {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
.ccs-empty {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
.ccs-empty-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ThemeVo } from "@/types/cms";
|
||||
import ImageAssetField from "@/components/ImageAssetField.vue";
|
||||
import { effectiveValue, invalidParamKeys, FormModelValue } from "@/utils/themeParamForm";
|
||||
|
||||
/**
|
||||
* 主题参数微调表单(方案 §6.3 + 工作项 T):按 theme.json params schema 动态生成,
|
||||
* 值语义为「皮肤包级参数值」(`ThemeVo.paramValues`,落 themes/.meta/{name}.json)——
|
||||
* 一包一份值,引用同包的站点共享;需要差异走「复制皮肤包」。
|
||||
* 2026-09-20 布局重排(用户反馈"颜色设置竖着太长"):
|
||||
* - color 参数 → 色卡网格(横排自动换行,auto-fill 150px;真实主题 12 个色参数不再拉出 12 行长蛇);
|
||||
* - select/image/text → 两列行式(label + 控件同行);image 走 ImageAssetField(上传 + 手填双通道);
|
||||
* - 非法值红框 + 行内提示;「已修改」标记 + 单项恢复默认 + 整体重置(对照 params[].defaultValue)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
theme?: ThemeVo;
|
||||
modelValue: FormModelValue;
|
||||
/** image 参数上传归属站点(皮肤包管理借用内容源站点;缺省则仅可手填路径) */
|
||||
siteId?: number;
|
||||
}>();
|
||||
|
||||
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 colorParams = computed(() => params.value.filter(p => p.type === "color"));
|
||||
const otherParams = computed(() => params.value.filter(p => p.type !== "color"));
|
||||
const badKeys = computed(() =>
|
||||
props.theme ? invalidParamKeys(props.theme, props.modelValue) : []
|
||||
);
|
||||
const modifiedCount = computed(() => params.value.filter(p => isModified(p.key)).length);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** 是否偏离包内声明的默认值(= 会被写进 .meta paramValues 的那些项) */
|
||||
function isModified(key: string): boolean {
|
||||
const p = params.value.find(x => x.key === key);
|
||||
if (!p) return false;
|
||||
return displayValue(key) !== (p.defaultValue ?? "");
|
||||
}
|
||||
|
||||
function resetOne(key: string) {
|
||||
const p = params.value.find(x => x.key === key);
|
||||
setValue(key, p?.defaultValue ?? "");
|
||||
}
|
||||
|
||||
/** 整体重置 = 提交全默认值模型(父级据此给后端空 Map,恢复 theme.json 默认) */
|
||||
function resetAll() {
|
||||
const next: FormModelValue = {};
|
||||
params.value.forEach(p => (next[p.key] = p.defaultValue ?? ""));
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
function isBad(key: string): boolean {
|
||||
return badKeys.value.includes(key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!theme" class="empty-tip">请先选择皮肤包</div>
|
||||
<div v-else class="theme-param-form">
|
||||
<div class="param-head">
|
||||
<span class="param-head-title">参数微调 · {{ themeName }}</span>
|
||||
<span v-if="badKeys.length" class="param-warn">
|
||||
{{ badKeys.length }} 个非法值未生效(保存时将回退皮肤默认)
|
||||
</span>
|
||||
<span class="param-head-tip">
|
||||
{{ modifiedCount ? `已修改 ${modifiedCount} 项` : "未修改(全部为包内默认值)" }}
|
||||
· 保存后引用该包的站点门户立即生效
|
||||
</span>
|
||||
<Button v-if="modifiedCount" size="small" ghost class="reset-all" @click="resetAll">整体恢复默认</Button>
|
||||
</div>
|
||||
|
||||
<!-- color:色卡网格(横排) -->
|
||||
<div v-if="colorParams.length" class="param-grid">
|
||||
<div
|
||||
v-for="p in colorParams"
|
||||
:key="p.key"
|
||||
class="param-chip"
|
||||
:class="{ bad: isBad(p.key), modified: isModified(p.key) }"
|
||||
:title="p.key"
|
||||
>
|
||||
<ColorPicker
|
||||
:model-value="displayValue(p.key)"
|
||||
recommend
|
||||
@on-change="(c: string) => setValue(p.key, c)"
|
||||
/>
|
||||
<span class="chip-name">
|
||||
{{ p.label }}
|
||||
<span v-if="isModified(p.key)" class="chip-flag">已修改</span>
|
||||
</span>
|
||||
<span class="chip-hex">{{ displayValue(p.key) }}</span>
|
||||
<Button
|
||||
v-if="isModified(p.key)"
|
||||
size="small"
|
||||
ghost
|
||||
class="chip-reset"
|
||||
@click="resetOne(p.key)"
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- select / image / text:两列行式 -->
|
||||
<div v-if="otherParams.length" class="param-rows">
|
||||
<div v-for="p in otherParams" :key="p.key" class="param-row" :class="{ bad: isBad(p.key) }">
|
||||
<label class="row-label">
|
||||
{{ p.label }}
|
||||
<span v-if="isModified(p.key)" class="chip-flag">已修改</span>
|
||||
</label>
|
||||
<div class="row-control">
|
||||
<Select
|
||||
v-if="p.type === 'select'"
|
||||
:model-value="displayValue(p.key)"
|
||||
size="small"
|
||||
transfer
|
||||
@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>
|
||||
<ImageAssetField
|
||||
v-else-if="p.type === 'image'"
|
||||
:model-value="displayValue(p.key)"
|
||||
:site-id="siteId"
|
||||
placeholder="/cms/upload/cms/... (上传或粘贴路径,仅接受 /cms/upload/** 与 /yschcms/upload/**)"
|
||||
@update:model-value="(v: string) => setValue(p.key, v)"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:model-value="displayValue(p.key)"
|
||||
size="small"
|
||||
:maxlength="256"
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
/>
|
||||
<Button v-if="isModified(p.key)" size="small" ghost @click="resetOne(p.key)">恢复默认</Button>
|
||||
<span v-if="isBad(p.key)" class="bad-mark">非法值({{ p.type }} 校验失败)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.empty-tip {
|
||||
color: #8a93a6;
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.theme-param-form {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.param-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
.param-head-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e2635;
|
||||
}
|
||||
.param-head-tip {
|
||||
color: #8a93a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.param-warn {
|
||||
color: #e54d42;
|
||||
font-size: 12px;
|
||||
}
|
||||
.reset-all {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* color 色卡网格:横排自动换行(150px 起步,宽屏一行 4~5 张) */
|
||||
.param-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.param-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e9edf5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
&:hover {
|
||||
border-color: #b9c8f7;
|
||||
box-shadow: 0 4px 12px -8px rgba(87, 122, 251, 0.45);
|
||||
}
|
||||
&.bad {
|
||||
border-color: #e54d42;
|
||||
}
|
||||
&.modified {
|
||||
border-color: #ffd591;
|
||||
background: #fffdf7;
|
||||
}
|
||||
.chip-name {
|
||||
font-size: 12px;
|
||||
color: #1e2635;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip-hex {
|
||||
font-size: 11px;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
color: #8a93a6;
|
||||
}
|
||||
.chip-reset {
|
||||
align-self: flex-start;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
/* ColorPicker 触发器整条拉宽成色条 */
|
||||
:deep(.ivu-color-picker) {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
:deep(.ivu-color-picker .ivu-input-wrapper) {
|
||||
display: block;
|
||||
}
|
||||
:deep(.ivu-color-picker .ivu-input) {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* 「已修改」小徽标:色卡与行式 label 共用 */
|
||||
.chip-flag {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 0 5px;
|
||||
border-radius: 7px;
|
||||
background: #fff7e6;
|
||||
color: #d46b08;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* select/image/text:两列行式 */
|
||||
.param-rows {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 8px 18px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
&.bad .row-label {
|
||||
color: #e54d42;
|
||||
}
|
||||
.row-label {
|
||||
flex: none;
|
||||
width: 76px;
|
||||
font-size: 13px;
|
||||
color: #1e2635;
|
||||
text-align: right;
|
||||
}
|
||||
.row-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.ivu-select,
|
||||
.ivu-input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
:deep(.image-asset-field) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.bad-mark {
|
||||
color: #e54d42;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ThemeVo } from "@/types/cms";
|
||||
import { assetUrl } from "@/api/module/cms";
|
||||
|
||||
/**
|
||||
* 主题卡片单选(方案 §6.3):建站向导①与站点编辑共用。
|
||||
* 展示 screenshot + 名称/描述;选中高亮;卡片布局在父级控制宽度。
|
||||
* 截图 src 经 assetUrl 补 serviceHost 前缀(生产 context-path=/yschcms,裸 /api 会 404 破图)。
|
||||
*/
|
||||
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="assetUrl(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>
|
||||