sync(theme-4): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-4 样式。
This commit is contained in:
@@ -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') },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
25
apps/admin/src/utils/media-library.ts
Normal file
25
apps/admin/src/utils/media-library.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user