Files
console-dev/app/lib/backend-menu.js
T

400 lines
12 KiB
JavaScript

/**
* 后端菜单到前端路由的适配层:兼容旧页面地址、限制可用路由和参数,并生成导航分组。
* 菜单可见性不是权限校验,所有实际操作仍须由后端按当前身份授权。
*/
// 合并旧账单入口与财务入口;全树最多保留一个由 /bills 转换得到的财务项。
function e(e) {
let t = (e) =>
e.some(
(e) =>
e.to === `/finance` ||
e.to === `/finance?tab=bills` ||
(e.children && t(e.children)),
),
n = t(e);
function r(e) {
return e.flatMap((e) => {
if (e.children) {
let t = r(e.children);
return t.length
? [
{
...e,
children: t,
},
]
: [];
}
return e.to?.split(`?`)[0] === `/bills`
? n
? []
: ((n = !0),
[
{
...e,
label: `财务`,
to: `/finance`,
},
])
: [e];
});
}
return r(e);
}
/** 路径长度和匹配 query 数量决定激活优先级,避免父菜单与更具体子菜单同时高亮。 */
function t(e, t, n = []) {
let r =
t.path === `/products/cvm`
? `/products/cloud-server`
: t.path === `/bills` || t.path.startsWith(`/bills/`)
? t.path.replace(`/bills`, `/finance`)
: t.path;
function i(e) {
let [n = ``, i = ``] = e.split(`?`);
if (r !== n && !r.startsWith(`${n}/`)) return -1;
let a = [...new URLSearchParams(i)];
return a.every(([e, n]) => String(t.query[e] ?? ``) === n)
? n.length * 100 + a.length
: -1;
}
let a = i(e);
return a >= 0 && !n.some((e) => i(e.to) > a);
}
// 只映射前端已实现的路由;旧版 .htm/插件入口必须显式登记,不透传任意路径。
var n = new Set(
`/./products./products/cloud-server./products/dedicated-server./cart./cloud-servers./dedicated-servers./security/groups./security/ssh-keys./security/domains./transfer./finance./orders./bills./invoices./invoices/settings./invoices/request./recommend./vouchers./account./account/api-keys./account/connections./account/realname./messages./logbook./tickets./tickets/create./help./notices./news./resources`.split(
`.`,
),
),
r = {
"index.htm": `/`,
"home.htm": `/`,
"cart/goodsList.htm": `/products`,
"cart/cart.htm": `/cart`,
"finance.htm": `/finance`,
"order.htm": `/orders`,
"account.htm": `/account`,
"security.htm": `/account/api-keys`,
"ticket.htm": `/tickets`,
"help.htm": `/help`,
"news.htm": `/news`,
"announcement.htm": `/notices`,
"api.htm": `/account/api-keys`,
"log.htm": `/logbook`,
"invoice.htm": `/invoices`,
"recommend.htm": `/recommend`,
"voucher.htm": `/vouchers`,
"host_transfer.htm": `/transfer`,
"security_group.htm": `/security/groups`,
"ssh_key.htm": `/security/ssh-keys`,
"message.htm": `/messages`,
"msgDetail.htm": `/messages`,
"group_rules.htm": `/security/groups`,
"withdrawal.htm": `/finance?tab=withdraw`,
"orderDetail.htm": `/orders/order`,
"addTicket.htm": `/tickets/create`,
"ticketDetails.htm": `/tickets/ticket`,
"issuedInvoice.htm": `/invoices/request`,
"security_ssh.htm": `/security/ssh-keys`,
"security_log.htm": `/logbook?type=api`,
},
i = {
45: `/transfer`,
},
a = new Set(
Object.values(r).filter(
(e) => ![`/`, `/security/groups`, `/vouchers`, `/logbook`].includes(e),
),
);
// 图标由本地 Lucide 名称决定,不直接采用后端传入的任意图标实现。
function o(e) {
let t = e.to?.split(`?`)[0];
return (
(t &&
{
"/": `lucide:house`,
"/products": `lucide:shopping-cart`,
"/cart": `lucide:shopping-basket`,
"/cloud-servers": `lucide:server`,
"/dedicated-servers": `lucide:server`,
"/bills": `lucide:receipt-text`,
"/finance": `lucide:wallet`,
"/orders": `lucide:clipboard-list`,
"/invoices": `lucide:receipt`,
"/recommend": `lucide:gift`,
"/vouchers": `lucide:ticket`,
"/account": `lucide:user`,
"/account/api-keys": `lucide:key-round`,
"/account/connections": `lucide:link`,
"/messages": `lucide:mail`,
"/logbook": `lucide:scroll-text`,
"/tickets": `lucide:headphones`,
"/help": `lucide:circle-help`,
"/resources": `lucide:book-open`,
"/notices": `lucide:megaphone`,
"/news": `lucide:newspaper`,
"/transfer": `lucide:truck`,
}[t]) ||
(e.href
? `lucide:external-link`
: e.children?.length
? `lucide:folder`
: `lucide:circle-help`)
);
}
/** 根据服务器模块菜单纠正列表类型或补齐唯一模块 m;多模块时不擅自替用户选择。 */
function s(e, t) {
if (![`/cloud-servers`, `/dedicated-servers`].includes(t.path)) return;
let n = new Set(),
r;
function i(e) {
for (let a of e) {
a.children && i(a.children);
let [e, o = ``] = a.to?.split(`?`) ?? [],
s = new URLSearchParams(o).get(`m`);
(s &&
s === String(t.query.m ?? ``) &&
e &&
[`/cloud-servers`, `/dedicated-servers`].includes(e) &&
e !== t.path &&
(r = {
path: e,
query: {
...t.query,
m: s,
},
}),
e === t.path && s && /^[1-9]\d*$/.test(s) && n.add(s));
}
}
if ((i(e), r)) return r;
if (!(n.size !== 1 || n.has(String(t.query.m ?? ``))))
return {
path: t.path,
query: {
...t.query,
m: [...n][0],
},
};
}
// 把已规范化的后端菜单装入现有分组,账户子页和资源中心有固定归属,其他落到末组。
function c(e, t, n) {
let r = e.map((e) => ({
...e,
items: [...(t.find((t) => t.label === e.label)?.items ?? [])],
})),
i = (e) => e.children?.flatMap(i) ?? (e.to ? [e.to.split(`?`)[0]] : []);
for (let t of n) {
let n = i(t),
a = e.findIndex((e) =>
e.items.some((e) =>
[e.to, ...(e.children?.map((e) => e.to) ?? [])].some(
(e) => e && n.includes(e),
),
),
);
(a < 0 &&
n.some((e) => e.startsWith(`/account/`)) &&
(a = e.findIndex((e) => e.items.some((e) => e.to === `/account`))),
a < 0 &&
n.includes(`/resources`) &&
(a = e.findIndex((e) => e.items.some((e) => e.to === `/help`))),
a < 0 && (a = r.length - 1),
!(a < 0) &&
r[a].items.push({
label: t.label,
icon: o(t),
backend: t,
}));
}
return r.filter((e) => e.items.length);
}
// 遍历树生成面包屑;相同路径时选择包含更多匹配 query 的最具体节点。
function l(e, n) {
let r = [];
function i(e, a) {
let o = e
.filter((e) => e.to)
.map((e) => ({
to: e.to,
}));
for (let s of e) {
let e = [...a, s.label];
if (s.to && t(s.to, n, o)) {
let [t = ``, n = ``] = s.to.split(`?`);
r.push({
labels: e,
path: t,
score: t.length * 100 + [...new URLSearchParams(n)].length,
});
}
s.children && i(s.children, e);
}
}
return (i(e, []), r.sort((e, t) => t.score - e.score)[0]?.labels ?? []);
}
// 使用后端菜单时移除已被接管的前端默认入口,避免同一功能重复出现。
function u(e, t) {
let n = new Set(),
r = (e) =>
e.forEach((e) => {
(e.to && n.add(e.to.split(`?`)[0]), e.children && r(e.children));
});
r(t);
let i = (e) => !a.has(e) && !n.has(e);
return e
.map((e) => ({
...e,
items: e.items.flatMap((e) => {
if (e.children) {
let t = e.children.filter((e) => i(e.to));
return t.length
? [
{
...e,
children: t,
},
]
: [];
}
return !e.to || i(e.to) ? [e] : [];
}),
}))
.filter((e) => e.items.length);
}
/** 外链只允许无用户名/密码的 HTTP(S) URL;控制字符和反斜杠直接拒绝。 */
function d(e) {
if (
typeof e != `string` ||
!/^https?:\/\//i.test(e) ||
/[\u0000-\u0020\\]/.test(e)
)
return ``;
try {
let t = new URL(e);
return t.username || t.password ? `` : t.href;
} catch {
return ``;
}
}
/**
* 规范化后端菜单树:最多三层、每层最多 100 项,文本最多 100 字。
* 无法适配的菜单保留禁用原因而非猜测跳转;二次提醒 second_reminder 作为确认标记保留。
*/
function f(e, t = ``, a = 0) {
if (!Array.isArray(e) || a > 2) return [];
let o = e.slice(0, 100).flatMap((e, o) => {
if (
!e ||
typeof e != `object` ||
typeof e.name != `string` ||
!e.name.trim()
)
return [];
let s = {
key: `${t}${e.id ?? o}`,
label: e.name.slice(0, 100),
confirm: Number(e.second_reminder) === 1,
},
c = f(e.child, `${s.key}-`, a + 1);
if (c.length) return ((s.children = c), [s]);
if ([`module`, `res_module`].includes(e.menu_type)) {
let t = {
云服务器: `/cloud-servers`,
物理服务器: `/dedicated-servers`,
}[e.name.trim()],
n = Number(e.id);
return (
t && Number.isSafeInteger(n) && n > 0
? (s.to = `${t}?m=${n}`)
: (s.disabledReason = `此菜单暂未适配当前控制台`),
[s]
);
}
if (e.menu_type === `embedded`)
return ((s.disabledReason = `此菜单暂未适配当前控制台`), [s]);
let l = typeof e.url == `string` ? e.url.trim() : ``,
u = l.split(`?`)[0] || ``,
p = u.replace(/^\//, ``).replace(/^clientarea\//, ``),
m = /^plugin\/([1-9]\d*)\/([a-zA-Z_]+\.htm)$/.exec(p),
h = r[p] || (m ? (m[2] === `index.htm` ? i[m[1]] : r[m[2]]) : void 0),
g = /^plugin\/([1-9]\d*)\/source\.htm$/.exec(p);
if (g) s.to = `/resources?plugin=${g[1]}`;
else if (h || n.has(u)) {
let [e, t = ``] = (h || u).split(`?`),
n = new URLSearchParams(l.split(`?`)[1] ?? ``),
r = new URLSearchParams(t);
// 只保留各页面明确支持且值域有效的单值 query,不能透传后端提供的任意参数。
for (let t of {
"/bills": [`status`],
"/orders": [`status`],
"/finance": [`tab`, `status`],
"/logbook": [`type`],
"/security/groups": [`id`],
"/orders/order": [`id`],
"/tickets/ticket": [`id`],
"/messages": [`id`],
}[e] ?? []) {
let e = n.getAll(t),
i = e[0] ?? ``,
a =
t === `id`
? /^[1-9]\d*$/.test(i)
: t === `tab`
? [
`bills`,
`transaction`,
`credit`,
`withdraw`,
`freeze`,
].includes(i)
: t === `status`
? [
`all`,
`Unpaid`,
`Paid`,
`Refunded`,
`WaitUpload`,
`WaitReview`,
`ReviewFail`,
].includes(i)
: [`api`, `system`].includes(i);
!r.has(t) && e.length === 1 && a && r.set(t, i);
}
s.to = e + (r.size ? `?${r}` : ``);
} else
e.menu_type === `custom` && d(l)
? (s.href = d(l))
: (s.disabledReason = `此功能暂未开放`);
return [s];
}),
s = o.filter((e) => e.to === `/account`);
// 旧后台可能把多个入口映射到 /account;合并后仍保留任一入口要求的二次确认。
return s.length < 2
? o
: o.flatMap((e) =>
e.to === `/account`
? e === s[0]
? [
{
...e,
label: `账户`,
confirm: s.some((e) => e.confirm),
},
]
: []
: [e],
);
}
export {
c as a,
t as c,
f as i,
e as l,
o as n,
d as o,
s as r,
u as s,
l as t,
};