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