sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑

从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
This commit is contained in:
2026-06-22 14:11:47 +08:00
parent f92c5e8a59
commit d8e08aaed0
50 changed files with 4653 additions and 1186 deletions

View File

@@ -7,10 +7,39 @@ export interface AdminBreadcrumbItem {
export function resolveAdminBreadcrumb(
path: string,
t: (key: string) => string,
query: Record<string, unknown> = {},
): AdminBreadcrumbItem[] | null {
if (/^\/settlement\/[^/]+/.test(path)) {
if (/^\/users\/agents\/[^/]+\/players/.test(path)) {
return [
{ label: t('nav.agents_players'), to: '/users' },
{ label: t('breadcrumb.agent_direct_players') },
];
}
if (path === '/users/settings') {
return [
{ label: t('nav.agents_players'), to: '/users' },
{ label: t('user.page_settings') },
];
}
if (/^\/matches\/leagues\/[^/]+/.test(path)) {
return [
{ label: t('nav.matches'), to: '/matches' },
{ label: t('breadcrumb.league_fixtures') },
];
}
if (/^\/matches\/outrights\/leagues\/[^/]+/.test(path)) {
return [
{ label: t('nav.matches'), to: '/matches/outrights' },
{ label: t('breadcrumb.league_outrights') },
];
}
if (/^\/settlement\/[^/]+/.test(path)) {
const returnTo =
typeof query.returnTo === 'string' && query.returnTo.startsWith('/')
? query.returnTo
: '/matches';
return [
{ label: t('nav.matches'), to: returnTo },
{ label: t('breadcrumb.settlement') },
];
}

View File

@@ -10,3 +10,62 @@ export function stripHtml(html: string): string {
export function isHtmlEmpty(html: string): boolean {
return !stripHtml(html);
}
const ALLOWED_TAGS = new Set([
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li',
'img', 'a', 'h2', 'h3', 'blockquote', 'div', 'span',
]);
function sanitizeNode(node: Node): Node | null {
if (node.nodeType === Node.TEXT_NODE) {
return node.cloneNode(false);
}
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
const frag = document.createDocumentFragment();
for (const child of Array.from(el.childNodes)) {
const safe = sanitizeNode(child);
if (safe) frag.appendChild(safe);
}
return frag;
}
const out = document.createElement(tag);
if (tag === 'img') {
const src = el.getAttribute('src')?.trim();
if (!src || /^javascript:/i.test(src)) return null;
out.setAttribute('src', src);
const alt = el.getAttribute('alt');
if (alt) out.setAttribute('alt', alt);
return out;
}
if (tag === 'a') {
const href = el.getAttribute('href')?.trim();
if (!href || /^javascript:/i.test(href)) return null;
out.setAttribute('href', href);
out.setAttribute('target', '_blank');
out.setAttribute('rel', 'noopener noreferrer');
}
for (const child of Array.from(el.childNodes)) {
const safe = sanitizeNode(child);
if (safe) out.appendChild(safe);
}
return out;
}
/** 富文本 HTML 白名单净化(公告/站内信预览) */
export function sanitizeAnnouncementHtml(html: string): string {
if (!html?.trim()) return '';
if (!/[<>]/.test(html)) return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
const container = document.createElement('div');
for (const child of Array.from(doc.body.childNodes)) {
const safe = sanitizeNode(child);
if (safe) container.appendChild(safe);
}
return container.innerHTML;
}

View File

@@ -1,10 +1,8 @@
/** 赛事列表 UI 状态(返回列表时恢复展开等 */
/** 赛事列表 UI 状态(返回列表时恢复筛选与分页 */
const STORAGE_KEY = 'admin_matches_list_ui';
export const MAX_EXPANDED_LEAGUES = 3;
export type MatchesListUiState = {
expandedLeagueIds: string[];
page: number;
pageSize: number;
filterStatus: string;
@@ -13,7 +11,6 @@ export type MatchesListUiState = {
function defaultState(): MatchesListUiState {
return {
expandedLeagueIds: [],
page: 1,
pageSize: 10,
filterStatus: '',
@@ -21,44 +18,21 @@ function defaultState(): MatchesListUiState {
};
}
function capExpanded(ids: string[]): string[] {
return ids.slice(0, MAX_EXPANDED_LEAGUES);
}
export function readMatchesListUiState(): MatchesListUiState | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as MatchesListUiState;
if (!Array.isArray(parsed.expandedLeagueIds)) return null;
return {
...parsed,
expandedLeagueIds: capExpanded(parsed.expandedLeagueIds),
};
return JSON.parse(raw) as MatchesListUiState;
} catch {
return null;
}
}
export function writeMatchesListUiState(state: MatchesListUiState) {
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({
...state,
expandedLeagueIds: capExpanded(state.expandedLeagueIds),
}),
);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
export function patchMatchesListUiState(patch: Partial<MatchesListUiState>) {
const base = readMatchesListUiState() ?? defaultState();
writeMatchesListUiState({ ...base, ...patch });
}
/** 从子页返回前确保该赛事行处于展开记录中 */
export function ensureLeagueExpanded(leagueId: string) {
if (!leagueId) return;
const base = readMatchesListUiState() ?? defaultState();
const ids = capExpanded([...new Set([...base.expandedLeagueIds, leagueId])]);
writeMatchesListUiState({ ...base, expandedLeagueIds: ids });
}

View File

@@ -0,0 +1,25 @@
import api from '../api';
export type MediaLibraryItem = {
id: string;
filename: string;
url: string;
mimeType: string;
category?: string;
};
/** 媒体库图片(默认全部分类,供「从媒体库选择」使用) */
export async function fetchMediaLibraryImages(opts?: {
category?: string;
pageSize?: number;
}): Promise<MediaLibraryItem[]> {
const params: Record<string, string | number> = {
pageSize: opts?.pageSize ?? 200,
imagesOnly: '1',
};
if (opts?.category?.trim()) {
params.category = opts.category.trim();
}
const { data } = await api.get('/admin/files', { params });
return (data.data?.items ?? []) as MediaLibraryItem[];
}