init local code

This commit is contained in:
chenxing
2026-09-07 09:11:37 +08:00
commit a3a75c3680
112 changed files with 24837 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
import { createAlova, Method, StatesHook } from "alova";
import fetchAdapter from "alova/fetch";
import VueHook from "alova/vue";
import Utils from "..";
/* ============= 类型定义 ============= */
export interface RequestHooks {
/** 前置票据/通用头注入 */
injectHeaders?: (method: Method) => void;
/** 加载开始 */
onLoadingStart?: () => void;
/** 加载结束 */
onLoadingEnd?: () => void;
/** 业务错误提示(code !== 0)*/
onBizError?: (msg: string) => void;
/** http 错误提示(status !== 200)*/
onHttpError?: (msg: string) => void;
}
/** 和后端约定的通用响应格式 */
export interface BschResponse<T = any> {
code: number;
msg?: string;
data: T;
urlToRedirectTo?: string;
}
/* ============= 默认空实现 ============= */
const defHooks: Required<RequestHooks> = {
injectHeaders: () => {},
onLoadingStart: () => {},
onLoadingEnd: () => {},
onBizError: () => {},
onHttpError: () => {},
};
/* ============= 错误处理(原 axios 逻辑平移) ============= */
function errorHandle<T>(res: BschResponse<T>, status: number): T {
// http 层错误
if (status !== 200) {
let msg = "网络错误";
switch (status) {
case 401:
case 403:
break;
case 404:
msg = "请求的资源不存在!";
break;
default:
msg = res?.msg || "内部服务错误!";
}
throw new Error(msg);
}
// 业务层错误
switch (res.code) {
case 0:
return res.data ?? (null as T);
case 302:
console.log(res);
// 后端 CasSsoApiRedirectFilter 已在 service 中拼好 ;redirectToReferer=<hex>,
// 直接跳转即可;登录后由 CasSsoRefererRedirectFilter 解码 302 回前端页。
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,
// 全局 onBizError 与各页面的 catch 均不再触发(不弹 redirect 报错)
window.location.href = res.urlToRedirectTo!;
return new Promise(() => {}) as T;
case 4:
throw new Error(res.msg || "参数错误!");
default:
throw new Error(res.msg || "业务错误");
}
}
/* ============= 工厂函数 ============= */
export function createBschAlova(
baseURL: string,
hooks: RequestHooks = {},
statesHook: StatesHook<any> = VueHook,
) {
const { injectHeaders, onLoadingStart, onLoadingEnd, onBizError, onHttpError } = {
...defHooks,
...hooks,
};
let reqCount = 0;
let timer: ReturnType<typeof setTimeout>;
/* ---- 请求拦截 ---- */
function beforeRequest(method: Method) {
reqCount++;
if (reqCount === 1) timer = setTimeout(() => onLoadingStart(), 800);
Promise.resolve().then(() => {
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
});
// 1. 用户注入票据/角色等头
injectHeaders(method);
method.config.credentials = "include";
// 2. 通用头
method.config.headers = Object.assign(method.config.headers || {}, {
yschapirequest: "1",
timeout: 20000,
yschreferer: Utils.strToHexCharCode(window.location.href),
});
}
/* ---- 响应拦截 ---- */
async function onSuccess(response: Response) {
reqCount--;
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
const res: BschResponse = await response.json();
try {
return errorHandle(res, response.status);
} catch (e: any) {
console.log(e);
onBizError(e.message);
return Promise.reject(e);
}
}
async function onError(err: any) {
reqCount = 0;
clearTimeout(timer);
onLoadingEnd();
const msg = err?.message || "网络请求出错";
onHttpError(msg);
return Promise.reject(err);
}
async function onComplete(method: Method) {
if (method.fromCache) {
reqCount--;
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
}
}
return createAlova({
statesHook,
requestAdapter: fetchAdapter(),
baseURL,
cacheFor: null,
beforeRequest,
responded: { onSuccess, onError, onComplete },
});
}
+40
View File
@@ -0,0 +1,40 @@
// @/utils/alova-iview.ts
import { createBschAlova } from "./alova-core";
import { Message } from "view-ui-plus";
import conf from "@/assets/config/host.config";
/* 全局 loading 遮罩实例 */
let spinInstance: any = null;
/* 主动关闭 loading */
function closeLoading() {
if (spinInstance) {
Message.destroy();
spinInstance = null;
}
}
const baseUrl = conf.serviceHost;
/* 创建带 View Design UI 的 Alova 实例 */
export const alovaInstance = createBschAlova(baseUrl, {
injectHeaders(method) {
// currentRole: encodeURI(JSON.stringify({ userId, roleId, schId: schoolId })),
if (localStorage.getItem("currentRole") != null) {
method.config.headers["currentRole"] = localStorage.getItem("currentRole") as string;
}
},
/* 2. 加载动画 – View Design Spin */
onLoadingStart() {
spinInstance = Message.loading("加载中...");
},
onLoadingEnd: closeLoading,
onBizError(msg: string) {
if (!!msg) Message.error(msg);
},
onHttpError(msg: string) {
Message.error({ title: "网络错误", content: msg });
},
});
+19
View File
@@ -0,0 +1,19 @@
import { App } from "vue";
import realImg from "./real-img";
// 自动导入当前文件夹下的所有自定义指令(默认导出项)
export default (app: App<Element>) => {
realImg(app);
};
// const modules = import.meta.glob("../directive/**/**.ts");
// for (const path in modules) {
// // 排除当前文件
// if (path !== "../directive/index.ts") {
// modules[path]().then(mod => {
// mod.default(app);
// });
// }
// }
+41
View File
@@ -0,0 +1,41 @@
import type { App } from "vue";
export default (app: App<Element>): void => {
app.directive("real-img", {
async mounted(el, binding, vnode: any) {
console.log(el);
if (el.getAttribute("data-load") == null) el.classList.add("img-hide");
let imgURL = binding.value; //获取图片地址
if (imgURL) {
let exist = await imageIsExist(imgURL);
if (exist) {
el.setAttribute("src", imgURL);
el.setAttribute("data-load", "1");
}
}
el.classList.remove("img-hide");
},
});
};
/**
* 检测图片是否存在
* @param url
*/
let imageIsExist = function (url: string) {
return new Promise(resolve => {
var img: HTMLImageElement | null = new Image();
img.onload = function () {
if ((this as any).complete == true) {
resolve(true);
img = null;
}
};
img.onerror = function () {
resolve(false);
img = null;
};
img.src = url;
});
};
+4
View File
@@ -0,0 +1,4 @@
import { ref } from "vue";
export function useCompRef<T extends abstract new (...args: any) => any>(_comp: T) {
return ref<InstanceType<T>>();
}
+7
View File
@@ -0,0 +1,7 @@
import { VueGlobal } from "@/types/Basics";
import { ComponentInternalInstance, getCurrentInstance } from "vue";
export default function useCurrentInstance(): VueGlobal {
const { appContext } = getCurrentInstance() as ComponentInternalInstance;
const proxy = appContext.config.globalProperties;
return proxy as unknown as VueGlobal;
}
+5
View File
@@ -0,0 +1,5 @@
type PathType = "image" | "icons";
export const requireImg = (imgPath: string, basePath?: PathType) => {
basePath = basePath || "image";
return new URL(`../../assets/${basePath}/${imgPath}`, import.meta.url).href;
};
+37
View File
@@ -0,0 +1,37 @@
import { ref, watch, type Ref } from "vue";
/**
* 将任意响应式数据与 sessionStorage 双向绑定
* @param key sessionStorage 的 key
* @param def 默认值(当 sessionStorage 没有时)
* @param reviver 可选:解析时用 JSON.parse 的第二个参数
* @returns Ref<T>
*/
export function useSessionStorage<T>(
key: string,
def: T,
reviver?: (this: any, key: string, value: any) => any,
): Ref<T> {
/* ---------- 初始化 ---------- */
const raw = sessionStorage.getItem(key);
let init: T = def;
if (raw) {
try {
init = JSON.parse(raw, reviver);
} catch {
/* 解析失败回退默认值 */
}
}
const state = ref<T>(init) as Ref<T>;
/* ---------- 变化时落盘 ---------- */
watch(
state,
newVal => {
sessionStorage.setItem(key, JSON.stringify(newVal));
},
{ deep: true, flush: "post" },
);
return state;
}
+347
View File
@@ -0,0 +1,347 @@
import { IObj, UseTableSetting } from "@/types/Basics";
import { resolveComponent } from "vue";
export default class Utils {
/**
* 格式化日期
*
* @param {(Date | string)} 日期
* @param {string} fmt 格式化字符串
* @returns {string} 日期字符串
* @memberof Utils
*/
public static dateFormat(date: Date | string, fmt: string): string {
if (typeof date === "string") date = date.replace(/\-/g, "/");
let dt = new Date(date);
let o: IObj = {
"M+": dt.getMonth() + 1, //月份
"d+": dt.getDate(), //日
"H+": dt.getHours(), //小时
"m+": dt.getMinutes(), //分
"s+": dt.getSeconds(), //秒
"q+": Math.floor((dt.getMonth() + 3) / 3), //季度
S: dt.getMilliseconds(), //毫秒
};
if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (dt.getFullYear() + "").substr(4 - RegExp.$1.length));
for (let k in o)
if (new RegExp("(" + k + ")").test(fmt))
fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : ("00" + o[k]).substr(("" + o[k]).length));
return fmt;
}
/**
* 获取地址栏指定参数
*/
public static getUrlParam(name: string): string | null {
let reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
let r = window.location.search.substr(1).match(reg);
if (r != null) return unescape(r[2]);
else return null;
}
/**
* 生成uuid
*/
public static uuid(): string {
return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, function (c) {
let r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* 返回文件大小
*/
public static getFileSize(size: number): string {
if (!size) return "";
if (size / 1024 < 1024) {
size = Math.ceil(size / 1024);
return size + "KB";
} else if (size / (1024 * 1024) < 1024) {
size = Math.ceil((size / (1024 * 1024)) * 100) / 100;
return size + "MB";
} else if (size / (1024 * 1024 * 1024) < 1024) {
size = Math.ceil((size / (1024 * 1024 * 1024)) * 100) / 100;
return size + "GB";
} else return "";
}
/**
* 删除指定键
*/
public static delKey(obj: IObj, ...args: string[]) {
args.forEach(v => delete obj[v]);
return obj;
}
/**
* iview Tooltip封装
*/
public static operatingHandle(
h: any,
info: string,
iconClass: string,
callback: Function,
disabled: boolean = false,
) {
if (disabled) iconClass += " disabled";
return h(
resolveComponent("Tooltip"),
{
content: info,
placement: "bottom",
transfer: true,
},
{
default: () => [
h(
"div",
{
class: `operating-btn iconfont ${iconClass}`,
onClick: callback,
},
null,
),
],
},
);
}
public static operatingTextHandle(
h: any,
info: string,
className: string,
callback: Function,
disabled: boolean = false,
) {
if (disabled) className += " disabled";
return h(
"div",
{
class: `operating-link ${className}`,
onClick: disabled ? null : callback,
},
info,
);
}
/**
* 计算表格高度
*/
public static useTableHeight({
isSearch = false,
isPage = false,
isTabs = false,
isOperat = false,
otherH = 0,
} = {}) {
let bodyH = document.body.clientHeight,
headerH = 112,
searchH = 52,
operatH = 48,
tabsH = 56,
pageH = 72,
paddingH = 8;
try {
//@ts-ignore 平台注入的全局对象,独立运行时不存在
const bschUtil = BschLoadUtil.getInstance();
if (!bschUtil.getIsSelf()) headerH = 56;
} catch (e) {
/* 独立运行:保留自身 header(56) + 面包屑(56) = 112 */
}
let tableH = bodyH - headerH - paddingH;
isOperat && (tableH -= operatH);
isTabs && (tableH -= tabsH);
isSearch && (tableH -= searchH);
isPage && (tableH -= pageH);
otherH > 0 && (tableH -= otherH);
return tableH;
}
/**
* 数字转大写汉字
* @param num
* @returns
*/
public static convertToChinaNum(num: number) {
var arr1 = new Array("零", "一", "二", "三", "四", "五", "六", "七", "八", "九");
var arr2 = new Array(
"",
"十",
"百",
"千",
"万",
"十",
"百",
"千",
"亿",
"十",
"百",
"千",
"万",
"十",
"百",
"千",
"亿",
); //可继续追加更高位转换值
if (!num || isNaN(num)) {
return "零";
}
var english = num.toString().split("");
var result = "";
for (var i = 0; i < english.length; i++) {
var des_i = english.length - 1 - i; //倒序排列设值
result = arr2[i] + result;
var arr1_index: any = english[des_i];
result = arr1[arr1_index] + result;
}
//将【零千、零百】换成【零】 【十零】换成【十】
result = result.replace(/零(千|百|十)/g, "零").replace(/十零/g, "十");
//合并中间多个零为一个零
result = result.replace(/零+/g, "零");
//将【零亿】换成【亿】【零万】换成【万】
result = result.replace(/零亿/g, "亿").replace(/零万/g, "万");
//将【亿万】换成【亿】
result = result.replace(/亿万/g, "亿");
//移除末尾的零
result = result.replace(/零+$/, "");
//将【零一十】换成【零十】
//result = result.replace(/零一十/g, '零十');//貌似正规读法是零一十
//将【一十】换成【十】
result = result.replace(/^一十/g, "十");
return result;
}
/**
* 对象转url参数
* @param num
* @returns
*/
public static createUrl(url: string, query: any) {
var temp: any[] = [];
for (var k in query) {
if (query[k] != null) temp.push(k + "=" + query[k]);
}
return url + "?" + temp.join("&");
}
/**
* 获取当前角色信息
* @returns
*/
public static getCurrentRoleInfo<T>() {
let currentRole = localStorage.getItem("currentRole");
if (!currentRole) return null;
let { schId, roleId, childId, userId } = JSON.parse(decodeURI(currentRole ?? ""));
return { schId, roleId, childId, userId } as T;
}
/**
* tree对象转化为数组
* @param tree
* @returns
*/
public static treeToArray(tree: any[]): any[] {
return tree.reduce((res, item) => {
const { children, ...i } = item;
return res.concat(i, children && children.length ? this.treeToArray(children) : []);
}, []);
}
/**
* 数组转化为tree对象
* @param tree
* @returns
*/
public static arrayToTree(arr: any[], pid: number): any[] {
return arr
.filter(item => item.pid === pid)
.map(item => ({ ...item, children: this.arrayToTree(arr, item.id) }));
}
//字符串的16进制表达
public static strToHexCharCode(_str: string) {
if (_str === "") return "";
var hexCharCode: string[] = [];
for (var i = 0; i < _str.length; i++) {
var str = _str.charCodeAt(i).toString(16);
if (_str.length == 1) str += "0" + _str;
hexCharCode.push(str);
}
return hexCharCode.join("");
}
public static hexToString(hex: string) {
if (hex === "") return "";
// 处理奇数长度,补前导零
if (hex.length % 2 !== 0) {
hex = "0" + hex;
[[3]];
}
let str = "";
for (let i = 0; i < hex.length; i += 2) {
// 每两位解析为一个字符的Unicode值
const hexPair = hex.substr(i, 2);
const charCode = parseInt(hexPair, 16);
str += String.fromCharCode(charCode);
[[14, 16]];
}
return str;
}
/**
* 将颜色值HEX格式转换为rgb的格式
* @param {hex} hex 需要转换的rgb字符串
* @param {number} opacity 透明度
* @return {string} ;
*/
public static hexToRgb(hex: string, opacity: number = 1) {
let str = hex.replace("#", "");
if (str.length % 3) {
return "hex格式不正确!";
}
//获取截取的字符长度
let count = str.length / 3;
//根据字符串的长度判断是否需要 进行幂次方
let power = 6 / str.length;
let r = parseInt("0x" + str.substring(0 * count, 1 * count)) ** power;
let g = parseInt("0x" + str.substring(1 * count, 2 * count)) ** power;
let b = parseInt("0x" + str.substring(2 * count)) ** power;
return `rgb(${r}, ${g}, ${b},${opacity})`;
}
public static removeHtmlTag(str: string) {
str = str.replace(/<\/?[^>]*>/g, ""); //去除HTML tag
str = str.replace(/[ | ]*\n/g, "\n"); //去除行尾空白
str = str.replace(/\n[\s| | ]*\r/g, "\n"); //去除多余空行
str = str.replace(/ /gi, ""); //去掉
return str;
}
public static unpackPerms = (bits: number) => [
!!(bits & 1), // create
!!(bits & 2), // update
!!(bits & 4), // move
!!(bits & 8), // delete
];
public static copyToClipboard(text: string) {
const input = document.createElement("input");
input.setAttribute("value", text);
document.body.appendChild(input);
input.select();
try {
const successful = document.execCommand("copy");
if (successful) {
return Promise.resolve();
} else {
return Promise.reject();
}
} catch (err) {
return Promise.reject(err);
} finally {
document.body.removeChild(input);
}
}
}
+199
View File
@@ -0,0 +1,199 @@
import { RouteRecordRaw } from "vue-router";
import { routes } from "@/router/routes";
import { useCmsStore } from "@/store";
/**
* 平台模块配置 → 前端内部路由的统一解析器(Notice 同款平移)。
*
* 基础平台里配置的模块 path 格式不可控(可能带旧前缀、带 index.html#/hash、
* 是应用入口地址甚至完整 URL、或干脆配错),直接 router.push(item.page) 匹配
* 不到就会白屏或被重定向兜到默认页。所有消费模块 page 的地方(侧边栏跳转/
* 高亮)必须先经过这里。
*
* 学校化管理(docs/09 §3.3)后页面全部挂在 /sch/:schCode 嵌套路由下:
* 平台模块 path 仍配置业务路径(如 /article),解析时匹配子路径形态,
* 并在运行时用当前学校上下文(store.schoolCode)拼出 /sch/{code}/article。
* 解析顺序:路径精确匹配 → 模块名匹配 → 外链判定 → modId 契约匹配。
* 全部落空时返回空结果并 console.warn,绝不返回未注册的原始路径。
*/
interface RouteEntry {
/** 顶层路由为绝对 path;/sch/:schCode 子路由为相对子路径(如 /article) */
sub: string;
absolute: boolean;
title?: string;
modId?: number;
}
/** 可解析路由清单(含嵌套 children;重定向路由穿透跳过,参数化叶子跳过) */
const ROUTE_ENTRIES: RouteEntry[] = [];
(function walk(rs: RouteRecordRaw[], underSchool: boolean) {
rs.forEach(r => {
const raw = r.path || "";
const isSchoolRoot = raw === "/sch/:schCode";
if (raw.includes(":") && !isSchoolRoot && !underSchool) {
return;
}
const nextUnder = underSchool || isSchoolRoot;
const sub = nextUnder && !raw.startsWith("/") ? "/" + raw : raw;
if (!r.redirect && r.component) {
const meta: any = r.meta || {};
ROUTE_ENTRIES.push({ sub, absolute: !nextUnder, title: meta.title, modId: meta.modId });
}
if (r.children && r.children.length) {
walk(r.children, nextUnder);
}
});
})(routes, false);
/** 顶层绝对 path 集合(重定向路由已穿透跳过) */
const ABSOLUTE_PATHS = ROUTE_ENTRIES.filter(e => e.absolute).map(e => e.sub);
/** 标题 → 路由(重复取先注册的) */
const ROUTE_BY_TITLE = new Map<string, RouteEntry>();
for (const e of ROUTE_ENTRIES) {
if (e.title && !ROUTE_BY_TITLE.has(e.title)) ROUTE_BY_TITLE.set(e.title, e);
}
/** modId → 路由(取该 modId 的第一个非重定向路由) */
const ROUTE_BY_MODID = new Map<number, RouteEntry>();
for (const e of ROUTE_ENTRIES) {
if (e.modId != null && !ROUTE_BY_MODID.has(e.modId)) ROUTE_BY_MODID.set(e.modId, e);
}
/** 平台历史模块名 → 路由(旧菜单叫法与现页面标题不一致的对照) */
const NAME_ALIASES: Record<string, string> = {};
const EXTERNAL_RE = /^(https?:)?\/\//i;
/** 从平台原始配置里提取内部路由候选串(去掉 hash、query、尾斜杠) */
function extractCandidate(raw: string): string {
let p = raw.trim();
try {
p = decodeURIComponent(p);
} catch {
/* 含非法编码时保持原样 */
}
// 带 hash 的地址(.../index.html#/setting):取 # 后的 SPA 路由
const hashIdx = p.indexOf("#");
if (hashIdx >= 0) {
p = p.slice(hashIdx + 1);
} else if (EXTERNAL_RE.test(p)) {
// 无 hash 的完整 URL:取 pathname(如 https://host/setting)
try {
p = new URL(p, "http://placeholder.local").pathname;
} catch {
/* 保持原样 */
}
}
const qIdx = p.indexOf("?");
if (qIdx >= 0) p = p.slice(0, qIdx);
// 入口文件名去掉后即可命中根重定向路由
p = p.replace(/index\.html?$/i, "");
p = p.replace(/\/+$/, "");
if (p && !p.startsWith("/")) p = "/" + p;
return p;
}
/** 相对(学校壳内)子路径 → 具体路径:运行时拼当前学校编码;无学校上下文返回 null */
function toConcrete(entry: RouteEntry): string | null {
if (entry.absolute) {
return entry.sub;
}
const code = useCmsStore().schoolCode;
return code ? `/sch/${code}${entry.sub}` : null;
}
/** 候选串 → 已注册路由 path;未命中返回 null */
function matchRoute(candidate: string): string | null {
if (!candidate) return null;
const lower = candidate.toLowerCase();
// 1. 绝对路径精确匹配
const exact = ABSOLUTE_PATHS.find(p => p.toLowerCase() === lower);
if (exact) return exact;
// 2. 学校壳内子路径匹配(/article → /sch/{code}/article),兼容旧 /cms 前缀
const stripped = lower.startsWith("/cms/") ? lower.slice("/cms".length) : lower;
const entry = ROUTE_ENTRIES.find(e => !e.absolute && e.sub.toLowerCase() === stripped);
if (entry) {
return toConcrete(entry);
}
return null;
}
/** 按模块名解析路由:精确标题 → 历史别名 → 双向包含(命中多个则放弃) */
function matchByName(name?: string | null): RouteEntry | null {
const n = String(name ?? "").trim();
if (!n) return null;
const byTitle = ROUTE_BY_TITLE.get(n);
if (byTitle) return byTitle;
const alias = NAME_ALIASES[n];
if (alias) {
return ROUTE_ENTRIES.find(e => e.sub === alias && e.absolute) || null;
}
const hits = ROUTE_ENTRIES.filter(e => {
const label = e.title || e.sub;
return label.length >= 2 && (n.includes(label) || label.includes(n));
});
return hits.length === 1 ? hits[0] : null;
}
/** 是否为本应用入口类地址(/、/cms、…/index.html):只表示"打开应用",
* 不携带页面信息,不能当作有效跳转目标 */
function isAppEntryCandidate(candidate: string): boolean {
return !candidate || candidate === "/" || candidate === "/cms";
}
export interface ResolvedMenuPage {
/** 内部路由 path;解析不出时为空串 */
path: string;
/** 外部链接(完整 URL 且不属于本应用路由),需整页跳转而非 router.push */
external: boolean;
}
/** 空配置的统一返回值 */
export const EMPTY_MENU_PAGE: ResolvedMenuPage = { path: "", external: false };
export function resolveMenuPage(
page?: string | null,
modId?: number | null,
name?: string | null
): ResolvedMenuPage {
const raw = String(page ?? "").trim();
const byName = (): RouteEntry | null => matchByName(name);
const byModId = (): RouteEntry | null =>
modId != null ? ROUTE_BY_MODID.get(modId) ?? null : null;
if (raw) {
const candidate = extractCandidate(raw);
const byPath = matchRoute(candidate);
if (byPath) return { path: byPath, external: false };
// 完整 URL:指向本应用入口的不算有效配置(继续用名字/modId 识别),
// 其余按外部链接整页打开
if (EXTERNAL_RE.test(raw) && !raw.includes("#")) {
if (!isAppEntryCandidate(candidate)) {
console.warn(`[menuRoute] 模块「${name ?? modId}」page 配置为外部链接: ${raw}`);
return { path: raw, external: true };
}
}
if (candidate && !isAppEntryCandidate(candidate)) {
console.warn(`[menuRoute] 模块「${name ?? modId}」page「${raw}」未匹配到路由,回退名称/modId 识别`);
}
}
const byNameHit = byName();
if (byNameHit) {
const path = toConcrete(byNameHit);
if (path) return { path, external: false };
}
const byModIdHit = byModId();
if (byModIdHit) {
const path = toConcrete(byModIdHit);
if (path) return { path, external: false };
}
if (raw && !isAppEntryCandidate(extractCandidate(raw))) {
console.warn(`[menuRoute] 模块「${name ?? modId}」无法解析 page「${raw}」,菜单项将不可跳转`);
}
return EMPTY_MENU_PAGE;
}
+15
View File
@@ -0,0 +1,15 @@
// import { BschIframeType, LightAppModal, IframePageParmas, RoleInfo, StateEnum } from "@/types";
import mitt, { Emitter } from "mitt";
type MittType<T = any> = {
reload: void; //刷新页面
setMenuPath: number; //选择左侧菜单
resetMenuPath: any[]; //重置设置左侧菜单
setNavPath: any[]; //设置面包屑
resetLayout: { identity: number; modId: number }; //重置页面菜单
};
// 类型
const emitter: Emitter<MittType> = mitt<MittType>();
// 导出
export default emitter;
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import {
EXTRA_KEY_RE,
SITE_CONFIG_FIELD_DEFS,
extraRowError,
firstExtraError,
parseSiteConfig,
serializeSiteConfig,
validateFieldValue,
} from "./siteConfigForm";
/** docs/09 §3.1 站点配置结构化:parse/serialize/校验 */
describe("siteConfigForm", () => {
it("parse:已知键入 fields、未知字符串键入扩展行、theme_params/对象键无损入 preserved", () => {
const json = JSON.stringify({
logo: "/cms/upload/logo.png",
contact: "电话 0571-88888888",
domain: "www.xxx.edu.cn",
public_base_url: "https://www.xxx.edu.cn",
my_ext: "自定义值",
theme_params: { primary: "#b01a2e" },
wechat: { appid: "wx123" },
});
const m = parseSiteConfig(json);
expect(m.fields.logo).toBe("/cms/upload/logo.png");
expect(m.fields.contact).toBe("电话 0571-88888888");
expect(m.fields.domain).toBe("www.xxx.edu.cn");
expect(m.fields.public_base_url).toBe("https://www.xxx.edu.cn");
expect(m.extras).toEqual([{ key: "my_ext", value: "自定义值" }]);
expect(m.preserved.theme_params).toEqual({ primary: "#b01a2e" });
expect(m.preserved.wechat).toEqual({ appid: "wx123" });
});
it("parse:非法 JSON / 空值按空配置处理,已知键非字符串值无损保留", () => {
expect(parseSiteConfig("这不是json{").fields).toEqual({});
expect(parseSiteConfig(null).extras).toEqual([]);
expect(parseSiteConfig("").preserved).toEqual({});
const m = parseSiteConfig(JSON.stringify({ logo: 123, other: [1, 2] }));
expect(m.fields.logo).toBeUndefined();
expect(m.preserved.logo).toBe(123);
expect(m.preserved.other).toEqual([1, 2]);
});
it("serialize:空值剔除、扩展行空键/空值剔除、preserved 永不丢失", () => {
const m = parseSiteConfig(JSON.stringify({ logo: "/cms/upload/logo.png", theme_params: { primary: "#fff" }, wechat: { x: 1 } }));
m.fields.contact = " "; // 空白 → 剔除
m.extras.push({ key: "k1", value: "v1" });
m.extras.push({ key: "", value: "被剔除" });
m.extras.push({ key: "k2", value: " " }); // 空值 → 剔除
const out = JSON.parse(serializeSiteConfig(m));
expect(out.logo).toBe("/cms/upload/logo.png");
expect(out).not.toHaveProperty("contact");
expect(out.k1).toBe("v1");
expect(out).not.toHaveProperty("k2");
expect(out.theme_params).toEqual({ primary: "#fff" });
expect(out.wechat).toEqual({ x: 1 });
});
it("roundtrip:parse→serialize→parse 内容不丢(含扩展行与 preserved)", () => {
const json = JSON.stringify({
copyright: "XX学校 · 版权所有 © 2026",
seo_description: "校讯",
my_ext: "v",
theme_params: { bg: "#f7f5f2" },
});
const once = parseSiteConfig(json);
once.fields.slogan = "厚德博学";
const twice = parseSiteConfig(serializeSiteConfig(once));
expect(twice.fields.slogan).toBe("厚德博学");
expect(twice.fields.copyright).toBe("XX学校 · 版权所有 © 2026");
expect(twice.extras).toEqual([{ key: "my_ext", value: "v" }]);
expect(twice.preserved.theme_params).toEqual({ bg: "#f7f5f2" });
});
it("validateFieldValue:url/host/asset/text 各类型通过与拒绝", () => {
const def = (kind: string, maxLength?: number) =>
SITE_CONFIG_FIELD_DEFS.find(d => d.key === { url: "public_base_url", host: "domain", asset: "logo", text: "copyright" }[kind])!;
expect(validateFieldValue(def("url"), "https://www.xxx.edu.cn")).toBe("");
expect(validateFieldValue(def("url"), "www.xxx.edu.cn")).toContain("http");
expect(validateFieldValue(def("host"), "www.xxx.edu.cn")).toBe("");
expect(validateFieldValue(def("host"), "https://www.xxx.edu.cn")).toContain("域名");
expect(validateFieldValue(def("asset"), "/cms/upload/a.png")).toBe("");
expect(validateFieldValue(def("asset"), "https://cdn.example.com/a.png")).toBe("");
expect(validateFieldValue(def("asset"), "javascript:alert(1)")).toContain("路径");
expect(validateFieldValue(def("text"), "正常文字")).toBe("");
expect(validateFieldValue({ key: "t", label: "t", group: "footer", kind: "text", maxLength: 5 }, "超过五个字的文案")).toContain("5");
expect(validateFieldValue(def("url"), "")).toBe(""); // 空值一律放行(非必填)
});
it("extraRowError:格式/重名/重复校验;空键行放行", () => {
const rows = [{ key: "my_ext", value: "v" }];
expect(extraRowError(rows, 0)).toBe("");
expect(extraRowError([{ key: "", value: "v" }], 0)).toBe("");
expect(extraRowError([{ key: "my-ext", value: "v" }], 0)).toContain("下划线");
expect(extraRowError([{ key: "1bad", value: "v" }], 0)).toContain("下划线");
expect(extraRowError([{ key: "logo", value: "v" }], 0)).toContain("重名");
expect(extraRowError([{ key: "theme_params", value: "v" }], 0)).toContain("重名");
expect(extraRowError([{ key: "a", value: "1" }, { key: "a", value: "2" }], 1)).toContain("重复");
expect(EXTRA_KEY_RE.test("wechat_appid")).toBe(true);
expect(EXTRA_KEY_RE.test("_priv")).toBe(true);
expect(EXTRA_KEY_RE.test("9bad")).toBe(false);
});
it("firstExtraError:返回第一条错误;全部合法返回空串", () => {
expect(firstExtraError([{ key: "ok_key", value: "1" }, { key: "bad-key", value: "2" }])).toContain("下划线");
expect(firstExtraError([{ key: "a", value: "1" }, { key: "b", value: "2" }])).toBe("");
expect(firstExtraError([])).toBe("");
});
it("已知键清单与 docs/09 §0 键盘点表一致(防漂移)", () => {
expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([
"logo", "favicon", "slogan", "colleges_label",
"contact", "copyright", "beian",
"seo_description",
"domain", "public_base_url",
]);
});
});
+204
View File
@@ -0,0 +1,204 @@
/**
* 站点配置结构化表单纯函数模块(docs/09 §3.1)。
* 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);未知字符串键 → 可编辑扩展行
* (Projman「添加项」模式);未知非字符串键(theme_params 等系统托管键)原样保留无损回写。
* vitest 单测对象:siteConfigForm.spec.ts,不依赖组件挂载基建。
*/
export type SiteConfigGroup = "display" | "footer" | "seo" | "domain";
export interface SiteConfigFieldDef {
key: string;
label: string;
group: SiteConfigGroup;
placeholder?: string;
tip?: string;
multiline?: boolean;
/** url: http(s) URL;host: 域名(不带协议);asset: / 开头路径或 http(s) URL;text: 仅长度 */
kind?: "text" | "url" | "host" | "asset";
maxLength?: number;
}
/** 与后端 SiteService.validateConfigJson 的已知键约束保持一致 */
export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
{
key: "logo", label: "站点 Logo", group: "display", kind: "asset", maxLength: 500,
placeholder: "如 /cms/upload/2026/06/logo.png 或 https://…",
tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。",
},
{
key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500,
placeholder: "如 /cms/upload/…/favicon.png(留空用主题内置图标)",
},
{
key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200,
placeholder: "门户顶栏展示的一句话标语(可选)",
},
{
key: "colleges_label", label: "院系下拉文案", group: "display", kind: "text", maxLength: 50,
placeholder: "留空默认「专业院系」",
tip: "门户右上「专业院系」下拉的文案;仅主站有效(下拉由院系子站生成)。",
},
{
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
},
{
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
placeholder: "如:XX学校 · 版权所有 © 2026",
},
{
key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100,
placeholder: "如:浙ICP备XXXXXXXX号-1",
},
{
key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true,
placeholder: "搜索引擎摘要(meta description,建议 80~160 字)",
},
{
key: "domain", label: "绑定域名", group: "domain", kind: "host", maxLength: 200,
placeholder: "如 www.xxx.edu.cn(不带 https://)",
tip: "学校独立域名台账;DNS 解析生效后需配合填写对外基地址。",
},
{
key: "public_base_url", label: "对外基地址", group: "domain", kind: "url", maxLength: 500,
placeholder: "如 https://www.xxx.edu.cn",
tip: "绑定独立域名后必填;未填时全站链接走 base 域子路径形态(/cms/portal/{站点编码})。",
},
];
export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [
{ name: "display", title: "基础展示" },
{ name: "footer", title: "页脚信息" },
{ name: "seo", title: "SEO" },
{ name: "domain", title: "域名与访问" },
];
/** 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护) */
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params"];
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
export const HOST_RE = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
export const URL_RE = /^https?:\/\/\S+$/;
export const ASSET_RE = /^(\/.+|https?:\/\/\S+)$/;
export interface SiteConfigExtraRow {
key: string;
value: string;
}
export interface SiteConfigFormModel {
fields: Record<string, string>;
extras: SiteConfigExtraRow[];
/** 系统托管键 + 非字符串未知键,无损保留 */
preserved: Record<string, unknown>;
}
/** configJson → 表单模型:已知键入 fields、未知字符串键入扩展行、其余无损入 preserved */
export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel {
const model: SiteConfigFormModel = { fields: {}, extras: [], preserved: {} };
if (!configJson || !configJson.trim()) {
return model;
}
let parsed: Record<string, unknown> | null = null;
try {
const obj = JSON.parse(configJson);
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
parsed = obj;
}
} catch {
// 非法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
}
if (!parsed) {
return model;
}
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
const reserved = new Set(RESERVED_SITE_CONFIG_KEYS);
Object.keys(parsed).forEach(k => {
const v = parsed![k];
if (typeof v !== "string") {
model.preserved[k] = v;
} else if (known.has(k)) {
model.fields[k] = v;
} else if (reserved.has(k)) {
model.preserved[k] = v;
} else {
model.extras.push({ key: k, value: v });
}
});
return model;
}
/** 表单模型 → configJson:空值剔除;扩展行空键/空值剔除;preserved 原样回写(永不丢失) */
export function serializeSiteConfig(model: SiteConfigFormModel): string {
const out: Record<string, unknown> = {};
SITE_CONFIG_FIELD_DEFS.forEach(def => {
const v = (model.fields[def.key] || "").trim();
if (v) {
out[def.key] = v;
}
});
(model.extras || []).forEach(row => {
const k = (row.key || "").trim();
const v = (row.value || "").trim();
if (k && v) {
out[k] = v;
}
});
Object.keys(model.preserved || {}).forEach(k => {
out[k] = model.preserved[k];
});
return JSON.stringify(out);
}
/** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */
export function validateFieldValue(def: SiteConfigFieldDef, value: string): string {
const v = (value || "").trim();
if (!v) {
return "";
}
switch (def.kind) {
case "url":
return URL_RE.test(v) ? "" : "须为完整的 http(s):// 地址";
case "host":
return HOST_RE.test(v) ? "" : "须为域名形式(如 www.xxx.edu.cn,不带协议)";
case "asset":
return ASSET_RE.test(v) ? "" : "须为站点上传路径(/ 开头)或 http(s) 地址";
default:
return def.maxLength && v.length > def.maxLength ? `不能超过 ${def.maxLength} 字` : "";
}
}
/** 扩展行校验:键名格式 / 与内置键或系统键冲突 / 行间重复;返回错误消息,空串=通过(空键行视为待填) */
export function extraRowError(rows: SiteConfigExtraRow[], index: number): string {
const row = rows[index];
const key = (row?.key || "").trim();
if (!key) {
return "";
}
if (!EXTRA_KEY_RE.test(key)) {
return "键名仅限字母/数字/下划线,且以字母或下划线开头";
}
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
if (known.has(key) || RESERVED_SITE_CONFIG_KEYS.includes(key)) {
return "扩展键不能与内置键/系统键重名";
}
const dup = rows.findIndex(r => (r.key || "").trim() === key);
if (dup >= 0 && dup !== index) {
return "扩展键重复";
}
return "";
}
/** 保存前兜底:返回第一条扩展行错误,空串=全部通过 */
export function firstExtraError(rows: SiteConfigExtraRow[]): string {
for (let i = 0; i < (rows || []).length; i++) {
const msg = extraRowError(rows, i);
if (msg) {
return msg;
}
}
return "";
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
COLOR_RE,
formModelFromTheme,
invalidParamKeys,
isValidParamValue,
mergeThemeParamsIntoConfigJson,
themeParamsFromConfigJson,
} from "./themeParamForm";
import { ThemeVo } from "@/types/cms";
/** 与后端 default/t-demo-red schema 对齐的夹具(§4.2) */
function theme(): ThemeVo {
return {
name: "t-demo-red",
title: "测试红(夹具)",
screenshotUrl: "/api/theme/t-demo-red/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#b01a2e" },
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#f7f5f2" },
{
key: "headerStyle",
label: "头部样式",
type: "select",
defaultValue: "banner",
options: [{ value: "banner", label: "横幅头图" }, { value: "plain", label: "简洁顶栏" }],
},
{ key: "logoImage", label: "头图", type: "image", defaultValue: "" },
],
};
}
describe("themeParamForm(方案 §6.3/§8.4)", () => {
it("T28: schema→表单模型:四类型各映射为对应生效值 + 默认值兜底", () => {
const model = formModelFromTheme(theme());
expect(model.primary).toBe("#b01a2e");
expect(model.bg).toBe("#f7f5f2");
expect(model.headerStyle).toBe("banner");
expect(model.logoImage).toBe("");
});
it("T28: 已有合法值保留,非法显式值回退默认", () => {
const model = formModelFromTheme(theme(), {
primary: "#123456", // 合法,保留
bg: "red;}", // 非法,回退默认
headerStyle: "plain", // select 枚举内合法
logoImage: "javascript:alert(1)", // 非法 image
});
expect(model.primary).toBe("#123456");
expect(model.bg).toBe("#f7f5f2");
expect(model.headerStyle).toBe("plain");
expect(model.logoImage).toBe("");
});
it("T29: 值校验 —— color 正则通过/拒绝", () => {
expect(COLOR_RE.test("#b01a2e")).toBe(true);
expect(COLOR_RE.test("#fff")).toBe(true);
expect(COLOR_RE.test("#b01a2eff")).toBe(true);
expect(COLOR_RE.test("red")).toBe(false);
expect(COLOR_RE.test("red;}")).toBe(false);
expect(COLOR_RE.test("#gggggg")).toBe(false);
expect(COLOR_RE.test("javascript:alert(1)")).toBe(false);
});
it("T29: select 枚举外拒绝;image 非 /cms/upload/** 拒绝;空值一律不合法", () => {
const t = theme();
const select = t.params.find(p => p.key === "headerStyle")!;
const image = t.params.find(p => p.key === "logoImage")!;
const color = t.params.find(p => p.key === "primary")!;
expect(isValidParamValue(select, "plain")).toBe(true);
expect(isValidParamValue(select, "hacker")).toBe(false);
// B-7:与应用真实路径对齐(context-path=/cms),旧 /upload/** 前缀不再放行
expect(isValidParamValue(image, "/cms/upload/cms/logo.png")).toBe(true);
expect(isValidParamValue(image, "/upload/cms/logo.png")).toBe(false);
expect(isValidParamValue(image, "https://evil/x.png")).toBe(false);
expect(isValidParamValue(color, "")).toBe(false);
expect(isValidParamValue(color, undefined)).toBe(false);
});
it("T29: invalidParamKeys 收集非法值 key", () => {
const bad = invalidParamKeys(theme(), {
primary: "#123456",
headerStyle: "not-in-enum",
});
expect(bad).toContain("headerStyle");
expect(bad).not.toContain("primary");
});
it("T30: themeParams↔configJson 装拆 —— 合并保留其余键 + 丢弃未声明键", () => {
const t = theme();
const merged = mergeThemeParamsIntoConfigJson(
'{"logo":"/upload/cms/logo.png","contact":"0571-88888888"}',
t,
{ primary: "#ff0000", headerStyle: "plain", zzhack: "zzvalue" },
);
const parsed = JSON.parse(merged);
expect(parsed.logo).toBe("/upload/cms/logo.png");
expect(parsed.contact).toBe("0571-88888888");
expect(parsed.theme_params.primary).toBe("#ff0000");
expect(parsed.theme_params.headerStyle).toBe("plain");
expect(parsed.theme_params).not.toHaveProperty("zzhack"); // 未声明 key 丢弃(后端同语义)
});
it("T30: 空/非法 configJson 兼容;无有效参数不写入 theme_params 键", () => {
const t = theme();
const merged = mergeThemeParamsIntoConfigJson("{bad json", t, {});
expect(JSON.parse(merged)).toEqual({});
const merged2 = mergeThemeParamsIntoConfigJson(
'{"theme_params":{"primary":"#b01a2e"},"logo":"x"}',
t,
{ headerStyle: "banner" },
);
const parsed2 = JSON.parse(merged2);
expect(parsed2.logo).toBe("x");
expect(parsed2.theme_params).toEqual({ headerStyle: "banner" }); // 旧的非法/未更新值被替换
});
it("T30: configJson → themeParams 编辑回显(仅声明 key + 非法值丢弃)", () => {
const t = theme();
const model = themeParamsFromConfigJson(
'{"theme_params":{"primary":"#b01a2e","zzhack":"x","headerStyle":"plain"}}',
t,
);
expect(model.primary).toBe("#b01a2e");
expect(model.headerStyle).toBe("plain");
expect(model.zzhack).toBeUndefined();
});
});
+123
View File
@@ -0,0 +1,123 @@
import { ThemeParamVo, ThemeVo } from "@/types/cms";
/**
* themeParams 纯函数模块(方案 §6.3,评审 R10/R6):schema → 表单模型映射、
* 值校验(color 正则 / select 枚举 / image 仅 /upload/**)、themeParams ↔ configJson 装拆。
* 与后端合并语义(方案 §5.3)保持一致:仅替换 theme_params 键、保留其余键、丢弃未声明键。
* vitest 单测对象(§8.4 T28-T30),不依赖组件挂载基建。
*/
/** color 合法值:#RGB/#RRGGBB/#RRGGBBAA(与后端 ThemeParam.COLOR_PATTERN 一致) */
export const COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
/** param key 约束(与后端 KEY_PATTERN 一致) */
export const KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,31}$/;
export interface FormModelValue {
[key: string]: string;
}
/** 单值校验(与后端 ThemeParam.isValidValue 同语义;null/空一律不合法) */
export function isValidParamValue(param: ThemeParamVo, value: string | undefined | null): boolean {
if (value === undefined || value === null || value === "") return false;
switch (param.type) {
case "color":
return COLOR_RE.test(value);
case "image":
// B-7:与后端对齐——附件 webUrl 相对形态含 context-path(/cms/upload/**)
return value.startsWith("/cms/upload/");
case "select":
return !!param.options?.some(o => o.value === value);
case "text":
return value.length <= 256;
default:
return false;
}
}
/** 取参数生效值:显式值合法用之;否则回退 defaultValue(缺省也视为合法默认) */
export function effectiveValue(param: ThemeParamVo, model: FormModelValue): string {
const raw = model[param.key];
if (isValidParamValue(param, raw)) return raw as string;
return param.defaultValue ?? "";
}
/** T28: schema → 表单模型(声明过的 key 全部带上生效值) */
export function formModelFromTheme(theme: ThemeVo, existing: FormModelValue = {}): FormModelValue {
const model: FormModelValue = {};
theme.params.forEach(p => {
const raw = existing[p.key];
model[p.key] = isValidParamValue(p, raw) ? (raw as string) : (p.defaultValue ?? "");
});
return model;
}
/** T28: 值校验兜底 —— 收集当前表单模型里的非法值 key(空值不报,默认值兜底) */
export function invalidParamKeys(theme: ThemeVo, model: FormModelValue): string[] {
const result: string[] = [];
theme.params.forEach(p => {
const v = model[p.key];
if (v !== undefined && v !== "" && !isValidParamValue(p, v)) {
result.push(p.key);
}
});
return result;
}
/** T30a: 服务端合并语义(与后端 §5.3 一致)—— 仅替换 theme_params、保留其余键、丢弃未声明/非法值 */
export function mergeThemeParamsIntoConfigJson(configJson: string | undefined | null,
theme: ThemeVo,
model: FormModelValue): string {
let config: Record<string, unknown> = {};
if (configJson && configJson.trim()) {
try {
const parsed = JSON.parse(configJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
config = { ...parsed };
}
} catch {
// 非合法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
}
}
const filtered: Record<string, string> = {};
theme.params.forEach(p => {
const v = model[p.key];
if (isValidParamValue(p, v)) {
filtered[p.key] = v as string;
}
});
if (Object.keys(filtered).length === 0) {
delete config.theme_params;
} else {
config.theme_params = filtered;
}
return JSON.stringify(config);
}
/** T30b: configJson → themeParams(编辑回显:仅取声明过的 key,非法值丢弃) */
export function themeParamsFromConfigJson(configJson: string | undefined | null,
theme: ThemeVo): FormModelValue {
const model: FormModelValue = {};
if (!configJson) return model;
try {
const parsed = JSON.parse(configJson);
const tp = parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>).theme_params
: undefined;
if (tp && typeof tp === "object") {
theme.params.forEach(p => {
const raw = (tp as Record<string, unknown>)[p.key];
const v = typeof raw === "string" ? raw : "";
if (isValidParamValue(p, v)) model[p.key] = v;
});
}
} catch {
// 非合法 JSON 按无参数处理
}
return model;
}
/** 主题是否声明了某类型参数(组件渲染分支用) */
export function hasParamType(theme: ThemeVo | undefined | null, type: string): boolean {
return !!theme?.params?.some(p => p.type === type);
}