Merge remote-tracking branch 'origin/main' into feat/account-level-proxy

# Conflicts:
#	src/server/proxy-core/surfaces/chatSurface.ts
#	src/server/proxy-core/surfaces/openAiResponsesSurface.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/images.ts
#	src/server/services/modelService.ts
This commit is contained in:
Hureru
2026-03-21 10:09:19 +08:00
198 changed files with 20039 additions and 1105 deletions
+5 -2
View File
@@ -35,6 +35,7 @@ const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
type ThemeMode = 'system' | 'light' | 'dark';
@@ -322,12 +323,13 @@ function UserProfileModal({
);
}
const sidebarGroups = [
export const sidebarGroups = [
{
label: '控制台',
items: [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/site-announcements', label: '站点公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> },
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
@@ -764,6 +766,7 @@ function AppShell() {
<Routes>
<Route path="/" element={<Dashboard adminName={displayName} />} />
<Route path="/sites" element={<Sites />} />
<Route path="/site-announcements" element={<SiteAnnouncements />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/oauth" element={<OAuthManagement />} />
<Route path="/tokens" element={<Tokens />} />
+52
View File
@@ -97,4 +97,56 @@ describe('api proxy test timeout handling', () => {
await vi.advanceTimersByTimeAsync(30_000);
await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' });
});
it('times out replay hydration file-content fetches after 30 seconds', async () => {
installPendingFetch();
const getProxyFileContentDataUrl = (api as Record<string, any>).getProxyFileContentDataUrl;
let settled = false;
const handled = getProxyFileContentDataUrl?.('file-metapi-123')
.then(() => ({ ok: true as const }))
.catch((error: Error) => ({ ok: false as const, error }))
.finally(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(30_000);
expect(settled).toBe(true);
const result = await handled;
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected replay hydration file-content fetch to time out');
}
expect(result.error.message).toBe('请求超时(30s)');
});
it('loads proxy file content as a data URL for replay hydration', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(
new Blob([Buffer.from('PDF')], { type: 'application/pdf' }),
{
status: 200,
headers: {
'content-type': 'application/pdf',
'content-disposition': 'inline; filename="brief.pdf"',
},
},
));
vi.stubGlobal('fetch', fetchMock);
const getProxyFileContentDataUrl = (api as Record<string, any>).getProxyFileContentDataUrl;
const result = await getProxyFileContentDataUrl?.('file-metapi-123');
expect(fetchMock).toHaveBeenCalledWith('/v1/files/file-metapi-123/content', expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer token-1',
}),
}));
expect(result).toEqual({
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,UERG',
});
});
});
+171 -33
View File
@@ -4,7 +4,74 @@ type RequestOptions = RequestInit & {
timeoutMs?: number;
};
async function request(url: string, options: RequestOptions = {}) {
function requireAuthToken(): string {
const token = getAuthToken(localStorage);
if (!token) {
const hadToken = !!localStorage.getItem('auth_token');
clearAuthSession(localStorage);
if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
window.location.reload();
}
throw new Error('Session expired');
}
return token;
}
async function extractResponseErrorMessage(res: Response): Promise<string> {
let message = `HTTP ${res.status}`;
try {
const text = await res.text();
if (text) {
try {
const json = JSON.parse(text);
if (json?.message && typeof json.message === 'string') {
message = json.message;
} else if (json?.error && typeof json.error === 'string') {
message = json.error;
} else if (json?.error?.message && typeof json.error.message === 'string') {
message = json.error.message;
} else {
message = `${message}: ${text.slice(0, 120)}`;
}
} catch {
message = `${message}: ${text.slice(0, 120)}`;
}
}
} catch { }
return message;
}
function parseContentDispositionFilename(headerValue: string | null): string | null {
if (!headerValue) return null;
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(headerValue);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch {
return utf8Match[1];
}
}
const quotedMatch = /filename="([^"]+)"/i.exec(headerValue);
if (quotedMatch?.[1]) return quotedMatch[1];
const bareMatch = /filename=([^;]+)/i.exec(headerValue);
return bareMatch?.[1]?.trim() || null;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
if (typeof Buffer !== 'undefined') {
return Buffer.from(buffer).toString('base64');
}
let binary = '';
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
}
return btoa(binary);
}
async function fetchAuthenticatedResponse(url: string, options: RequestOptions = {}): Promise<Response> {
const { timeoutMs = 30_000, signal: externalSignal, ...fetchOptions } = options;
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
@@ -22,15 +89,7 @@ async function request(url: string, options: RequestOptions = {}) {
}
}
const token = getAuthToken(localStorage);
if (!token) {
const hadToken = !!localStorage.getItem('auth_token');
clearAuthSession(localStorage);
if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
window.location.reload();
}
throw new Error('Session expired');
}
const token = requireAuthToken();
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
};
@@ -48,31 +107,12 @@ async function request(url: string, options: RequestOptions = {}) {
if (res.status === 401 || res.status === 403) {
const hadToken = !!getAuthToken(localStorage);
clearAuthSession(localStorage);
if (hadToken) window.location.reload();
if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
window.location.reload();
}
throw new Error('Session expired');
}
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const text = await res.text();
if (text) {
try {
const json = JSON.parse(text);
if (json?.message && typeof json.message === 'string') {
message = json.message;
} else if (json?.error && typeof json.error === 'string') {
message = json.error;
} else {
message = `${message}: ${text.slice(0, 120)}`;
}
} catch {
message = `${message}: ${text.slice(0, 120)}`;
}
}
} catch { }
throw new Error(message);
}
return res.json();
return res;
} catch (error: any) {
if (error?.name === 'AbortError') {
if (externalSignal?.aborted) throw error;
@@ -88,6 +128,14 @@ async function request(url: string, options: RequestOptions = {}) {
}
}
async function request(url: string, options: RequestOptions = {}) {
const res = await fetchAuthenticatedResponse(url, options);
if (!res.ok) {
throw new Error(await extractResponseErrorMessage(res));
}
return res.json();
}
function buildQueryString(params?: Record<string, string | number | boolean | null | undefined>) {
if (!params) return '';
const searchParams = new URLSearchParams();
@@ -156,7 +204,23 @@ export type ProxyTestJobResponse = {
expiresAt?: string;
};
export type SystemProxyTestRequest = {
proxyUrl?: string;
};
export type SystemProxyTestResponse = {
success: true;
proxyUrl: string;
probeUrl: string;
finalUrl: string;
reachable: true;
ok: boolean;
statusCode: number;
latencyMs: number;
};
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
export type ProxyLogBillingDetails = {
quotaType: number;
@@ -208,6 +272,10 @@ export type ProxyLogListItem = {
downstreamKeyName?: string | null;
downstreamKeyGroupName?: string | null;
downstreamKeyTags?: string[];
clientFamily?: string | null;
clientAppId?: string | null;
clientAppName?: string | null;
clientConfidence?: ProxyLogClientConfidence;
promptTokens?: number | null;
completionTokens?: number | null;
estimatedCost?: number | null;
@@ -233,16 +301,23 @@ export type ProxyLogsQuery = {
offset?: number;
status?: ProxyLogStatusFilter;
search?: string;
client?: string;
siteId?: number;
from?: string;
to?: string;
};
export type ProxyLogClientOption = {
value: string;
label: string;
};
export type ProxyLogsResponse = {
items: ProxyLogListItem[];
total: number;
page: number;
pageSize: number;
clientOptions: ProxyLogClientOption[];
summary: ProxyLogsSummary;
};
@@ -478,6 +553,14 @@ export const api = {
markEventRead: (id: number) => request(`/api/events/${id}/read`, { method: 'POST' }),
markAllEventsRead: () => request('/api/events/read-all', { method: 'POST' }),
clearEvents: () => request('/api/events', { method: 'DELETE' }),
getSiteAnnouncements: (params?: string) => request(`/api/site-announcements${params ? '?' + params : ''}`),
markSiteAnnouncementRead: (id: number) => request(`/api/site-announcements/${id}/read`, { method: 'POST' }),
markAllSiteAnnouncementsRead: () => request('/api/site-announcements/read-all', { method: 'POST' }),
clearSiteAnnouncements: () => request('/api/site-announcements', { method: 'DELETE' }),
syncSiteAnnouncements: (payload?: { siteId?: number }) => request('/api/site-announcements/sync', {
method: 'POST',
body: JSON.stringify(payload || {}),
}),
getTasks: (limit = 50) => request(`/api/tasks?limit=${Math.max(1, Math.min(200, Math.trunc(limit)))}`),
getTask: (id: string) => request(`/api/tasks/${encodeURIComponent(id)}`),
@@ -491,6 +574,11 @@ export const api = {
method: 'PUT',
body: JSON.stringify(data),
}),
testSystemProxy: (data: SystemProxyTestRequest) => request('/api/settings/system-proxy/test', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: 20_000,
}),
getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
request('/api/settings/database/runtime', {
@@ -547,6 +635,33 @@ export const api = {
method: 'POST',
body: JSON.stringify({ data }),
}),
getBackupWebdavConfig: () => request('/api/settings/backup/webdav'),
saveBackupWebdavConfig: (data: {
enabled: boolean;
fileUrl: string;
username: string;
password?: string;
clearPassword?: boolean;
exportType: 'all' | 'accounts' | 'preferences';
autoSyncEnabled: boolean;
autoSyncCron: string;
}) =>
request('/api/settings/backup/webdav', {
method: 'PUT',
body: JSON.stringify(data),
}),
exportBackupToWebdav: (type?: 'all' | 'accounts' | 'preferences') =>
request('/api/settings/backup/webdav/export', {
method: 'POST',
body: JSON.stringify(type ? { type } : {}),
timeoutMs: 60_000,
}),
importBackupFromWebdav: () =>
request('/api/settings/backup/webdav/import', {
method: 'POST',
body: JSON.stringify({}),
timeoutMs: 60_000,
}),
clearRuntimeCache: () => request('/api/settings/maintenance/clear-cache', { method: 'POST' }),
clearUsageData: () => request('/api/settings/maintenance/clear-usage', { method: 'POST' }),
factoryReset: () => request('/api/settings/maintenance/factory-reset', { method: 'POST' }),
@@ -583,6 +698,29 @@ export const api = {
}),
getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`),
deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
getProxyFileContentDataUrl: async (
fileId: string,
options: Pick<RequestOptions, 'signal' | 'timeoutMs'> = {},
) => {
const response = await fetchAuthenticatedResponse(`/v1/files/${encodeURIComponent(fileId)}/content`, {
method: 'GET',
...options,
});
if (!response.ok) {
throw new Error(await extractResponseErrorMessage(response));
}
const mimeType = (response.headers.get('content-type') || 'application/octet-stream')
.split(';')[0]
.trim() || 'application/octet-stream';
const filename = parseContentDispositionFilename(response.headers.get('content-disposition'));
const base64 = arrayBufferToBase64(await response.arrayBuffer());
return {
filename,
mimeType,
data: `data:${mimeType};base64,${base64}`,
};
},
testProxy: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', {
method: 'POST',
+2 -1
View File
@@ -18,6 +18,7 @@ const typeLabels: Record<string, string> = {
token: '令牌',
proxy: '代理',
status: '状态',
site_notice: '站点公告',
};
export default function NotificationPanel({
@@ -97,7 +98,7 @@ export default function NotificationPanel({
{/* Filters */}
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
{['', 'checkin', 'balance', 'token', 'proxy', 'status'].map((filterType) => (
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => (
<button key={filterType} onClick={() => setFilter(filterType)}
style={{
fontSize: 11, padding: '3px 8px', borderRadius: 12,
+147
View File
@@ -1629,6 +1629,107 @@ body {
animation: row-focus-glow 2.2s ease;
}
.announcement-rich-content {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.7;
word-break: break-word;
}
.announcement-rich-content > :first-child {
margin-top: 0;
}
.announcement-rich-content > :last-child {
margin-bottom: 0;
}
.announcement-rich-content p,
.announcement-rich-content ul,
.announcement-rich-content ol,
.announcement-rich-content blockquote,
.announcement-rich-content pre,
.announcement-rich-content table,
.announcement-rich-content h1,
.announcement-rich-content h2,
.announcement-rich-content h3,
.announcement-rich-content h4,
.announcement-rich-content h5,
.announcement-rich-content h6 {
margin: 0 0 10px 0;
}
.announcement-rich-content h1,
.announcement-rich-content h2,
.announcement-rich-content h3,
.announcement-rich-content h4,
.announcement-rich-content h5,
.announcement-rich-content h6 {
color: var(--color-text-primary);
line-height: 1.45;
}
.announcement-rich-content ul,
.announcement-rich-content ol {
padding-left: 20px;
}
.announcement-rich-content blockquote {
padding: 10px 12px;
border-left: 3px solid var(--color-primary);
background: var(--color-surface-subtle);
border-radius: 8px;
}
.announcement-rich-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
padding: 1px 4px;
border-radius: 6px;
background: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.announcement-rich-content pre {
overflow-x: auto;
padding: 12px 14px;
border-radius: 10px;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
}
.announcement-rich-content pre code {
display: block;
padding: 0;
background: transparent;
}
.announcement-rich-content a {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.announcement-rich-content img {
max-width: 100%;
height: auto;
border-radius: 10px;
border: 1px solid var(--color-border);
}
.announcement-rich-content table {
width: 100%;
border-collapse: collapse;
}
.announcement-rich-content th,
.announcement-rich-content td {
border: 1px solid var(--color-border);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
@keyframes rebind-panel-glow {
0% {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 42%, transparent);
@@ -1816,6 +1917,52 @@ body {
white-space: normal !important;
}
.site-balance-cell {
white-space: normal !important;
}
.site-balance-inline {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
max-width: 100%;
line-height: 1.35;
}
.site-balance-inline.align-end {
justify-content: flex-end;
}
.site-balance-primary {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.site-balance-divider {
color: var(--color-text-muted);
font-size: 12px;
}
.site-balance-subscription {
font-size: 11px;
color: var(--color-text-muted);
line-height: 1.35;
font-variant-numeric: tabular-nums;
white-space: normal;
text-wrap: pretty;
cursor: help;
transition: color 0.15s ease;
}
.site-balance-subscription:hover,
.site-balance-subscription:focus-visible {
color: var(--color-text-secondary);
}
.sites-row-actions {
display: flex;
flex-wrap: wrap;
+11 -6
View File
@@ -1171,12 +1171,17 @@ export default function Accounts() {
</div>
{isSub2ApiSelected && (
<>
<input
placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
value={tokenForm.refreshToken}
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
value={tokenForm.refreshToken}
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
可在浏览器控制台执行 <code style={{ fontFamily: 'var(--font-mono)' }}>localStorage.getItem('refresh_token')</code> 获取。
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="token_expires_at(可选,毫秒时间戳)"
+74
View File
@@ -404,6 +404,80 @@ describe('DownstreamKeys page', () => {
}
});
it('lets operators explicitly select all exact models and all group routes before saving', async () => {
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
{ id: 13, modelPattern: 're:^gemini-2\\..*$', displayName: 'Gemini 全家桶', enabled: true },
{ id: 14, modelPattern: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', enabled: true },
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel');
const modelPanel = panels.find((node) => collectText(node).includes('模型白名单'));
const groupPanel = panels.find((node) => collectText(node).includes('群组范围'));
expect(modelPanel).toBeTruthy();
expect(groupPanel).toBeTruthy();
const modelSelectAllBtn = modelPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0];
const groupSelectAllBtn = groupPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0];
await act(async () => {
modelSelectAllBtn.props.onClick();
groupSelectAllBtn.props.onClick();
});
await flushMicrotasks();
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'select-all-key' } });
keyInput!.props.onChange({ target: { value: 'sk-select-all-key-0319' } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'select-all-key',
key: 'sk-select-all-key-0319',
supportedModels: ['claude-opus-4-6', 'gpt-4.1-mini'],
allowedRouteIds: [11, 13],
}));
} finally {
root?.unmount();
}
});
it('uses backend batch api for selected rows', async () => {
let root: ReturnType<typeof create> | null = null;
try {
+143 -12
View File
@@ -7,10 +7,13 @@ import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js'));
type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket;
const PROXY_TOKEN_PREFIX = 'sk-';
type Range = '24h' | '7d' | 'all';
type Status = 'all' | 'enabled' | 'disabled';
@@ -259,6 +262,86 @@ function tagChipStyle(kind: 'normal' | 'accent' = 'normal'): React.CSSProperties
};
}
async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
function DownstreamKeyCopyIconButton({ fullKey }: { fullKey: string | undefined }) {
const toast = useToast();
const [pressed, setPressed] = useState(false);
const disabled = !fullKey?.trim();
const release = () => setPressed(false);
return (
<button
type="button"
title="复制完整密钥"
aria-label="复制完整密钥"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 2,
lineHeight: 0,
flexShrink: 0,
border: 'none',
background: 'transparent',
color: disabled
? 'var(--color-text-muted)'
: pressed
? 'var(--color-text-primary)'
: 'var(--color-text-muted)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.4 : 1,
borderRadius: 'var(--radius-sm)',
}}
disabled={disabled}
onMouseDown={() => {
if (!disabled) setPressed(true);
}}
onMouseUp={release}
onMouseLeave={release}
onTouchStart={() => {
if (!disabled) setPressed(true);
}}
onTouchEnd={release}
onTouchCancel={release}
onClick={async (e) => {
e.stopPropagation();
const full = fullKey?.trim();
if (!full) {
toast.info('完整密钥暂不可用,请刷新页面后重试');
return;
}
try {
await copyToClipboard(full);
toast.success('已复制到剪贴板');
} catch {
toast.error('复制失败');
}
}}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</button>
);
}
function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): EditorForm {
return {
name: item?.name || '',
@@ -277,13 +360,13 @@ function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): Edit
}
function summarizeModelLimit(models: string[]): string {
if (!Array.isArray(models) || models.length === 0) return '全部模型';
if (!Array.isArray(models) || models.length === 0) return '未授权模型';
if (models.length === 1) return models[0];
return `${models[0]} +${models.length - 1}`;
}
function summarizeRouteLimit(routeIds: number[], routeMap: Map<number, RouteSelectorItem>): string {
if (!Array.isArray(routeIds) || routeIds.length === 0) return '全部群组';
if (!Array.isArray(routeIds) || routeIds.length === 0) return '未授权群组';
const names = routeIds
.map((id) => routeMap.get(id))
.filter(Boolean)
@@ -877,7 +960,22 @@ function EditorModal({
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">下游密钥</div>
<input value={form.key} onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))} placeholder="sk-..." style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', minWidth: 0 }}>
<input
value={form.key}
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))}
placeholder="sk-..."
style={{ ...inputStyle, flex: 1, minWidth: 0, fontFamily: 'var(--font-mono)' }}
/>
<button
type="button"
className="btn btn-ghost"
style={{ flexShrink: 0, whiteSpace: 'nowrap', alignSelf: 'stretch' }}
onClick={() => onChange((prev) => ({ ...prev, key: generateDownstreamSkKey(PROXY_TOKEN_PREFIX) }))}
>
随机
</button>
</div>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">主分组</div>
@@ -956,11 +1054,26 @@ function EditorModal({
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">模型白名单</div>
<div className="downstream-key-modal-help">只展示精确模型,未勾选则视为全部模型可用</div>
<div className="downstream-key-modal-help">只展示精确模型;未勾选时默认不允许任何精确模型,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedModels: exactModels }))}
>
全选
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}
>
清空
</button>
</div>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>
清空
</button>
</div>
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
@@ -995,11 +1108,26 @@ function EditorModal({
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">群组范围</div>
<div className="downstream-key-modal-help">限制可访问的群组路由,未勾选则视为全部群组可用</div>
<div className="downstream-key-modal-help">限制可访问的群组路由;未勾选时默认不允许任何群组,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: groupRouteOptions.map((route) => route.id) }))}
>
全选
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}
>
清空
</button>
</div>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>
清空
</button>
</div>
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
@@ -1607,7 +1735,10 @@ export default function DownstreamKeys() {
<strong style={{ color: 'var(--color-text-primary)' }}>{row.name}</strong>
<StatusBadge enabled={row.enabled} />
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{row.keyMasked}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 4 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-muted)' }}>{row.keyMasked}</span>
<DownstreamKeyCopyIconButton fullKey={row.key} />
</div>
{row.description ? <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', maxWidth: 320 }}>{row.description}</div> : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<span className={`badge ${row.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
+194
View File
@@ -7,6 +7,10 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: {
exportBackup: vi.fn(),
importBackup: vi.fn(),
getBackupWebdavConfig: vi.fn(),
saveBackupWebdavConfig: vi.fn(),
exportBackupToWebdav: vi.fn(),
importBackupFromWebdav: vi.fn(),
},
toastMock: {
success: vi.fn(),
@@ -185,6 +189,53 @@ describe('ImportExport', () => {
vi.stubGlobal('window', {
confirm: vi.fn(() => true),
});
apiMock.getBackupWebdavConfig.mockResolvedValue({
success: true,
config: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: true,
passwordMasked: 'se****ss',
},
state: {
lastSyncAt: null,
lastError: null,
},
});
apiMock.saveBackupWebdavConfig.mockResolvedValue({
success: true,
config: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: true,
passwordMasked: 'se****ss',
},
state: {
lastSyncAt: null,
lastError: null,
},
});
apiMock.exportBackupToWebdav.mockResolvedValue({
success: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
exportType: 'all',
});
apiMock.importBackupFromWebdav.mockResolvedValue({
success: true,
sections: {
accounts: true,
preferences: true,
},
appliedSettings: [],
});
apiMock.importBackup.mockResolvedValue({
allImported: true,
sections: {
@@ -305,4 +356,147 @@ describe('ImportExport', () => {
root?.unmount();
}
});
it('loads webdav config and saves updates from the import/export page', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = collectText(root!.root);
expect(apiMock.getBackupWebdavConfig).toHaveBeenCalledTimes(1);
expect(rendered).toContain('WebDAV');
expect(rendered).toContain('自动同步');
const saveButton = root!.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存 WebDAV 配置')
)).at(-1);
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.saveBackupWebdavConfig).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
it('disables webdav actions while config has unsaved changes', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const fileUrlInput = root!.root.findAll((node) => (
node.type === 'input'
&& node.props.placeholder === 'https://dav.example.com/backups/metapi.json'
)).at(-1);
expect(fileUrlInput).toBeTruthy();
await act(async () => {
fileUrlInput!.props.onChange({ target: { value: 'https://dav.example.com/backups/changed.json' } });
});
await flushMicrotasks();
const exportButton = root!.root.findAll((node) => (
node.type === 'button'
&& collectText(node).includes('立即导出到 WebDAV')
)).at(-1);
const importButton = root!.root.findAll((node) => (
node.type === 'button'
&& collectText(node).includes('从 WebDAV 拉取')
)).at(-1);
expect(exportButton?.props.disabled).toBe(true);
expect(importButton?.props.disabled).toBe(true);
} finally {
root?.unmount();
}
});
it('can clear a saved webdav password', async () => {
apiMock.saveBackupWebdavConfig.mockResolvedValueOnce({
success: true,
config: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: false,
passwordMasked: '',
},
state: {
lastSyncAt: null,
lastError: null,
},
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const clearPasswordToggle = root!.root.findAll((node) => (
node.type === 'label'
&& collectText(node).includes('清空已保存密码')
)).at(-1);
expect(clearPasswordToggle).toBeTruthy();
const checkbox = clearPasswordToggle!.findByType('input');
await act(async () => {
checkbox.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const saveButton = root!.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存 WebDAV 配置')
)).at(-1);
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.saveBackupWebdavConfig).toHaveBeenCalledWith(expect.objectContaining({
clearPassword: true,
}));
} finally {
root?.unmount();
}
});
});
+323 -2
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
@@ -24,6 +24,55 @@ type ParsedSummary = {
ignoredSections: string[];
};
type WebdavConfigForm = {
enabled: boolean;
fileUrl: string;
username: string;
password: string;
exportType: BackupType;
autoSyncEnabled: boolean;
autoSyncCron: string;
hasPassword: boolean;
passwordMasked: string;
};
type WebdavSyncState = {
lastSyncAt: string | null;
lastError: string | null;
};
type WebdavConfigSnapshot = {
enabled: boolean;
fileUrl: string;
username: string;
exportType: BackupType;
autoSyncEnabled: boolean;
autoSyncCron: string;
hasPassword: boolean;
};
const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = {
enabled: false,
fileUrl: '',
username: '',
password: '',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
hasPassword: false,
passwordMasked: '',
};
const DEFAULT_WEBDAV_SNAPSHOT: WebdavConfigSnapshot = {
enabled: false,
fileUrl: '',
username: '',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
hasPassword: false,
};
function downloadJsonFile(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -188,10 +237,82 @@ export default function ImportExport() {
const [importData, setImportData] = useState('');
const [selectedFileName, setSelectedFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [webdavConfig, setWebdavConfig] = useState<WebdavConfigForm>(DEFAULT_WEBDAV_CONFIG);
const [savedWebdavConfig, setSavedWebdavConfig] = useState<WebdavConfigSnapshot>(DEFAULT_WEBDAV_SNAPSHOT);
const [webdavState, setWebdavState] = useState<WebdavSyncState>({ lastSyncAt: null, lastError: null });
const [webdavSaving, setWebdavSaving] = useState(false);
const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>('');
const [clearWebdavPassword, setClearWebdavPassword] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const summary = useMemo(() => parseImportSummary(importData), [importData]);
const buildWebdavForm = (config: any): WebdavConfigForm => ({
enabled: config.enabled === true,
fileUrl: String(config.fileUrl || ''),
username: String(config.username || ''),
password: '',
exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all',
autoSyncEnabled: config.autoSyncEnabled === true,
autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron),
hasPassword: config.hasPassword === true,
passwordMasked: String(config.passwordMasked || ''),
});
const buildWebdavSnapshot = (config: any): WebdavConfigSnapshot => ({
enabled: config.enabled === true,
fileUrl: String(config.fileUrl || ''),
username: String(config.username || ''),
exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all',
autoSyncEnabled: config.autoSyncEnabled === true,
autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron),
hasPassword: config.hasPassword === true,
});
const applyWebdavResponse = (result: any) => {
const config = result?.config;
if (config) {
setWebdavConfig(buildWebdavForm(config));
setSavedWebdavConfig(buildWebdavSnapshot(config));
setClearWebdavPassword(false);
}
const state = result?.state || result;
setWebdavState((prev) => ({
lastSyncAt: typeof state?.lastSyncAt === 'string' ? state.lastSyncAt : prev.lastSyncAt,
lastError: typeof state?.lastError === 'string'
? state.lastError
: (state?.lastError === null ? null : prev.lastError),
}));
};
const webdavConfigDirty = (
webdavConfig.enabled !== savedWebdavConfig.enabled
|| webdavConfig.fileUrl !== savedWebdavConfig.fileUrl
|| webdavConfig.username !== savedWebdavConfig.username
|| webdavConfig.exportType !== savedWebdavConfig.exportType
|| webdavConfig.autoSyncEnabled !== savedWebdavConfig.autoSyncEnabled
|| webdavConfig.autoSyncCron !== savedWebdavConfig.autoSyncCron
|| webdavConfig.hasPassword !== savedWebdavConfig.hasPassword
|| webdavConfig.password.trim().length > 0
|| clearWebdavPassword
);
useEffect(() => {
let alive = true;
void api.getBackupWebdavConfig()
.then((result: any) => {
if (!alive) return;
applyWebdavResponse(result);
})
.catch((err: any) => {
if (!alive) return;
toast.error(err?.message || '加载 WebDAV 配置失败');
});
return () => {
alive = false;
};
}, [toast]);
const handleExport = async (type: BackupType) => {
setExportingType(type);
try {
@@ -281,6 +402,63 @@ export default function ImportExport() {
}
};
const handleSaveWebdavConfig = async () => {
setWebdavSaving(true);
try {
const nextPassword = webdavConfig.password.trim();
const payload: Record<string, unknown> = {
enabled: webdavConfig.enabled,
fileUrl: webdavConfig.fileUrl,
username: webdavConfig.username,
exportType: webdavConfig.exportType,
autoSyncEnabled: webdavConfig.autoSyncEnabled,
autoSyncCron: webdavConfig.autoSyncCron,
};
if (nextPassword) {
payload.password = webdavConfig.password;
} else if (clearWebdavPassword) {
payload.clearPassword = true;
}
const result = await api.saveBackupWebdavConfig(payload as any);
applyWebdavResponse(result);
toast.success('WebDAV 配置已保存');
} catch (err: any) {
toast.error(err?.message || '保存 WebDAV 配置失败');
} finally {
setWebdavSaving(false);
}
};
const handleExportToWebdav = async () => {
setWebdavAction('export');
try {
const result = await api.exportBackupToWebdav(webdavConfig.exportType);
applyWebdavResponse(result);
toast.success(`已导出到 WebDAV:${result?.fileUrl || webdavConfig.fileUrl}`);
} catch (err: any) {
toast.error(err?.message || '导出到 WebDAV 失败');
} finally {
setWebdavAction('');
}
};
const handleImportFromWebdav = async () => {
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
? true
: window.confirm('从 WebDAV 导入会覆盖本地账号/路由或系统设置,确认继续?');
if (!confirmed) return;
setWebdavAction('import');
try {
const result = await api.importBackupFromWebdav();
applyWebdavResponse(result);
toast.success(buildImportSuccessMessage(result));
} catch (err: any) {
toast.error(err?.message || '从 WebDAV 导入失败');
} finally {
setWebdavAction('');
}
};
return (
<div className="animate-fade-in">
<div className="page-header" style={{ alignItems: 'flex-end', marginBottom: 18 }}>
@@ -483,7 +661,150 @@ export default function ImportExport() {
</div>
</div>
<div className="card animate-slide-up stagger-3" style={{ marginTop: 14, padding: 16 }}>
<div className="card animate-slide-up stagger-3" style={{ marginTop: 14, padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-primary)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999A5.002 5.002 0 006 9a4 4 0 00-3 6z" />
</svg>
<span style={{ fontSize: 15, fontWeight: 700 }}>WebDAV 同步</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
支持手动推送、手动拉取,以及定时自动导出到 WebDAV。
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 10 }}>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>文件 URL</span>
<input
value={webdavConfig.fileUrl}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, fileUrl: e.target.value }))}
placeholder="https://dav.example.com/backups/metapi.json"
style={{ width: '100%' }}
/>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>用户名</span>
<input
value={webdavConfig.username}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, username: e.target.value }))}
placeholder="可留空"
style={{ width: '100%' }}
/>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>密码</span>
<input
type="password"
value={webdavConfig.password}
onChange={(e) => {
const nextPassword = e.target.value;
setWebdavConfig((prev) => ({ ...prev, password: nextPassword }));
if (nextPassword.trim()) {
setClearWebdavPassword(false);
}
}}
placeholder={clearWebdavPassword
? '保存后将清空已存密码'
: (webdavConfig.hasPassword ? `已保存 ${webdavConfig.passwordMasked},留空则保持不变` : '请输入密码')}
disabled={clearWebdavPassword}
style={{ width: '100%' }}
/>
{webdavConfig.hasPassword ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={clearWebdavPassword}
onChange={(e) => {
const checked = e.target.checked === true;
setClearWebdavPassword(checked);
if (checked) {
setWebdavConfig((prev) => ({ ...prev, password: '' }));
}
}}
/>
清空已保存密码
</label>
) : null}
</label>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>导出分区</span>
<select
value={webdavConfig.exportType}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, exportType: e.target.value as BackupType }))}
>
<option value="all">全部</option>
<option value="accounts">账号与路由</option>
<option value="preferences">系统设置</option>
</select>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>自动同步 Cron</span>
<input
value={webdavConfig.autoSyncCron}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncCron: e.target.value }))}
placeholder="0 */6 * * *"
style={{ width: '100%' }}
/>
</label>
<div style={{ display: 'grid', gap: 8, alignContent: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input
type="checkbox"
checked={webdavConfig.enabled}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, enabled: e.target.checked }))}
/>
启用 WebDAV
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input
type="checkbox"
checked={webdavConfig.autoSyncEnabled}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncEnabled: e.target.checked }))}
/>
自动同步
</label>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
<button
onClick={handleSaveWebdavConfig}
disabled={webdavSaving}
className="btn btn-primary"
>
{webdavSaving ? '保存中...' : '保存 WebDAV 配置'}
</button>
<button
onClick={handleExportToWebdav}
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{webdavAction === 'export' ? '导出中...' : '立即导出到 WebDAV'}
</button>
<button
onClick={handleImportFromWebdav}
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{webdavAction === 'import' ? '拉取中...' : '从 WebDAV 拉取'}
</button>
</div>
{webdavConfigDirty ? (
<div style={{ marginTop: 10, fontSize: 12, color: 'var(--color-warning)' }}>
当前 WebDAV 配置有未保存改动,请先保存后再执行导入或导出。
</div>
) : null}
<div style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
<div>上次同步:{webdavState.lastSyncAt ? new Date(webdavState.lastSyncAt).toLocaleString() : '尚未同步'}</div>
<div>最近错误:{webdavState.lastError || '无'}</div>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ marginTop: 14, padding: 16 }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>注意事项</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}>
<div>1. 导入账号分区会覆盖现有站点、账号、令牌和路由配置。</div>
+116 -41
View File
@@ -23,16 +23,19 @@ import {
createLoadingAssistantMessage,
createMessage,
createConversationUserMessage,
extractConversationUploadedFilesFromMessage,
filterModelTesterModelNames,
finalizeIncompleteMessage,
findLastLoadingAssistantIndex,
parseCustomRequestBody,
parseModelTesterSession,
processThinkTags,
resolveConversationReplayFiles,
serializeModelTesterSession,
syncCustomRequestBodyToMessages,
syncMessagesToCustomRequestBody,
type ChatMessage,
type ConversationDraftFile,
type ConversationContentPart,
type ConversationUploadedFile,
type DebugTab,
@@ -44,6 +47,12 @@ import {
type TestTargetFormat,
type TestChatPayload,
} from './helpers/modelTesterSession.js';
import {
buildConversationFileAccept,
buildConversationFileHint,
isConversationUploadedFileSupported,
resolveConversationFileCapability,
} from './helpers/conversationFileCapabilities.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js';
@@ -67,15 +76,9 @@ type UploadState = {
dataUrl: string;
};
type ConversationFileState = UploadState & {
localId: string;
fileId?: string | null;
status: 'pending' | 'uploading' | 'uploaded' | 'error';
errorMessage?: string | null;
};
type ConversationFileState = ConversationDraftFile;
const POLL_INTERVAL_MS = 1200;
const CONVERSATION_FILE_ACCEPT = '.pdf,.txt,.md,.markdown,.json,image/*,audio/*';
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const createConversationFileLocalId = () =>
@@ -688,6 +691,19 @@ export default function ModelTester() {
const restoredSessionRef = useRef<ReturnType<typeof parseModelTesterSession>>(null);
const streamAbortRef = useRef<AbortController | null>(null);
const streamStopRequestedRef = useRef(false);
const conversationFileCapability = useMemo(
() => resolveConversationFileCapability(inputs.protocol),
[inputs.protocol],
);
const conversationFileSupported = conversationFileCapability.supported;
const conversationFileAccept = useMemo(
() => buildConversationFileAccept(conversationFileCapability),
[conversationFileCapability],
);
const conversationFileHint = useMemo(
() => buildConversationFileHint(conversationFileCapability),
[conversationFileCapability],
);
const pushDebug = useCallback((level: DebugTimelineEntry['level'], text: string) => {
const now = new Date().toISOString();
@@ -748,6 +764,7 @@ export default function ModelTester() {
setAssetPrompt(restored.modeState.imagesPrompt || restored.modeState.videosPrompt);
setVideoInspectId(restored.modeState.videosInspectId);
setVideoInspectAction(restored.inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET');
setConversationFiles(restored.conversationFiles);
if (restored.pendingJobId) {
setSending(true);
@@ -808,6 +825,7 @@ export default function ModelTester() {
inputs,
parameterEnabled,
messages,
conversationFiles,
modeState: {
embeddingsInput: embeddingInputText,
searchQuery: searchQueryValue,
@@ -833,6 +851,7 @@ export default function ModelTester() {
input,
inputs,
messages,
conversationFiles,
assetPrompt,
customRequestBody,
embeddingInputText,
@@ -879,38 +898,41 @@ export default function ModelTester() {
name: file.name,
mimeType: file.type || 'application/octet-stream',
dataUrl: await readFileAsDataUrl(file),
fileId: null,
status: 'pending' as const,
errorMessage: null,
})));
setConversationFiles((prev) => [...prev, ...nextFiles]);
pushDebug('info', `已添加 ${nextFiles.length} 个会话附件。`);
fileId: null,
status: 'pending' as const,
errorMessage: null,
})));
const acceptedFiles = nextFiles.filter((file) => isConversationUploadedFileSupported(
conversationFileCapability,
{ filename: file.name, mimeType: file.mimeType },
));
const rejectedFiles = nextFiles.filter((file) => !isConversationUploadedFileSupported(
conversationFileCapability,
{ filename: file.name, mimeType: file.mimeType },
));
if (acceptedFiles.length > 0) {
setConversationFiles((prev) => [...prev, ...acceptedFiles]);
pushDebug('info', `已添加 ${acceptedFiles.length} 个会话附件。`);
}
if (rejectedFiles.length > 0) {
const message = `当前协议不支持这些会话附件:${rejectedFiles.map((file) => file.name).join('、')}。${conversationFileHint}`;
setError(message);
pushDebug('warn', message);
}
} catch (readError: any) {
const message = readError?.message || '读取附件失败';
setError(message);
pushDebug('error', message);
}
}, [pushDebug]);
}, [conversationFileCapability, conversationFileHint, pushDebug]);
const removeConversationFile = useCallback((localId: string) => {
if (sending) return;
setConversationFiles((prev) => prev.filter((item) => item.localId !== localId));
}, [sending]);
const extractUploadedFilesFromMessage = useCallback((message: ChatMessage): ConversationUploadedFile[] => {
const parts = Array.isArray(message.parts) ? message.parts : [];
return parts.flatMap((part) => {
if (part.type !== 'input_file') return [];
const fileId = typeof part.fileId === 'string' ? part.fileId.trim() : '';
if (!fileId) return [];
return [{
fileId,
filename: typeof part.filename === 'string' && part.filename.trim() ? part.filename.trim() : null,
mimeType: typeof part.mimeType === 'string' && part.mimeType.trim() ? part.mimeType.trim() : null,
}];
});
}, []);
const uploadConversationFiles = useCallback(async (): Promise<ConversationUploadedFile[]> => {
if (conversationFiles.length <= 0) return [];
@@ -970,6 +992,41 @@ export default function ModelTester() {
return uploaded;
}, [conversationFiles, pushDebug]);
const inlineConversationFiles = useCallback((): ConversationUploadedFile[] =>
conversationFiles.map((item) => ({
fileId: item.fileId,
filename: item.name,
mimeType: item.mimeType,
data: item.dataUrl,
})), [conversationFiles]);
const ensureSupportedConversationFiles = useCallback((files: ConversationUploadedFile[]): boolean => {
const unsupported = files.filter((file) => !isConversationUploadedFileSupported(conversationFileCapability, file));
if (unsupported.length <= 0) return true;
const names = unsupported.map((file, index) => {
const filename = typeof file.filename === 'string' ? file.filename.trim() : '';
return filename || `附件${index + 1}`;
});
const message = `当前协议不支持这些会话附件:${names.join('、')}。${conversationFileHint}`;
setError(message);
pushDebug('warn', message);
return false;
}, [conversationFileCapability, conversationFileHint, pushDebug]);
const loadLocalConversationFile = useCallback(async (fileId: string) => {
const resolved = await api.getProxyFileContentDataUrl(fileId) as {
filename?: string | null;
mimeType?: string | null;
data: string;
};
return {
filename: resolved.filename || null,
mimeType: resolved.mimeType || null,
data: resolved.data,
};
}, []);
const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => {
if (!inputs.systemPrompt.trim()) return baseMessages;
return [
@@ -1339,7 +1396,6 @@ export default function ModelTester() {
() => filteredModels.map((item) => ({ value: item, label: item })),
[filteredModels],
);
const conversationFileSupported = inputs.protocol === 'openai' || inputs.protocol === 'responses';
const canSend = useMemo(() => {
if (sending || pendingJobId || !inputs.model) return false;
if (inputs.mode !== 'conversation') {
@@ -1762,7 +1818,19 @@ export default function ModelTester() {
baseMessages: ChatMessage[],
files: ConversationUploadedFile[] = [],
) => {
const userMessage = createConversationUserMessage(prompt, files);
let resolvedFiles = files;
try {
resolvedFiles = await resolveConversationReplayFiles(files, inputs.protocol, loadLocalConversationFile);
} catch (resolveError: any) {
const message = resolveError?.message || '读取会话附件失败';
setError(message);
pushDebug('error', message);
return;
}
if (!ensureSupportedConversationFiles(resolvedFiles)) {
return;
}
const userMessage = createConversationUserMessage(prompt, resolvedFiles);
const loadingAssistant = createLoadingAssistantMessage();
const nextMessages = [...baseMessages, userMessage, loadingAssistant];
const useProxyTransport = inputs.protocol === 'gemini' || customRequestMode;
@@ -1780,7 +1848,7 @@ export default function ModelTester() {
}
await dispatchPayload(nextMessages, payload, { syncedCustomBody });
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, createConversationUserMessage, customRequestMode, dispatchPayload, dispatchProxyEnvelope, inputs.protocol, pushDebug]);
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, createConversationUserMessage, customRequestMode, dispatchPayload, dispatchProxyEnvelope, ensureSupportedConversationFiles, inputs.protocol, loadLocalConversationFile, pushDebug]);
const sendModeRequest = useCallback(async () => {
const envelope = buildModeProxyEnvelope();
@@ -1808,7 +1876,7 @@ export default function ModelTester() {
}
if (conversationFiles.length > 0 && !conversationFileSupported) {
const message = '当前协议的会话附件仅支持 OpenAI / Responses;请切换协议或移除附件。';
const message = conversationFileCapability.reason || '当前协议暂不支持会话附件。';
setError(message);
pushDebug('warn', message);
return;
@@ -1817,7 +1885,14 @@ export default function ModelTester() {
if (!customRequestMode && conversationFileSupported && conversationFiles.length > 0) {
setSending(true);
try {
const uploadedFiles = await uploadConversationFiles();
const draftFiles = inlineConversationFiles();
if (!ensureSupportedConversationFiles(draftFiles)) {
setSending(false);
return;
}
const uploadedFiles = conversationFileCapability.documentMode === 'inline_only'
? draftFiles
: await uploadConversationFiles();
setInput('');
setConversationFiles([]);
await sendWithPrompt(trimmed, messages, uploadedFiles);
@@ -1855,7 +1930,7 @@ export default function ModelTester() {
{ stream: inputs.stream, jobMode: !inputs.stream },
),
);
}, [canSend, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, dispatchPayload, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt, uploadConversationFiles]);
}, [canSend, conversationFileCapability, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, dispatchPayload, ensureSupportedConversationFiles, inlineConversationFiles, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt, uploadConversationFiles]);
const retryPending = useCallback(async () => {
if (sending || pendingJobId || !pendingPayload) return;
@@ -2032,11 +2107,11 @@ export default function ModelTester() {
const base = messages.slice(0, userIndex);
const prompt = messages[userIndex].content;
const files = extractUploadedFilesFromMessage(messages[userIndex]);
const files = extractConversationUploadedFilesFromMessage(messages[userIndex]);
setEditingMessageId(null);
setEditValue('');
void sendWithPrompt(prompt, base, files);
}, [extractUploadedFilesFromMessage, messages, pendingJobId, sendWithPrompt, sending]);
}, [messages, pendingJobId, sendWithPrompt, sending]);
const startEditMessage = useCallback((target: ChatMessage) => {
if (sending) return;
@@ -2070,9 +2145,9 @@ export default function ModelTester() {
if (retry && target.role === 'user') {
const base = updated.slice(0, targetIndex);
void sendWithPrompt(nextContent, base, extractUploadedFilesFromMessage(target));
void sendWithPrompt(nextContent, base, extractConversationUploadedFilesFromMessage(target));
}
}, [cancelEditMessage, editValue, editingMessageId, extractUploadedFilesFromMessage, messages, sendWithPrompt]);
}, [cancelEditMessage, editValue, editingMessageId, messages, sendWithPrompt]);
const syncMessageToBody = useCallback(() => {
const nextBody = syncMessagesToCustomRequestBody(customRequestBody, messages, inputs);
@@ -2791,7 +2866,7 @@ export default function ModelTester() {
ref={conversationFileInputRef}
type="file"
multiple
accept={CONVERSATION_FILE_ACCEPT}
accept={conversationFileAccept}
style={{ display: 'none' }}
onChange={(event) => {
void handleConversationFilesChange(event.target.files);
@@ -2812,8 +2887,8 @@ export default function ModelTester() {
{customRequestMode
? '自定义请求模式不会自动上传这些附件;关闭自定义模式后可走标准 /v1/files 链路。'
: !conversationFileSupported
? '当前协议暂不支持会话附件注入;请切换到 OpenAI 或 Responses。'
: '支持 PDF / TXT / Markdown / JSON / 图片 / 音频;发送前会先上传到 /v1/files。'}
? (conversationFileCapability.reason || '当前协议暂不支持会话附件注入。')
: conversationFileHint}
</span>
</div>
{conversationFiles.length > 0 && (
+81 -1
View File
@@ -42,7 +42,9 @@ describe('NotificationSettings', () => {
telegramEnabled: true,
telegramApiBaseUrl: 'https://tg-proxy.example.com',
telegramChatId: '-1001234567890',
telegramMessageThreadId: '77',
telegramBotTokenMasked: '1234****token',
telegramUseSystemProxy: false,
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
@@ -55,6 +57,7 @@ describe('NotificationSettings', () => {
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
telegramApiBaseUrl: 'https://proxy.example.com/custom',
telegramMessageThreadId: '88',
telegramBotTokenMasked: '1234****token',
});
apiMock.testNotification.mockResolvedValue({ success: true });
@@ -64,7 +67,7 @@ describe('NotificationSettings', () => {
vi.clearAllMocks();
});
it('loads and saves telegram api base url', async () => {
it('loads and saves telegram api base url and topic id', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
@@ -84,8 +87,15 @@ describe('NotificationSettings', () => {
));
expect(proxyInput.props.value).toBe('https://tg-proxy.example.com');
const topicInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '例如: 77'
));
expect(topicInput.props.value).toBe('77');
await act(async () => {
proxyInput.props.onChange({ target: { value: 'https://proxy.example.com/custom/' } });
topicInput.props.onChange({ target: { value: '88' } });
});
const saveButton = root.root.find((node) => (
@@ -100,6 +110,76 @@ describe('NotificationSettings', () => {
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({
telegramApiBaseUrl: 'https://proxy.example.com/custom/',
telegramMessageThreadId: '88',
}));
} finally {
root?.unmount();
}
});
it('loads and saves telegram use system proxy toggle', async () => {
apiMock.getRuntimeSettings.mockResolvedValue({
webhookUrl: '',
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: true,
telegramApiBaseUrl: 'https://api.telegram.org',
telegramChatId: '-1001234567890',
telegramBotTokenMasked: '1234****token',
telegramUseSystemProxy: false,
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUser: '',
smtpFrom: '',
smtpTo: '',
notifyCooldownSec: 300,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<NotificationSettings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const allCheckboxes = root.root.findAll((node) => (
node.type === 'input' && node.props.type === 'checkbox'
));
const proxyCheckbox = allCheckboxes.find((node) => {
const parent = node.parent;
if (!parent) return false;
const text = collectText(parent);
return text.includes('使用系统代理');
});
expect(proxyCheckbox).toBeTruthy();
expect(proxyCheckbox!.props.checked).toBe(false);
await act(async () => {
proxyCheckbox!.props.onChange({ target: { checked: true } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存通知设置')
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({
telegramUseSystemProxy: true,
}));
} finally {
root?.unmount();
+38 -9
View File
@@ -12,6 +12,8 @@ type RuntimeSettings = {
telegramEnabled: boolean;
telegramApiBaseUrl: string;
telegramChatId: string;
telegramUseSystemProxy: boolean;
telegramMessageThreadId: string;
smtpEnabled: boolean;
smtpHost: string;
smtpPort: number;
@@ -35,6 +37,8 @@ export default function NotificationSettings() {
telegramEnabled: false,
telegramApiBaseUrl: 'https://api.telegram.org',
telegramChatId: '',
telegramUseSystemProxy: false,
telegramMessageThreadId: '',
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
@@ -78,6 +82,8 @@ export default function NotificationSettings() {
telegramEnabled: !!runtimeInfo.telegramEnabled,
telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org',
telegramChatId: runtimeInfo.telegramChatId || '',
telegramUseSystemProxy: !!runtimeInfo.telegramUseSystemProxy,
telegramMessageThreadId: runtimeInfo.telegramMessageThreadId || '',
smtpEnabled: !!runtimeInfo.smtpEnabled,
smtpHost: runtimeInfo.smtpHost || '',
smtpPort: Number(runtimeInfo.smtpPort) || 587,
@@ -115,6 +121,8 @@ export default function NotificationSettings() {
telegramEnabled: runtime.telegramEnabled,
telegramApiBaseUrl: runtime.telegramApiBaseUrl,
telegramChatId: runtime.telegramChatId,
telegramUseSystemProxy: runtime.telegramUseSystemProxy,
telegramMessageThreadId: runtime.telegramMessageThreadId,
smtpEnabled: runtime.smtpEnabled,
smtpHost: runtime.smtpHost,
smtpPort: runtime.smtpPort,
@@ -317,15 +325,26 @@ export default function NotificationSettings() {
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.telegramEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Telegram</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.telegramEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramEnabled: e.target.checked }))}
/>
</label>
<div style={{ display: 'flex', gap: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.telegramUseSystemProxy ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>使用系统代理</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.telegramUseSystemProxy}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramUseSystemProxy: e.target.checked }))}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.telegramEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Telegram</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.telegramEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramEnabled: e.target.checked }))}
/>
</label>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '16px 20px', opacity: runtime.telegramEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
@@ -352,6 +371,16 @@ export default function NotificationSettings() {
disabled={!runtime.telegramEnabled}
/>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram Topic ID</div>
<input
value={runtime.telegramMessageThreadId}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramMessageThreadId: e.target.value }))}
placeholder="例如: 77"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
Telegram Bot Token
+1
View File
@@ -26,6 +26,7 @@ const TYPE_OPTIONS = [
{ value: 'token', label: '令牌' },
{ value: 'proxy', label: '代理' },
{ value: 'status', label: '状态' },
{ value: 'site_notice', label: '站点公告' },
];
function levelLabel(level: string) {
+63 -2
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js';
import ProxyLogs from './ProxyLogs.js';
@@ -62,6 +63,10 @@ function buildListResponse(overrides?: Partial<{
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'heuristic',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
@@ -77,6 +82,10 @@ function buildListResponse(overrides?: Partial<{
totalCost: 1.23,
totalTokensAll: 15,
},
clientOptions: [
{ value: 'app:cherry_studio', label: '应用 · Cherry Studio' },
{ value: 'family:codex', label: '协议 · Codex' },
],
...overrides,
};
}
@@ -105,6 +114,10 @@ describe('ProxyLogs server-driven page', () => {
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'heuristic',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
@@ -171,13 +184,43 @@ describe('ProxyLogs server-driven page', () => {
expect(text).toContain('全部 12');
expect(text).toContain('成功 8');
expect(text).toContain('失败 4');
expect(text).toContain('Cherry Studio');
expect(text).toContain('Codex');
expect(text).toContain('推测');
expect(text).toContain('下游 Key: 移动端灰度');
} finally {
root?.unmount();
}
});
it('re-queries the server for status and search changes instead of filtering locally', async () => {
it('keeps the model badge sized to the model name in desktop rows', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/logs']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const modelBadge = root!.root.find((node) => (
node.type === 'span'
&& collectText(node) === 'gpt-4o'
&& node.props.style?.display === 'inline-flex'
));
expect(modelBadge.props.style?.alignSelf).toBe('flex-start');
} finally {
root?.unmount();
}
});
it('re-queries the server for status, client, and search changes instead of filtering locally', async () => {
let root: ReturnType<typeof create> | null = null;
try {
@@ -200,6 +243,15 @@ describe('ProxyLogs server-driven page', () => {
});
await flushMicrotasks();
const selects = root!.root.findAllByType(ModernSelect);
const clientSelect = selects.find((node) => node.props.placeholder === '全部客户端');
expect(clientSelect).toBeDefined();
await act(async () => {
clientSelect!.props.onChange('app:cherry_studio');
});
await flushMicrotasks();
const searchInput = root!.root.find((node) => (
node.type === 'input' && node.props.placeholder === '搜索模型、下游 Key、主分组、标签...'
));
@@ -214,11 +266,19 @@ describe('ProxyLogs server-driven page', () => {
status: 'failed',
search: '',
});
expect(apiMock.getProxyLogs).toHaveBeenNthCalledWith(3, {
limit: 50,
offset: 0,
status: 'failed',
search: '',
client: 'app:cherry_studio',
});
expect(apiMock.getProxyLogs).toHaveBeenLastCalledWith({
limit: 50,
offset: 0,
status: 'failed',
search: 'mini',
client: 'app:cherry_studio',
});
} finally {
root?.unmount();
@@ -274,7 +334,7 @@ describe('ProxyLogs server-driven page', () => {
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/logs?siteId=9&from=2026-03-09T08:00&to=2026-03-09T09:00']}>
<MemoryRouter initialEntries={['/logs?siteId=9&client=family%3Acodex&from=2026-03-09T08:00&to=2026-03-09T09:00']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>
@@ -291,6 +351,7 @@ describe('ProxyLogs server-driven page', () => {
status: 'all',
search: '',
siteId: 9,
client: 'family:codex',
from: expectedFrom,
to: expectedTo,
});
+125 -4
View File
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import {
api,
type ProxyLogBillingDetails,
type ProxyLogClientOption,
type ProxyLogDetail,
type ProxyLogListItem,
type ProxyLogsSummary,
@@ -35,6 +36,12 @@ type ProxyLogDetailState = {
const PAGE_SIZES = [20, 50, 100];
const DEFAULT_PAGE_SIZE = 50;
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
codex: 'Codex',
claude_code: 'Claude Code',
gemini_cli: 'Gemini CLI',
generic: '通用',
};
const EMPTY_SUMMARY: ProxyLogsSummary = {
totalCount: 0,
successCount: 0,
@@ -153,6 +160,12 @@ function normalizeRouteSearch(raw: string | null): string {
return (raw || '').trim();
}
function normalizeRouteClient(raw: string | null): string {
const text = (raw || '').trim();
if (!text) return '';
return /^((app|family):)/i.test(text) ? text : '';
}
function normalizeRouteSiteId(raw: string | null): number | null {
const parsed = Number.parseInt(raw || '', 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
@@ -174,6 +187,7 @@ function readProxyLogsRouteState(search: string) {
pageSize: normalizeRoutePageSize(params.get('pageSize')),
status: normalizeRouteStatus(params.get('status')),
search: normalizeRouteSearch(params.get('q')),
client: normalizeRouteClient(params.get('client')),
siteId: normalizeRouteSiteId(params.get('siteId')),
from: normalizeRouteDateTimeInput(params.get('from')),
to: normalizeRouteDateTimeInput(params.get('to')),
@@ -185,6 +199,7 @@ function buildProxyLogsRouteSearch(input: {
pageSize: number;
status: ProxyLogStatusFilter;
search: string;
client: string;
siteId: number | null;
from: string;
to: string;
@@ -194,6 +209,7 @@ function buildProxyLogsRouteSearch(input: {
if (input.pageSize !== DEFAULT_PAGE_SIZE) params.set('pageSize', String(input.pageSize));
if (input.status !== 'all') params.set('status', input.status);
if (input.search.trim()) params.set('q', input.search.trim());
if (input.client.trim()) params.set('client', input.client.trim());
if (input.siteId) params.set('siteId', String(input.siteId));
if (input.from.trim()) params.set('from', input.from.trim());
if (input.to.trim()) params.set('to', input.to.trim());
@@ -201,6 +217,66 @@ function buildProxyLogsRouteSearch(input: {
return next ? `?${next}` : '';
}
function formatProxyLogClientFamilyLabel(clientFamily?: string | null, options?: { includeGeneric?: boolean }) {
const normalized = typeof clientFamily === 'string' ? clientFamily.trim().toLowerCase() : '';
if (!normalized) return null;
if (!options?.includeGeneric && normalized === 'generic') return null;
return PROXY_LOG_CLIENT_FAMILY_LABELS[normalized] || clientFamily || null;
}
function resolveProxyLogClientDisplay(
log: Pick<ProxyLogRenderItem, 'clientFamily' | 'clientAppName' | 'clientConfidence'>,
options?: { includeGeneric?: boolean },
) {
const familyLabel = formatProxyLogClientFamilyLabel(log.clientFamily, options);
const appName = typeof log.clientAppName === 'string' ? log.clientAppName.trim() : '';
if (appName) {
return {
primary: appName,
secondary: familyLabel,
heuristic: log.clientConfidence === 'heuristic',
};
}
return {
primary: familyLabel,
secondary: null,
heuristic: false,
};
}
function renderProxyLogClientCell(
log: Pick<ProxyLogRenderItem, 'clientFamily' | 'clientAppName' | 'clientConfidence'>,
options?: { includeGeneric?: boolean },
) {
const display = resolveProxyLogClientDisplay(log, options);
if (!display.primary) {
return <span style={{ color: 'var(--color-text-muted)' }}>-</span>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span>{display.primary}</span>
{display.heuristic ? (
<span
className="badge"
style={{
fontSize: 10,
color: 'var(--color-text-muted)',
borderColor: 'var(--color-border)',
}}
>
推测
</span>
) : null}
</div>
{display.secondary ? (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{display.secondary}</span>
) : null}
</div>
);
}
function toApiTimeBoundary(value: string): string | undefined {
const text = value.trim();
if (!text) return undefined;
@@ -220,6 +296,7 @@ export default function ProxyLogs() {
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>(initialRouteState.status);
const [searchInput, setSearchInput] = useState(initialRouteState.search);
const deferredSearchInput = useDeferredValue(searchInput.trim());
const [clientFilter, setClientFilter] = useState(initialRouteState.client);
const [siteFilter, setSiteFilter] = useState<number | null>(initialRouteState.siteId);
const [fromInput, setFromInput] = useState(initialRouteState.from);
const [toInput, setToInput] = useState(initialRouteState.to);
@@ -229,6 +306,7 @@ export default function ProxyLogs() {
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
const [showFilters, setShowFilters] = useState(false);
const [sites, setSites] = useState<Array<{ id: number; name: string; status?: string | null }>>([]);
const [clientOptions, setClientOptions] = useState<ProxyLogClientOption[]>([]);
const [autoRefresh, setAutoRefresh] = useState(false);
const isMobile = useIsMobile(768);
const toast = useToast();
@@ -245,6 +323,7 @@ export default function ProxyLogs() {
const next = readProxyLogsRouteState(location.search);
setStatusFilter((current) => (current === next.status ? current : next.status));
setSearchInput((current) => (current === next.search ? current : next.search));
setClientFilter((current) => (current === next.client ? current : next.client));
setSiteFilter((current) => (current === next.siteId ? current : next.siteId));
setFromInput((current) => (current === next.from ? current : next.from));
setToInput((current) => (current === next.to ? current : next.to));
@@ -258,13 +337,14 @@ export default function ProxyLogs() {
pageSize,
status: statusFilter,
search: searchInput,
client: clientFilter,
siteId: siteFilter,
from: fromInput,
to: toInput,
});
if (nextSearch === location.search) return;
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
}, [fromInput, location.pathname, location.search, navigate, page, pageSize, searchInput, siteFilter, statusFilter, toInput]);
}, [clientFilter, fromInput, location.pathname, location.search, navigate, page, pageSize, searchInput, siteFilter, statusFilter, toInput]);
useEffect(() => {
let cancelled = false;
@@ -326,6 +406,20 @@ export default function ProxyLogs() {
];
}, [siteFilter, sites]);
const resolvedClientOptions = useMemo(() => {
const options = [...clientOptions];
if (clientFilter && !options.some((option) => option.value === clientFilter)) {
options.unshift({
value: clientFilter,
label: clientFilter,
});
}
return [
{ value: '', label: '全部客户端' },
...options,
];
}, [clientFilter, clientOptions]);
const activeSiteLabel = useMemo(() => {
if (!siteFilter) return '全部站点';
return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`;
@@ -357,6 +451,7 @@ export default function ProxyLogs() {
offset: currentOffset,
status: statusFilter,
search: deferredSearchInput,
...(clientFilter ? { client: clientFilter } : {}),
...(siteFilter ? { siteId: siteFilter } : {}),
...(fromApiBoundary ? { from: fromApiBoundary } : {}),
...(toApiBoundaryValue ? { to: toApiBoundaryValue } : {}),
@@ -366,13 +461,14 @@ export default function ProxyLogs() {
setLogs(Array.isArray(data.items) ? data.items : []);
setTotal(Number(data.total || 0));
setSummary(data.summary || EMPTY_SUMMARY);
setClientOptions(Array.isArray(data.clientOptions) ? data.clientOptions : []);
} catch (e: any) {
if (seq !== loadSeq.current) return;
if (!silent) toast.error(e.message || '加载日志失败');
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
}, [clientFilter, currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
useEffect(() => {
void load();
@@ -450,6 +546,18 @@ export default function ProxyLogs() {
</button>
))}
</div>
<div className="proxy-logs-filter-select">
<ModernSelect
size="sm"
value={clientFilter}
onChange={(nextValue) => {
setClientFilter(nextValue);
setPage(1);
}}
options={resolvedClientOptions}
placeholder="全部客户端"
/>
</div>
<div className="proxy-logs-filter-select">
<ModernSelect
size="sm"
@@ -504,6 +612,7 @@ export default function ProxyLogs() {
className="btn btn-ghost proxy-logs-filter-reset"
onClick={() => {
setStatusFilter('all');
setClientFilter('');
setSiteFilter(null);
setFromInput('');
setToInput('');
@@ -620,6 +729,7 @@ export default function ProxyLogs() {
>
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
<MobileField label="客户端" value={renderProxyLogClientCell(detailLog)} />
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
@@ -634,6 +744,7 @@ export default function ProxyLogs() {
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
<MobileField label="客户端详情" value={renderProxyLogClientCell(detailLog, { includeGeneric: true })} />
{downstreamKeySummary && <div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>}
{billingProcessLines.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
@@ -668,6 +779,7 @@ export default function ProxyLogs() {
<th>时间</th>
<th>模型</th>
<th>站点</th>
<th>客户端</th>
<th>{tr('状态')}</th>
<th style={{ textAlign: 'center' }}>用时</th>
<th style={{ textAlign: 'right' }}>输入</th>
@@ -711,7 +823,7 @@ export default function ProxyLogs() {
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<ModelBadge model={log.modelRequested} />
<ModelBadge model={log.modelRequested} style={{ alignSelf: 'flex-start' }} />
{downstreamKeySummary ? (
<div style={{ fontSize: 11, lineHeight: 1.45, color: 'var(--color-text-muted)' }}>
{downstreamKeySummary}
@@ -722,6 +834,9 @@ export default function ProxyLogs() {
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{renderProxyLogClientCell(detailLog)}
</td>
<td>
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
<span style={{
@@ -763,7 +878,7 @@ export default function ProxyLogs() {
</tr>
{expanded === log.id && (
<tr style={{ background: 'var(--color-bg)' }}>
<td colSpan={10} style={{ padding: 0 }}>
<td colSpan={11} style={{ padding: 0 }}>
<div className="anim-collapse is-open">
<div className="anim-collapse-inner">
<div className="animate-fade-in" style={{
@@ -796,6 +911,12 @@ export default function ProxyLogs() {
{billingDetailSummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
)}
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
<span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>客户端</span>
<div style={{ minWidth: 0 }}>
{renderProxyLogClientCell(detailLog, { includeGeneric: true })}
</div>
</div>
{downstreamKeySummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>
)}
+200 -32
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
@@ -17,15 +17,29 @@ import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js';
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
const FACTORY_RESET_CONFIRM_SECONDS = 3;
const CHECKIN_SCHEDULE_MODE_OPTIONS = [
{ value: 'cron', label: 'Cron' },
{ value: 'interval', label: '间隔签到' },
] as const;
const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => {
const hour = index + 1;
return {
value: String(hour),
label: `${hour} 小时`,
};
});
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
type RuntimeSettings = {
checkinCron: string;
checkinScheduleMode: 'cron' | 'interval';
checkinIntervalHours: number;
balanceRefreshCron: string;
logCleanupCron: string;
logCleanupUsageLogsEnabled: boolean;
@@ -41,6 +55,11 @@ type RuntimeSettings = {
currentAdminIp?: string;
};
type SystemProxyTestState =
| { kind: 'success'; text: string }
| { kind: 'error'; text: string }
| null;
type DownstreamApiKeyItem = {
id: number;
name: string;
@@ -187,6 +206,8 @@ export default function Settings() {
const navigate = useNavigate();
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'cron',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '0 6 * * *',
logCleanupUsageLogsEnabled: false,
@@ -203,8 +224,11 @@ export default function Settings() {
const [maskedToken, setMaskedToken] = useState('');
const [loading, setLoading] = useState(true);
const [savingSchedule, setSavingSchedule] = useState(false);
const [testingCheckin, setTestingCheckin] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
@@ -397,6 +421,10 @@ export default function Settings() {
setMaskedToken(authInfo.masked || '****');
setRuntime({
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
checkinScheduleMode: runtimeInfo.checkinScheduleMode === 'interval' ? 'interval' : 'cron',
checkinIntervalHours: Number(runtimeInfo.checkinIntervalHours) >= 1
? Math.min(24, Math.trunc(Number(runtimeInfo.checkinIntervalHours)))
: 6,
balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *',
logCleanupCron: runtimeInfo.logCleanupCron || '0 6 * * *',
logCleanupUsageLogsEnabled: !!runtimeInfo.logCleanupUsageLogsEnabled,
@@ -488,6 +516,8 @@ export default function Settings() {
try {
await api.updateRuntimeSettings({
checkinCron: runtime.checkinCron,
checkinScheduleMode: runtime.checkinScheduleMode,
checkinIntervalHours: runtime.checkinIntervalHours,
balanceRefreshCron: runtime.balanceRefreshCron,
logCleanupCron: runtime.logCleanupCron,
logCleanupUsageLogsEnabled: runtime.logCleanupUsageLogsEnabled,
@@ -502,6 +532,18 @@ export default function Settings() {
}
};
const triggerScheduleCheckin = async () => {
setTestingCheckin(true);
try {
await api.triggerCheckinAll();
toast.success('已开始全部签到,请稍后查看签到日志');
} catch (err: any) {
toast.error(err?.message || '触发签到失败');
} finally {
setTestingCheckin(false);
}
};
const saveProxyToken = async () => {
const suffix = proxyTokenSuffix.trim();
if (!suffix) {
@@ -541,6 +583,31 @@ export default function Settings() {
}
};
const testSystemProxy = async () => {
const proxyUrl = runtime.systemProxyUrl.trim();
if (!proxyUrl) {
const message = '请先填写系统代理地址';
setSystemProxyTestState({ kind: 'error', text: message });
toast.info(message);
return;
}
setTestingSystemProxy(true);
setSystemProxyTestState(null);
try {
const res = await api.testSystemProxy({ proxyUrl });
const summary = `连通成功,延迟 ${res.latencyMs} ms`;
setSystemProxyTestState({ kind: 'success', text: summary });
toast.success(`系统代理测试成功(${res.latencyMs} ms)`);
} catch (err: any) {
const message = err?.message || '系统代理测试失败';
setSystemProxyTestState({ kind: 'error', text: message });
toast.error(message);
} finally {
setTestingSystemProxy(false);
}
};
const saveProxyFailureRules = async () => {
setSavingProxyFailureRules(true);
try {
@@ -953,6 +1020,39 @@ export default function Settings() {
<div className="card animate-slide-up stagger-2" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>定时任务</div>
<div style={{ display: 'grid', gridTemplateColumns: '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到方式</div>
<ModernSelect
value={runtime.checkinScheduleMode}
onChange={(value) => setRuntime((prev) => ({
...prev,
checkinScheduleMode: value === 'interval' ? 'interval' : 'cron',
}))}
options={CHECKIN_SCHEDULE_MODE_OPTIONS.map((item) => ({ ...item }))}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到间隔</div>
<ModernSelect
value={String(runtime.checkinIntervalHours)}
onChange={(value) => setRuntime((prev) => ({
...prev,
checkinIntervalHours: Math.min(24, Math.max(1, Math.trunc(Number(value) || 1))),
}))}
disabled={runtime.checkinScheduleMode !== 'interval'}
options={CHECKIN_INTERVAL_OPTIONS}
/>
</div>
<button
onClick={triggerScheduleCheckin}
disabled={testingCheckin}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}
>
{testingCheckin ? '触发中...' : '测试一次签到'}
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到 Cron</div>
@@ -960,6 +1060,7 @@ export default function Settings() {
value={runtime.checkinCron}
onChange={(e) => setRuntime((prev) => ({ ...prev, checkinCron: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
disabled={runtime.checkinScheduleMode !== 'cron'}
/>
</div>
<div>
@@ -1045,13 +1146,39 @@ export default function Settings() {
</div>
<input
value={runtime.systemProxyUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }))}
onChange={(e) => {
setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }));
setSystemProxyTestState(null);
}}
placeholder="系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }}
/>
<button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary">
{savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'}
</button>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary">
{savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'}
</button>
<button
onClick={testSystemProxy}
disabled={testingSystemProxy}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{testingSystemProxy ? <><span className="spinner spinner-sm" /> 测试中...</> : '测试系统代理'}
</button>
</div>
{systemProxyTestState && (
<div
style={{
fontSize: 12,
marginTop: 10,
color: systemProxyTestState.kind === 'success'
? 'var(--color-success)'
: 'var(--color-danger)',
}}
>
{systemProxyTestState.text}
</div>
)}
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
@@ -1096,42 +1223,83 @@ export default function Settings() {
</code>
<div
style={{
...inputStyle,
marginBottom: 10,
padding: 0,
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
gap: 10,
alignItems: 'stretch',
marginBottom: 10,
minWidth: 0,
flexWrap: 'wrap',
}}
>
<span
<div
style={{
padding: '10px 12px',
borderRight: '1px solid var(--color-border-light)',
fontFamily: 'var(--font-mono)',
fontSize: 13,
color: 'var(--color-text-secondary)',
userSelect: 'none',
...inputStyle,
flex: 1,
minWidth: 200,
marginBottom: 0,
padding: 0,
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
}}
>
{PROXY_TOKEN_PREFIX}
</span>
<input
type="password"
value={proxyTokenSuffix}
onChange={(e) => setProxyTokenSuffix(normalizeProxyTokenSuffix(e.target.value))}
placeholder="请输入 sk- 后的令牌内容"
<span
style={{
padding: '10px 12px',
borderRight: '1px solid var(--color-border-light)',
fontFamily: 'var(--font-mono)',
fontSize: 13,
color: 'var(--color-text-secondary)',
userSelect: 'none',
background: 'color-mix(in srgb, var(--color-text-muted) 6%, transparent)',
}}
>
{PROXY_TOKEN_PREFIX}
</span>
<input
type="text"
value={proxyTokenSuffix}
onChange={(e) => setProxyTokenSuffix(normalizeProxyTokenSuffix(e.target.value))}
placeholder="请输入 sk- 后的令牌内容"
style={{
flex: 1,
minWidth: 0,
border: 'none',
outline: 'none',
background: 'transparent',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-mono)',
fontSize: 13,
padding: '10px 12px',
}}
/>
</div>
<button
type="button"
className="btn btn-soft-primary"
aria-label="随机生成访问令牌后缀"
title="生成高熵随机后缀(不会自动保存)"
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-mono)',
flexShrink: 0,
padding: '10px 18px',
fontSize: 13,
padding: '10px 12px',
gap: 8,
alignSelf: 'stretch',
}}
/>
onClick={() => {
const full = generateDownstreamSkKey(PROXY_TOKEN_PREFIX);
setProxyTokenSuffix(full.slice(PROXY_TOKEN_PREFIX.length));
}}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
随机生成
</button>
</div>
<button onClick={saveProxyToken} disabled={savingToken} className="btn btn-primary">
{savingToken ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '更新下游访问令牌'}
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { clearFocusParams, readFocusAnnouncementId } from './helpers/navigationFocus.js';
import {
formatSiteAnnouncementSeenAt,
readClientTimeZone,
resolveSiteAnnouncementTimeZone,
SiteAnnouncementContent,
} from './helpers/siteAnnouncementPresentation.js';
import { tr } from '../i18n.js';
type SiteAnnouncementRow = {
id: number;
siteId: number;
platform: string;
sourceKey: string;
title: string;
content: string;
level: 'info' | 'warning' | 'error';
firstSeenAt?: string | null;
lastSeenAt?: string | null;
readAt?: string | null;
};
type SiteRow = {
id: number;
name: string;
platform?: string | null;
};
export default function SiteAnnouncements() {
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const [rows, setRows] = useState<SiteAnnouncementRow[]>([]);
const [sites, setSites] = useState<SiteRow[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [clearing, setClearing] = useState(false);
const [markingAll, setMarkingAll] = useState(false);
const [serverTimeZone, setServerTimeZone] = useState<string | undefined>(undefined);
const [highlightAnnouncementId, setHighlightAnnouncementId] = useState<number | null>(null);
const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const highlightTimerRef = useRef<number | null>(null);
const viewerTimeZone = useMemo(() => readClientTimeZone(), []);
const displayTimeZone = resolveSiteAnnouncementTimeZone(viewerTimeZone, serverTimeZone);
const siteNameById = useMemo(() => {
const map = new Map<number, string>();
for (const site of sites) {
map.set(site.id, site.name);
}
return map;
}, [sites]);
const load = async (silent = false) => {
if (silent) setRefreshing(true);
else setLoading(true);
try {
const [announcementRows, siteRows, runtimeInfo] = await Promise.all([
api.getSiteAnnouncements(),
api.getSites(),
api.getRuntimeSettings().catch(() => null),
]);
setRows(Array.isArray(announcementRows) ? announcementRows : []);
setSites(Array.isArray(siteRows) ? siteRows : []);
const nextServerTimeZone = typeof runtimeInfo?.serverTimeZone === 'string'
? runtimeInfo.serverTimeZone.trim()
: '';
setServerTimeZone(nextServerTimeZone || undefined);
} catch (error: any) {
toast.error(error?.message || '加载站点公告失败');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void load();
return () => {
const win = globalThis as typeof globalThis & {
clearTimeout?: typeof clearTimeout;
};
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') {
win.clearTimeout(highlightTimerRef.current);
}
};
}, []);
useEffect(() => {
const focusAnnouncementId = readFocusAnnouncementId(location.search);
if (!focusAnnouncementId || loading) return;
const row = rowRefs.current.get(focusAnnouncementId);
const nextSearch = clearFocusParams(location.search);
const win = globalThis as typeof globalThis & {
setTimeout?: typeof setTimeout;
clearTimeout?: typeof clearTimeout;
};
if (!row) {
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
return;
}
row.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
setHighlightAnnouncementId(focusAnnouncementId);
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') {
win.clearTimeout(highlightTimerRef.current);
}
if (typeof win.setTimeout === 'function') {
highlightTimerRef.current = win.setTimeout(() => {
setHighlightAnnouncementId((current) => (current === focusAnnouncementId ? null : current));
}, 2200);
}
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
}, [loading, location.pathname, location.search, navigate, rows]);
const clearAll = async () => {
setClearing(true);
try {
await api.clearSiteAnnouncements();
setRows([]);
toast.success('公告已清空');
} catch (error: any) {
toast.error(error?.message || '清空公告失败');
} finally {
setClearing(false);
}
};
const markAllRead = async () => {
setMarkingAll(true);
try {
await api.markAllSiteAnnouncementsRead();
setRows((current) => current.map((row) => ({
...row,
readAt: row.readAt || new Date().toISOString(),
})));
toast.success('已标记全部为已读');
} catch (error: any) {
toast.error(error?.message || '标记失败');
} finally {
setMarkingAll(false);
}
};
const triggerSync = async () => {
try {
await api.syncSiteAnnouncements();
toast.success('公告同步任务已启动');
} catch (error: any) {
toast.error(error?.message || '启动同步失败');
}
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('站点公告')}</h2>
<div className="page-actions">
<button
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
onClick={markAllRead}
disabled={markingAll}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
<button
onClick={triggerSync}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
手动同步
</button>
<button
onClick={clearAll}
disabled={clearing}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'}
</button>
</div>
</div>
<div className="card" style={{ padding: 0 }}>
{loading ? (
<div style={{ padding: 24, textAlign: 'center' }}>
<span className="spinner spinner-sm" />
</div>
) : rows.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center' }}>
<div className="empty-state-title">暂无公告</div>
<div className="empty-state-desc">当前没有可显示的站点公告。</div>
</div>
) : (
rows.map((row, index) => (
<div
key={row.id}
ref={(node) => {
if (node) rowRefs.current.set(row.id, node);
else rowRefs.current.delete(row.id);
}}
className={`animate-slide-up stagger-${Math.min(index + 1, 5)} ${highlightAnnouncementId === row.id ? 'row-focus-highlight' : ''}`.trim()}
style={{
padding: '16px 18px',
borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 6 }}>
<div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span className="badge badge-muted">{siteNameById.get(row.siteId) || `站点 #${row.siteId}`}</span>
<span className="badge badge-info">{row.platform}</span>
<span className={`badge ${row.readAt ? 'badge-muted' : 'badge-warning'}`}>{row.readAt ? '已读' : '未读'}</span>
</div>
</div>
<SiteAnnouncementContent content={row.content} />
<div
style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}
title={displayTimeZone ? `本地时区:${displayTimeZone}` : undefined}
>
首次发现:{formatSiteAnnouncementSeenAt(row.firstSeenAt || row.lastSeenAt || '', displayTimeZone)}
</div>
</div>
))
)}
</div>
</div>
);
}
+154 -14
View File
@@ -23,6 +23,16 @@ import {
type SiteForm,
} from './helpers/sitesEditor.js';
type SiteSubscriptionSummary = {
activeCount: number;
totalUsedUsd: number;
totalMonthlyLimitUsd?: number | null;
totalRemainingUsd?: number | null;
nextExpiresAt?: string | null;
planNames?: string[];
updatedAt?: number | null;
};
type SiteRow = {
id: number;
name: string;
@@ -30,12 +40,14 @@ type SiteRow = {
externalCheckinUrl?: string | null;
platform?: string;
status?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean;
customHeaders?: string | null;
globalWeight?: number;
isPinned?: boolean;
sortOrder?: number;
totalBalance?: number;
subscriptionSummary?: SiteSubscriptionSummary | null;
createdAt?: string;
};
@@ -43,6 +55,105 @@ function hasConfiguredCustomHeaders(customHeaders?: string | null): boolean {
return typeof customHeaders === 'string' && customHeaders.trim().length > 0;
}
function formatUsd(value?: number | null): string {
return `$${(value || 0).toFixed(2)}`;
}
function formatSubscriptionDate(value?: string | null): string {
if (!value) return '';
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return value;
return new Date(parsed).toISOString().slice(0, 10);
}
function formatRemainingDuration(value?: string | null): string | null {
if (!value) return null;
const targetMs = Date.parse(value);
if (!Number.isFinite(targetMs)) return null;
const deltaMs = targetMs - Date.now();
if (deltaMs <= 0) return '已到期';
const minuteMs = 60 * 1000;
const hourMs = 60 * minuteMs;
const dayMs = 24 * hourMs;
if (deltaMs >= dayMs) return `剩余${Math.ceil(deltaMs / dayMs)}天`;
if (deltaMs >= hourMs) return `剩余${Math.ceil(deltaMs / hourMs)}小时`;
if (deltaMs >= minuteMs) return `剩余${Math.ceil(deltaMs / minuteMs)}分钟`;
return `剩余${Math.max(1, Math.ceil(deltaMs / 1000))}秒`;
}
function buildSubscriptionInlineValue(summary?: SiteSubscriptionSummary | null): string | null {
if (!summary) return null;
const remainingValue = typeof summary.totalRemainingUsd === 'number' && Number.isFinite(summary.totalRemainingUsd)
? formatUsd(summary.totalRemainingUsd)
: '--';
const usedValue = formatUsd(summary.totalUsedUsd);
const remainingDuration = formatRemainingDuration(summary.nextExpiresAt);
const remainingSuffix = remainingDuration ? `(${remainingDuration})` : '';
if (usedValue === '$0.00' && remainingValue === '--' && !remainingSuffix) return null;
return `${remainingValue}${remainingSuffix}`;
}
function buildSubscriptionTooltip(summary?: SiteSubscriptionSummary | null): string | null {
if (!summary) return null;
const parts: string[] = [];
if (summary.activeCount > 0) parts.push(`生效订阅 ${summary.activeCount} 个`);
const planNames = Array.isArray(summary.planNames)
? summary.planNames.filter((item) => typeof item === 'string' && item.trim())
: [];
if (planNames.length > 0) parts.push(`套餐 ${planNames.join(' / ')}`);
if (typeof summary.totalRemainingUsd === 'number' && Number.isFinite(summary.totalRemainingUsd)) {
parts.push(`订阅余额 ${formatUsd(summary.totalRemainingUsd)}`);
}
parts.push(`已用 ${formatUsd(summary.totalUsedUsd)}`);
if (typeof summary.totalMonthlyLimitUsd === 'number' && Number.isFinite(summary.totalMonthlyLimitUsd)) {
parts.push(`总额度 ${formatUsd(summary.totalMonthlyLimitUsd)}`);
}
const remainingDuration = formatRemainingDuration(summary.nextExpiresAt);
if (remainingDuration) parts.push(remainingDuration);
if (summary.nextExpiresAt) parts.push(`到期 ${formatSubscriptionDate(summary.nextExpiresAt)}`);
return parts.join(' | ');
}
function SiteBalanceDisplay(props: {
balance?: number | null;
summary?: SiteSubscriptionSummary | null;
align?: 'start' | 'end';
}) {
const { balance, summary, align = 'start' } = props;
const walletBalanceText = formatUsd(balance);
const subscriptionValue = buildSubscriptionInlineValue(summary);
const tooltip = buildSubscriptionTooltip(summary);
return (
<div
className={`site-balance-inline ${align === 'end' ? 'align-end' : ''}`.trim()}
>
<span className="site-balance-primary">{walletBalanceText}</span>
{subscriptionValue ? (
<>
<span className="site-balance-divider">/</span>
<span
className="site-balance-subscription"
data-tooltip={tooltip || undefined}
data-tooltip-align={align === 'end' ? 'end' : 'start'}
data-tooltip-side="top"
tabIndex={tooltip ? 0 : undefined}
>
{subscriptionValue}
</span>
</>
) : null}
</div>
);
}
const platformColors: Record<string, string> = {
'new-api': 'badge-info',
'one-api': 'badge-success',
@@ -272,6 +383,7 @@ export default function Sites() {
url: form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(),
proxyUrl: form.proxyUrl.trim(),
useSystemProxy: !!form.useSystemProxy,
customHeaders: serializedCustomHeaders.customHeaders,
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
@@ -808,6 +920,15 @@ export default function Sites() {
</div>
)}
</div>
<input
placeholder="站点代理(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
value={form.proxyUrl}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
style={formInputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
</div>
<label style={{
display: 'flex',
alignItems: 'center',
@@ -873,7 +994,16 @@ export default function Sites() {
</span>
)}
/>
<MobileField label="余额" value={`$${(site.totalBalance || 0).toFixed(2)}`} />
<MobileField
label="余额"
value={(
<SiteBalanceDisplay
balance={site.totalBalance}
summary={site.subscriptionSummary}
align="end"
/>
)}
/>
<MobileField label="权重" value={(site.globalWeight || 1).toFixed(2)} />
{isExpanded ? (
<div className="mobile-card-extra">
@@ -1017,17 +1147,24 @@ export default function Sites() {
/>
</td>
<td style={{ fontWeight: 600 }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--color-text-primary)',
textDecoration: 'underline',
}}
>
{site.name}
</a>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--color-text-primary)',
textDecoration: 'underline',
}}
>
{site.name}
</a>
{hasConfiguredCustomHeaders(site.customHeaders) ? (
<span className="badge badge-info" style={{ fontSize: 11 }}>
自定义头
</span>
) : null}
</div>
</td>
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
{site.externalCheckinUrl ? (
@@ -1048,8 +1185,11 @@ export default function Sites() {
</a>
) : null}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
${(site.totalBalance || 0).toFixed(2)}
<td className="site-balance-cell">
<SiteBalanceDisplay
balance={site.totalBalance}
summary={site.subscriptionSummary}
/>
</td>
<td>
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
+21 -3
View File
@@ -88,6 +88,24 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
advancedOpen: false,
};
function normalizeRouteRoutingStrategyValue(value?: RouteRoutingStrategy | null): RouteRoutingStrategy {
if (value === 'round_robin' || value === 'stable_first') return value;
return 'weighted';
}
function getRouteRoutingStrategyLabel(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRouteRoutingStrategyValue(value);
if (strategy === 'round_robin') return tr('轮询');
if (strategy === 'stable_first') return tr('稳定优先');
return tr('权重随机');
}
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
if (value === 'round_robin') return '已切换为轮询策略';
if (value === 'stable_first') return '已切换为稳定优先策略';
return '已切换为权重随机策略';
}
export default function TokenRoutes() {
const navigate = useNavigate();
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
@@ -421,7 +439,7 @@ export default function TokenRoutes() {
};
const handleRoutingStrategyChange = async (route: RouteSummaryRow, routingStrategy: RouteRoutingStrategy) => {
const currentStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
const currentStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy);
if (routingStrategy === currentStrategy) return;
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: true }));
@@ -432,7 +450,7 @@ export default function TokenRoutes() {
)));
try {
await api.updateRoute(route.id, { routingStrategy });
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
toast.success(getRouteRoutingStrategySuccessMessage(routingStrategy));
} catch (e: any) {
setRouteSummaries((prev) => prev.map((item) => (
item.id === route.id
@@ -1186,7 +1204,7 @@ export default function TokenRoutes() {
>
<MobileField label="模型" value={route.modelPattern} />
<MobileField label="通道" value={route.channelCount} />
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : (route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机'))} />
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : getRouteRoutingStrategyLabel(route.routingStrategy)} />
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
<div className="mobile-card-actions">
<button
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
buildConversationFileAccept,
buildConversationFileHint,
isConversationUploadedFileSupported,
resolveConversationFileCapability,
} from './conversationFileCapabilities.js';
describe('resolveConversationFileCapability', () => {
it('keeps OpenAI and Responses on native file references', () => {
expect(resolveConversationFileCapability('openai')).toEqual({
supported: true,
imageMode: 'native',
audioMode: 'native',
documentMode: 'native',
reason: '',
});
expect(resolveConversationFileCapability('responses')).toEqual({
supported: true,
imageMode: 'native',
audioMode: 'native',
documentMode: 'native',
reason: '',
});
});
it('maps Claude and Gemini media support separately', () => {
expect(resolveConversationFileCapability('claude')).toEqual({
supported: true,
imageMode: 'native',
audioMode: 'unsupported',
documentMode: 'inline_only',
reason: '当前界面的会话附件会以内联文档方式发送。',
});
expect(resolveConversationFileCapability('gemini')).toEqual({
supported: true,
imageMode: 'native',
audioMode: 'native',
documentMode: 'inline_only',
reason: '当前界面的会话附件会以内联文档方式发送。',
});
});
it('builds the accept list from the supported file kinds', () => {
expect(buildConversationFileAccept(resolveConversationFileCapability('claude'))).toBe(
'.pdf,.txt,.md,.markdown,.json,image/*',
);
expect(buildConversationFileAccept(resolveConversationFileCapability('gemini'))).toBe(
'.pdf,.txt,.md,.markdown,.json,image/*,audio/*',
);
});
it('describes the actual per-kind transport behavior', () => {
expect(buildConversationFileHint(resolveConversationFileCapability('claude'))).toBe(
'支持 PDF / TXT / Markdown / JSON / 图片;文档会以内联数据注入,图片会按图片部件发送。',
);
expect(buildConversationFileHint(resolveConversationFileCapability('gemini'))).toBe(
'支持 PDF / TXT / Markdown / JSON / 图片 / 音频;文档会以内联数据注入,图片会按图片部件发送,音频会按音频部件发送。',
);
});
it('rejects unsupported file kinds before send', () => {
const claudeCapability = resolveConversationFileCapability('claude');
expect(isConversationUploadedFileSupported(claudeCapability, {
filename: 'paper.pdf',
mimeType: 'application/pdf',
})).toBe(true);
expect(isConversationUploadedFileSupported(claudeCapability, {
filename: 'chart.png',
mimeType: 'image/png',
})).toBe(true);
expect(isConversationUploadedFileSupported(claudeCapability, {
filename: 'voice.mp3',
mimeType: 'audio/mpeg',
})).toBe(false);
});
});
@@ -0,0 +1,155 @@
import type { PlaygroundProtocol } from './modelTesterSession.js';
export type ConversationFileTransportMode = 'native' | 'inline_only' | 'unsupported';
export type ConversationFileCapability = {
supported: boolean;
imageMode: ConversationFileTransportMode;
audioMode: ConversationFileTransportMode;
documentMode: ConversationFileTransportMode;
reason: string;
};
const DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json'] as const;
const DOCUMENT_MIME_TYPES = new Set([
'application/json',
'application/pdf',
'text/markdown',
'text/plain',
]);
const DOCUMENT_EXTENSIONS = ['.json', '.md', '.markdown', '.pdf', '.txt'] as const;
const IMAGE_EXTENSIONS = ['.avif', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'] as const;
const AUDIO_EXTENSIONS = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.wav', '.weba'] as const;
type ConversationFileDescriptor = {
filename?: string | null;
mimeType?: string | null;
};
type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown';
const isSupportedMode = (mode: ConversationFileTransportMode): boolean => mode !== 'unsupported';
const normalizeName = (value: string | null | undefined): string => (value || '').trim().toLowerCase();
function detectKindFromFilename(filename: string): ConversationFileKind {
if (!filename) return 'unknown';
if (DOCUMENT_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'document';
if (IMAGE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'image';
if (AUDIO_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'audio';
return 'unknown';
}
function resolveConversationFileKind(file: ConversationFileDescriptor): ConversationFileKind {
const mimeType = normalizeName(file.mimeType);
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('audio/')) return 'audio';
if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document';
return detectKindFromFilename(normalizeName(file.filename));
}
function buildSupportedTypeLabel(capability: ConversationFileCapability): string {
const labels: string[] = [];
if (isSupportedMode(capability.documentMode)) labels.push('PDF / TXT / Markdown / JSON');
if (isSupportedMode(capability.imageMode)) labels.push('图片');
if (isSupportedMode(capability.audioMode)) labels.push('音频');
return labels.join(' / ');
}
function buildTransportNotes(capability: ConversationFileCapability): string[] {
const notes: string[] = [];
if (capability.documentMode === 'inline_only') notes.push('文档会以内联数据注入');
if (capability.imageMode === 'native' && capability.documentMode === 'inline_only') {
notes.push('图片会按图片部件发送');
}
if (capability.audioMode === 'native' && capability.documentMode === 'inline_only') {
notes.push('音频会按音频部件发送');
}
return notes;
}
export function resolveConversationFileCapability(
protocol: PlaygroundProtocol,
): ConversationFileCapability {
if (protocol === 'openai' || protocol === 'responses') {
return {
supported: true,
imageMode: 'native',
audioMode: 'native',
documentMode: 'native',
reason: '',
};
}
if (protocol === 'claude') {
return {
supported: true,
imageMode: 'native',
audioMode: 'unsupported',
documentMode: 'inline_only',
reason: '当前界面的会话附件会以内联文档方式发送。',
};
}
if (protocol === 'gemini') {
return {
supported: true,
imageMode: 'native',
audioMode: 'native',
documentMode: 'inline_only',
reason: '当前界面的会话附件会以内联文档方式发送。',
};
}
return {
supported: false,
imageMode: 'unsupported',
audioMode: 'unsupported',
documentMode: 'unsupported',
reason: '当前协议暂不支持会话附件。',
};
}
export function buildConversationFileAccept(capability: ConversationFileCapability): string {
const parts: string[] = [];
if (isSupportedMode(capability.documentMode)) parts.push(...DOCUMENT_ACCEPT_PARTS);
if (isSupportedMode(capability.imageMode)) parts.push('image/*');
if (isSupportedMode(capability.audioMode)) parts.push('audio/*');
return parts.join(',');
}
export function buildConversationFileHint(capability: ConversationFileCapability): string {
if (!capability.supported) {
return capability.reason || '当前协议暂不支持会话附件。';
}
const typeLabel = buildSupportedTypeLabel(capability);
if (!typeLabel) {
return capability.reason || '当前协议暂不支持会话附件。';
}
if (
capability.documentMode === 'native'
&& capability.imageMode === 'native'
&& capability.audioMode === 'native'
) {
return `支持 ${typeLabel};发送前会先上传到 /v1/files。`;
}
const notes = buildTransportNotes(capability);
if (notes.length <= 0) {
return `支持 ${typeLabel}。`;
}
return `支持 ${typeLabel};${notes.join(',')}。`;
}
export function isConversationUploadedFileSupported(
capability: ConversationFileCapability,
file: ConversationFileDescriptor,
): boolean {
const kind = resolveConversationFileKind(file);
if (kind === 'document') return isSupportedMode(capability.documentMode);
if (kind === 'image') return isSupportedMode(capability.imageMode);
if (kind === 'audio') return isSupportedMode(capability.audioMode);
return false;
}
@@ -0,0 +1,6 @@
export function generateDownstreamSkKey(prefix: string): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${prefix}${hex}`;
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import * as modelTesterSessionModule from './modelTesterSession.js';
import {
DEBUG_TABS,
DEFAULT_INPUTS,
@@ -15,6 +16,7 @@ import {
collectModelTesterModelNames,
countConversationTurns,
createConversationUserMessage,
extractConversationUploadedFilesFromMessage,
filterModelTesterModelNames,
parseCustomRequestBody,
parseModelTesterSession,
@@ -79,6 +81,17 @@ describe('modelTesterSession', () => {
customRequestBody: '{"model":"gemini-2.5-pro","contents":[]}',
showDebugPanel: true,
activeDebugTab: DEBUG_TABS.REQUEST,
conversationFiles: [
{
localId: 'draft-file-1',
name: 'draft.pdf',
mimeType: 'application/pdf',
dataUrl: 'data:application/pdf;base64,JVBERi0x',
fileId: 'file-metapi-draft',
status: 'uploaded',
errorMessage: null,
},
],
modeState: {
...DEFAULT_MODE_STATE,
searchQuery: 'hello',
@@ -222,6 +235,87 @@ describe('modelTesterSession', () => {
]);
});
it('extracts inline conversation file data for retry flows', () => {
const files = extractConversationUploadedFilesFromMessage({
id: 'u1',
role: 'user',
content: '请总结附件',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0xLjQK',
},
],
});
expect(files).toEqual([
{
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0xLjQK',
},
]);
});
it('extracts uploaded file references for retry flows', () => {
const files = extractConversationUploadedFilesFromMessage({
id: 'u2',
role: 'user',
content: '请继续分析',
createAt: 2,
parts: [
{
type: 'input_file',
fileId: 'file-metapi-456',
filename: 'appendix.txt',
mimeType: 'text/plain',
},
],
});
expect(files).toEqual([
{
fileId: 'file-metapi-456',
filename: 'appendix.txt',
mimeType: 'text/plain',
},
]);
});
it('hydrates local file ids into inline replay files for claude', async () => {
const resolveConversationReplayFiles = (modelTesterSessionModule as Record<string, any>).resolveConversationReplayFiles;
const loader = vi.fn(async () => ({
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x',
}));
const files = await resolveConversationReplayFiles?.(
[
{
fileId: 'file-metapi-123',
filename: 'brief.pdf',
mimeType: 'application/pdf',
},
],
'claude',
loader,
);
expect(loader).toHaveBeenCalledWith('file-metapi-123');
expect(files).toEqual([
{
fileId: 'file-metapi-123',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x',
},
]);
});
it('preserves file reference parts when building responses conversation payloads', () => {
const payload = buildApiPayload(
[{
@@ -435,6 +529,276 @@ describe('modelTesterSession', () => {
});
});
it('prefers native file ids over inline backups for OpenAI and Responses payloads', () => {
const message = {
id: 'u1',
role: 'user' as const,
content: 'reuse native file id',
createAt: 1,
parts: [
{
type: 'input_file' as const,
fileId: 'file-metapi-123',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x',
},
],
};
const openAiPayload = buildApiPayload(
[message],
{
...DEFAULT_INPUTS,
model: 'gpt-4.1',
protocol: 'openai',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(openAiPayload.jsonBody).toEqual({
model: 'gpt-4.1',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'reuse native file id' },
{
type: 'file',
file: {
file_id: 'file-metapi-123',
filename: 'brief.pdf',
mime_type: 'application/pdf',
},
},
],
},
],
stream: false,
temperature: 0.7,
frequency_penalty: 0,
presence_penalty: 0,
});
const responsesPayload = buildApiPayload(
[message],
{
...DEFAULT_INPUTS,
model: 'gpt-4.1',
protocol: 'responses',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(responsesPayload.jsonBody).toEqual({
model: 'gpt-4.1',
input: [
{
role: 'user',
content: [
{ type: 'input_text', text: 'reuse native file id' },
{
type: 'input_file',
file_id: 'file-metapi-123',
filename: 'brief.pdf',
},
],
},
],
stream: false,
temperature: 0.7,
});
});
it('strips data URL wrappers when replaying inline files into OpenAI chat payloads', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: 'summarize this inline file',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'gpt-4.1',
protocol: 'openai',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
model: 'gpt-4.1',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this inline file' },
{
type: 'file',
file: {
file_data: 'JVBERi0x',
filename: 'brief.pdf',
mime_type: 'application/pdf',
},
},
],
},
],
stream: false,
temperature: 0.7,
frequency_penalty: 0,
presence_penalty: 0,
});
});
it('builds Claude conversation payloads with inline document blocks for conversation files', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: 'summarize this',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0xLjc=',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'claude-opus-4-6',
protocol: 'claude',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
model: 'claude-opus-4-6',
stream: false,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this' },
{
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: 'JVBERi0xLjc=',
},
title: 'brief.pdf',
},
],
},
],
temperature: 0.7,
});
});
it('builds Claude conversation payloads with image blocks for inline image attachments', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: 'describe this image',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'chart.png',
mimeType: 'image/png',
data: 'data:image/png;base64,QUFBQQ==',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'claude-opus-4-6',
protocol: 'claude',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
model: 'claude-opus-4-6',
stream: false,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'describe this image' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'QUFBQQ==',
},
},
],
},
],
temperature: 0.7,
});
});
it('builds Gemini conversation payloads with inlineData document parts for conversation files', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: 'summarize this',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0xLjc=',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'gemini-2.5-pro',
protocol: 'gemini',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
contents: [
{
role: 'user',
parts: [
{ text: 'summarize this' },
{
inlineData: {
mimeType: 'application/pdf',
data: 'JVBERi0xLjc=',
},
},
],
},
],
generationConfig: { temperature: 0.7 },
});
});
it('builds embeddings and search envelopes', () => {
expect(buildEmbeddingsRequestEnvelope('hello', { ...DEFAULT_INPUTS, model: 'text-embedding-3-large' })).toEqual({
method: 'POST',
+262 -13
View File
@@ -70,9 +70,20 @@ export type PlaygroundMultipartFile = {
};
export type ConversationUploadedFile = {
fileId: string;
fileId?: string | null;
filename?: string | null;
mimeType?: string | null;
data?: string | null;
};
export type ConversationDraftFile = {
localId: string;
name: string;
mimeType: string;
dataUrl: string;
fileId?: string | null;
status: 'pending' | 'uploading' | 'uploaded' | 'error';
errorMessage?: string | null;
};
export type TesterProxyEnvelope = {
@@ -132,6 +143,7 @@ export type ModelTesterSessionState = {
inputs: ModelTesterInputs;
parameterEnabled: ParameterEnabled;
messages: ChatMessage[];
conversationFiles: ConversationDraftFile[];
pendingPayload: TesterProxyEnvelope | null;
pendingJobId?: string | null;
customRequestMode: boolean;
@@ -199,8 +211,10 @@ const VALID_MODES: ReadonlySet<string> = new Set([
'videos.inspect',
]);
const VALID_PROTOCOLS: ReadonlySet<string> = new Set(['openai', 'responses', 'claude', 'gemini']);
const VALID_CONVERSATION_DRAFT_STATUSES: ReadonlySet<string> = new Set(['pending', 'uploading', 'uploaded', 'error']);
const VALID_PROXY_METHODS: ReadonlySet<string> = new Set(['POST', 'GET', 'DELETE']);
const VALID_REQUEST_KINDS: ReadonlySet<string> = new Set(['json', 'multipart', 'empty']);
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
let messageCounter = 0;
@@ -295,10 +309,20 @@ const toOpenAiContentPart = (part: ConversationContentPart): Record<string, unkn
if (part.type === 'input_file') {
const filePayload: Record<string, unknown> = {};
if (typeof part.fileId === 'string' && part.fileId.trim()) filePayload.file_id = part.fileId.trim();
const fileId = typeof part.fileId === 'string' && part.fileId.trim()
? part.fileId.trim()
: '';
const rawData = typeof part.data === 'string' && part.data.trim()
? part.data.trim()
: '';
const parsed = rawData
? parseDataUrl(rawData)
: null;
if (fileId) filePayload.file_id = fileId;
else if (rawData) filePayload.file_data = parsed?.data || rawData;
if (typeof part.filename === 'string' && part.filename.trim()) filePayload.filename = part.filename.trim();
if (typeof part.data === 'string' && part.data.trim()) filePayload.file_data = part.data.trim();
if (typeof part.mimeType === 'string' && part.mimeType.trim()) filePayload.mime_type = part.mimeType.trim();
else if (parsed?.mimeType) filePayload.mime_type = parsed.mimeType;
if (Object.keys(filePayload).length === 0) return null;
return {
type: 'file',
@@ -341,8 +365,8 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
type: 'input_file',
};
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
else if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
if (Object.keys(fileBlock).length === 1) return null;
return fileBlock;
}
@@ -350,11 +374,106 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
return null;
};
const toClaudeContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
if (part.type !== 'input_file' || typeof part.data !== 'string' || !part.data.trim()) return null;
const parsed = parseDataUrl(part.data);
if (!parsed) return null;
const mimeType = part.mimeType || parsed.mimeType || 'application/octet-stream';
if (mimeType.toLowerCase().startsWith('image/')) {
return {
type: 'image',
source: {
type: 'base64',
media_type: mimeType,
data: parsed.data,
},
};
}
return {
type: 'document',
source: {
type: 'base64',
media_type: mimeType,
data: parsed.data,
},
...(typeof part.filename === 'string' && part.filename.trim()
? { title: part.filename.trim() }
: {}),
};
};
const toGeminiContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
if (part.type === 'image_url') {
return {
fileData: {
fileUri: part.url,
},
};
}
if (part.type === 'image_inline') {
const parsed = parseDataUrl(part.dataUrl);
if (!parsed) return null;
return {
inlineData: {
mimeType: part.mimeType || parsed.mimeType,
data: parsed.data,
},
};
}
if (part.type === 'input_audio') {
const parsed = parseDataUrl(part.dataUrl);
if (!parsed) return null;
return {
inlineData: {
mimeType: part.mimeType || parsed.mimeType,
data: parsed.data,
},
};
}
if (part.type === 'input_file') {
if (typeof part.data === 'string' && part.data.trim()) {
const parsed = parseDataUrl(part.data);
if (!parsed) return null;
return {
inlineData: {
mimeType: part.mimeType || parsed.mimeType || 'application/octet-stream',
data: parsed.data,
},
};
}
const fileUri = typeof part.fileId === 'string' && part.fileId.trim()
? part.fileId.trim()
: '';
if (!fileUri) return null;
return {
fileData: {
fileUri,
...(typeof part.mimeType === 'string' && part.mimeType.trim()
? { mimeType: part.mimeType.trim() }
: {}),
},
};
}
return null;
};
const toGeminiContents = (messages: ApiChatMessage[]) =>
messages.map((message) => ({
role: message.role === 'assistant' ? 'model' : 'user',
parts: [{ text: message.content }],
}));
parts: [
...(message.content.trim() ? [{ text: message.content }] : []),
...((Array.isArray(message.parts)
? message.parts
.map((part) => toGeminiContentPart(part))
.filter((item): item is Record<string, unknown> => !!item)
: [])),
],
})).filter((message) => Array.isArray(message.parts) && message.parts.length > 0);
const toOpenAiMessageContent = (message: ApiChatMessage): unknown => {
const parts = Array.isArray(message.parts)
@@ -409,10 +528,21 @@ const toResponsesInput = (messages: ApiChatMessage[]) => {
const toClaudeMessages = (messages: ApiChatMessage[]) =>
messages
.filter((message) => message.role !== 'system' && message.role !== 'developer')
.map((message) => ({
role: message.role === 'assistant' ? 'assistant' : 'user',
content: message.content,
}));
.map((message) => {
const parts = [
...(message.content.trim() ? [{ type: 'text', text: message.content }] : []),
...((Array.isArray(message.parts)
? message.parts
.map((part) => toClaudeContentPart(part))
.filter((item): item is Record<string, unknown> => !!item)
: [])),
];
return {
role: message.role === 'assistant' ? 'assistant' : 'user',
content: parts.length > 0 ? parts : message.content,
};
});
const buildConversationJsonBody = (
messages: ChatMessage[],
@@ -595,6 +725,42 @@ const parseParameterEnabled = (value: unknown): ParameterEnabled => {
};
};
const parseConversationDraftFiles = (value: unknown): ConversationDraftFile[] => {
if (!Array.isArray(value)) return [];
return value.flatMap((item, index) => {
if (!isRecord(item)) return [];
const localId = typeof item.localId === 'string' && item.localId.trim()
? item.localId.trim()
: `draft-file-restored-${index}`;
const name = typeof item.name === 'string' && item.name.trim()
? item.name.trim()
: 'upload.bin';
const mimeType = typeof item.mimeType === 'string' && item.mimeType.trim()
? item.mimeType.trim()
: 'application/octet-stream';
const dataUrl = typeof item.dataUrl === 'string' && item.dataUrl.trim()
? item.dataUrl.trim()
: '';
if (!dataUrl) return [];
return [{
localId,
name,
mimeType,
dataUrl,
...(typeof item.fileId === 'string' && item.fileId.trim()
? { fileId: item.fileId.trim() }
: {}),
status: typeof item.status === 'string' && VALID_CONVERSATION_DRAFT_STATUSES.has(item.status)
? item.status as ConversationDraftFile['status']
: 'pending',
errorMessage: typeof item.errorMessage === 'string'
? item.errorMessage
: null,
}];
});
};
const parseModeState = (value: unknown): ModelTesterModeState => {
if (!isRecord(value)) return { ...DEFAULT_MODE_STATE };
return {
@@ -779,9 +945,18 @@ export const createConversationInputFilePart = (
file: ConversationUploadedFile,
): ConversationContentPart => ({
type: 'input_file',
fileId: file.fileId,
filename: file.filename ?? null,
mimeType: file.mimeType ?? null,
...(typeof file.fileId === 'string' && file.fileId.trim()
? { fileId: file.fileId.trim() }
: {}),
...(typeof file.filename === 'string' && file.filename.trim()
? { filename: file.filename.trim() }
: {}),
...(typeof file.mimeType === 'string' && file.mimeType.trim()
? { mimeType: file.mimeType.trim() }
: {}),
...(typeof file.data === 'string' && file.data.trim()
? { data: file.data.trim() }
: {}),
});
export const createConversationUserMessage = (
@@ -796,6 +971,79 @@ export const createConversationUserMessage = (
});
};
export const extractConversationUploadedFilesFromMessage = (
message: ChatMessage,
): ConversationUploadedFile[] => {
const parts = Array.isArray(message.parts) ? message.parts : [];
return parts.flatMap((part) => {
if (part.type !== 'input_file') return [];
const fileId = typeof part.fileId === 'string' && part.fileId.trim()
? part.fileId.trim()
: null;
const filename = typeof part.filename === 'string' && part.filename.trim()
? part.filename.trim()
: null;
const mimeType = typeof part.mimeType === 'string' && part.mimeType.trim()
? part.mimeType.trim()
: null;
const data = typeof part.data === 'string' && part.data.trim()
? part.data.trim()
: null;
if (!fileId && !data) return [];
return [{
...(fileId ? { fileId } : {}),
...(filename ? { filename } : {}),
...(mimeType ? { mimeType } : {}),
...(data ? { data } : {}),
}];
});
};
export async function resolveConversationReplayFiles(
files: ConversationUploadedFile[],
protocol: PlaygroundProtocol,
loadLocalFile: (fileId: string) => Promise<{ filename?: string | null; mimeType?: string | null; data: string }>,
): Promise<ConversationUploadedFile[]> {
if (protocol !== 'claude' && protocol !== 'gemini') {
return files.map((file) => ({ ...file }));
}
return Promise.all(files.map(async (file) => {
const fileId = typeof file.fileId === 'string' && file.fileId.trim()
? file.fileId.trim()
: '';
const data = typeof file.data === 'string' && file.data.trim()
? file.data.trim()
: '';
if (!fileId || data) {
return { ...file };
}
if (!fileId.startsWith(LOCAL_PROXY_FILE_ID_PREFIX)) {
throw new Error(`会话附件 ${fileId} 只有 file_id,当前协议需要可重放的内联数据。`);
}
const resolved = await loadLocalFile(fileId);
const resolvedData = typeof resolved?.data === 'string' ? resolved.data.trim() : '';
if (!resolvedData) {
throw new Error(`会话附件 ${fileId} 缺少可重放的数据。`);
}
return {
...file,
...(typeof resolved?.filename === 'string' && resolved.filename.trim()
? { filename: resolved.filename.trim() }
: {}),
...(typeof resolved?.mimeType === 'string' && resolved.mimeType.trim()
? { mimeType: resolved.mimeType.trim() }
: {}),
data: resolvedData,
};
}));
}
export const createLoadingAssistantMessage = (): ChatMessage =>
createMessage('assistant', '', {
status: MESSAGE_STATUS.LOADING,
@@ -900,6 +1148,7 @@ export const parseModelTesterSession = (raw: string | null): ModelTesterSessionS
inputs,
parameterEnabled,
messages: sanitizeMessages(parsed.messages),
conversationFiles: parseConversationDraftFiles(parsed.conversationFiles),
pendingPayload: parsePendingPayload(parsed.pendingPayload, inputs, parameterEnabled),
customRequestMode: toBoolean(parsed.customRequestMode, false),
customRequestBody: typeof parsed.customRequestBody === 'string' ? parsed.customRequestBody : '',
@@ -5,6 +5,7 @@ import {
buildSiteFocusPath,
clearFocusParams,
readFocusAccountIntent,
readFocusAnnouncementId,
readFocusSiteId,
} from './navigationFocus.js';
@@ -20,6 +21,8 @@ describe('navigationFocus helpers', () => {
it('parses focus params from query string', () => {
expect(readFocusSiteId('?focusSiteId=15')).toBe(15);
expect(readFocusSiteId('?focusSiteId=abc')).toBeNull();
expect(readFocusAnnouncementId('?focusAnnouncementId=21')).toBe(21);
expect(readFocusAnnouncementId('?focusAnnouncementId=abc')).toBeNull();
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=1')).toEqual({
accountId: 22,
@@ -37,6 +40,7 @@ describe('navigationFocus helpers', () => {
it('clears focus params but keeps other params', () => {
expect(clearFocusParams('?focusSiteId=12&q=abc')).toBe('?q=abc');
expect(clearFocusParams('?focusAnnouncementId=44&type=site_notice')).toBe('?type=site_notice');
expect(clearFocusParams('?focusAccountId=2&openRebind=1&type=token')).toBe('?type=token');
expect(clearFocusParams('?focusAccountId=2')).toBe('');
});
@@ -60,6 +64,12 @@ describe('navigationFocus helpers', () => {
type: 'status',
})).toBe('/sites?focusSiteId=9');
expect(buildEventNavigationPath({
relatedType: 'site_announcement',
relatedId: 12,
type: 'site_notice',
})).toBe('/site-announcements?focusAnnouncementId=12');
expect(buildEventNavigationPath({
relatedType: 'route',
relatedId: null,
@@ -68,6 +78,7 @@ describe('navigationFocus helpers', () => {
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
expect(buildEventNavigationPath({})).toBe('/events');
});
});
+19
View File
@@ -1,4 +1,5 @@
const FOCUS_SITE_ID_KEY = 'focusSiteId';
const FOCUS_ANNOUNCEMENT_ID_KEY = 'focusAnnouncementId';
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
const FOCUS_TOKEN_ID_KEY = 'focusTokenId';
const OPEN_REBIND_KEY = 'openRebind';
@@ -21,6 +22,12 @@ export function buildSiteFocusPath(siteId: number): string {
return `/sites?${FOCUS_SITE_ID_KEY}=${normalizedId}`;
}
export function buildAnnouncementFocusPath(announcementId: number): string {
const normalizedId = normalizePositiveId(announcementId);
if (!normalizedId) return '/site-announcements';
return `/site-announcements?${FOCUS_ANNOUNCEMENT_ID_KEY}=${normalizedId}`;
}
export function buildAccountFocusPath(
accountId: number,
options?: { openRebind?: boolean; segment?: 'session' | 'apikey' | 'tokens' },
@@ -48,6 +55,11 @@ export function readFocusSiteId(search: string): number | null {
return normalizePositiveId(params.get(FOCUS_SITE_ID_KEY));
}
export function readFocusAnnouncementId(search: string): number | null {
const params = new URLSearchParams(search);
return normalizePositiveId(params.get(FOCUS_ANNOUNCEMENT_ID_KEY));
}
export function readFocusAccountIntent(search: string): { accountId: number | null; openRebind: boolean } {
const params = new URLSearchParams(search);
return {
@@ -64,6 +76,7 @@ export function readFocusTokenId(search: string): number | null {
export function clearFocusParams(search: string): string {
const params = new URLSearchParams(search);
params.delete(FOCUS_SITE_ID_KEY);
params.delete(FOCUS_ANNOUNCEMENT_ID_KEY);
params.delete(FOCUS_ACCOUNT_ID_KEY);
params.delete(FOCUS_TOKEN_ID_KEY);
params.delete(OPEN_REBIND_KEY);
@@ -86,6 +99,9 @@ export function buildEventNavigationPath(event: {
if (relatedType === 'site' && relatedId) {
return buildSiteFocusPath(relatedId);
}
if (relatedType === 'site_announcement' && relatedId) {
return buildAnnouncementFocusPath(relatedId);
}
if (relatedType === 'route') {
return '/routes';
}
@@ -95,6 +111,9 @@ export function buildEventNavigationPath(event: {
if (eventType === 'checkin') {
return '/checkin';
}
if (eventType === 'site_notice') {
return '/site-announcements';
}
return '/events';
}
@@ -16,6 +16,14 @@ describe('parseProxyLogPathMeta', () => {
expect(parsed.errorMessage).toBe('messages is required');
});
it('strips legacy client and session prefixes while keeping them available for display', () => {
const parsed = parseProxyLogPathMeta('[client:codex] [session:turn-123] [downstream:/v1/responses] boom');
expect(parsed.clientFamily).toBe('codex');
expect(parsed.sessionId).toBe('turn-123');
expect(parsed.downstreamPath).toBe('/v1/responses');
expect(parsed.errorMessage).toBe('boom');
});
it('keeps plain message when no metadata exists', () => {
const parsed = parseProxyLogPathMeta('network timeout');
expect(parsed.downstreamPath).toBe(null);
+10 -1
View File
@@ -1,4 +1,6 @@
type ProxyLogPathMeta = {
clientFamily: string | null;
sessionId: string | null;
downstreamPath: string | null;
upstreamPath: string | null;
errorMessage: string;
@@ -6,11 +8,18 @@ type ProxyLogPathMeta = {
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
const raw = typeof message === 'string' ? message.trim() : '';
const clientMatch = raw.match(/\[client:([^\]]+)\]/i);
const sessionMatch = raw.match(/\[session:([^\]]+)\]/i);
const downstreamMatch = raw.match(/\[downstream:([^\]]+)\]/i);
const upstreamMatch = raw.match(/\[upstream:([^\]]+)\]/i);
const stripped = raw.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim();
const stripped = raw.replace(
/^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i,
'',
).trim();
return {
clientFamily: clientMatch?.[1]?.trim() || null,
sessionId: sessionMatch?.[1]?.trim() || null,
downstreamPath: downstreamMatch?.[1]?.trim() || null,
upstreamPath: upstreamMatch?.[1]?.trim() || null,
errorMessage: stripped,
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import { renderToStaticMarkup } from 'react-dom/server';
import {
SiteAnnouncementContent,
formatSiteAnnouncementSeenAt,
readClientTimeZone,
resolveSiteAnnouncementTimeZone,
} from './siteAnnouncementPresentation.js';
type BrowserGlobals = {
window?: Window & typeof globalThis;
document?: Document;
DOMParser?: typeof DOMParser;
Node?: typeof Node;
};
const browserGlobals = globalThis as typeof globalThis & BrowserGlobals;
const originalWindow = browserGlobals.window;
const originalDocument = browserGlobals.document;
const originalDOMParser = browserGlobals.DOMParser;
const originalNode = browserGlobals.Node;
describe('siteAnnouncementPresentation helpers', () => {
beforeEach(() => {
const dom = new JSDOM('<!doctype html><html><body></body></html>');
browserGlobals.window = dom.window as unknown as Window & typeof globalThis;
browserGlobals.document = dom.window.document;
browserGlobals.DOMParser = dom.window.DOMParser;
browserGlobals.Node = dom.window.Node;
});
afterEach(() => {
browserGlobals.window = originalWindow;
browserGlobals.document = originalDocument;
browserGlobals.DOMParser = originalDOMParser;
browserGlobals.Node = originalNode;
});
it('renders sanitized html notices with safe links', () => {
const markup = renderToStaticMarkup(
<SiteAnnouncementContent
content={[
'<h2>Notice</h2>',
'<p>Welcome <strong>back</strong>.</p>',
'<script>alert(1)</script>',
'<a href="javascript:alert(1)" onclick="alert(1)">bad</a>',
'<a href="https://example.com/docs" target="_blank">docs</a>',
].join('')}
/>,
);
expect(markup).toContain('<h2>Notice</h2>');
expect(markup).toContain('<strong>back</strong>');
expect(markup).not.toContain('<script');
expect(markup).not.toContain('onclick=');
expect(markup).not.toContain('javascript:alert');
expect(markup).toContain('href="https://example.com/docs"');
expect(markup).toContain('rel="noopener noreferrer"');
});
it('renders markdown notices as structured content', () => {
const markup = renderToStaticMarkup(
<SiteAnnouncementContent
content={[
'# README.md',
'',
'这是一个 [接入文档](https://example.com/setup)。',
'',
'```json',
'{',
' "model": "gpt-5.4"',
'}',
'```',
].join('\n')}
/>,
);
expect(markup).toContain('<h1>README.md</h1>');
expect(markup).toContain('href="https://example.com/setup"');
expect(markup).toContain('<pre><code class="language-json">');
expect(markup).toContain('&quot;model&quot;: &quot;gpt-5.4&quot;');
});
it('formats first-seen time in the requested local timezone', () => {
expect(formatSiteAnnouncementSeenAt('2026-03-20 04:23:27', 'Asia/Shanghai')).toBe('2026/03/20 12:23:27');
});
it('prefers client timezone over server timezone', () => {
expect(resolveSiteAnnouncementTimeZone('Asia/Shanghai', 'UTC')).toBe('Asia/Shanghai');
expect(resolveSiteAnnouncementTimeZone('', 'UTC')).toBe('UTC');
});
it('reads the browser timezone when available', () => {
expect(readClientTimeZone()).toBeTruthy();
});
});
@@ -0,0 +1,384 @@
import { marked } from 'marked';
import { formatDateTimeLocal } from './checkinLogTime.js';
const VOID_TAGS = new Set(['br', 'hr', 'img']);
const DROPPED_TAGS = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
const ALLOWED_TAGS = new Set([
'a',
'b',
'blockquote',
'br',
'center',
'code',
'del',
'div',
'em',
'font',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'img',
'li',
'ol',
'p',
'pre',
'span',
'strong',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'ul',
]);
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeLineEndings(value: string): string {
return String(value || '').replace(/\r\n?/g, '\n');
}
function isLikelyHtml(content: string): boolean {
return /<([a-z][a-z0-9-]*)(?:\s[^>]*)?>/i.test(content) || /<\/([a-z][a-z0-9-]*)>/i.test(content);
}
function isLikelyMarkdown(content: string): boolean {
return /(^|\n)(#{1,6}\s+|>\s+|[-*+]\s+|\d+\.\s+|```|~~~)/.test(content)
|| /!\[[^\]]*]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|~~[^~]+~~/.test(content);
}
function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
const value = String(raw || '').trim();
if (!value) return null;
if (value.startsWith('#') || value.startsWith('/')) return value;
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
if (/^https?:\/\//i.test(value)) return value;
if (/^mailto:/i.test(value)) return value;
if (/^tel:/i.test(value)) return value;
return null;
}
function extractVisibleTextFallback(html: string): string {
return normalizeLineEndings(html)
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function renderInlineMarkdown(text: string): string {
const tokens: string[] = [];
const pushToken = (html: string) => {
const key = `\u0000${tokens.length}\u0000`;
tokens.push(html);
return key;
};
let html = escapeHtml(text);
html = html.replace(/`([^`]+)`/g, (_, code) => pushToken(`<code>${escapeHtml(code)}</code>`));
html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, alt, src, title) => {
const safeSrc = sanitizeUrl(src, true);
if (!safeSrc) return escapeHtml(String(alt || ''));
const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
return pushToken(`<img src="${escapeHtml(safeSrc)}" alt="${escapeHtml(String(alt || ''))}"${titleAttr}>`);
});
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, label, href, title) => {
const safeHref = sanitizeUrl(href, false);
if (!safeHref) return escapeHtml(String(label || ''));
const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
return pushToken(`<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${escapeHtml(String(label || ''))}</a>`);
});
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
html = html.replace(/(^|[^\*])\*([^*\n]+)\*(?=[^\*]|$)/g, '$1<em>$2</em>');
return html.replace(/\u0000(\d+)\u0000/g, (_, index) => tokens[Number(index)] || '');
}
function isMarkdownListLine(line: string, ordered: boolean): boolean {
return ordered ? /^\s*\d+\.\s+/.test(line) : /^\s*[-*+]\s+/.test(line);
}
function consumeMarkdownList(lines: string[], startIndex: number, ordered: boolean) {
const items: string[] = [];
let index = startIndex;
while (index < lines.length && isMarkdownListLine(lines[index] || '', ordered)) {
const line = lines[index] || '';
const item = ordered
? line.replace(/^\s*\d+\.\s+/, '')
: line.replace(/^\s*[-*+]\s+/, '');
items.push(`<li>${renderInlineMarkdown(item.trim())}</li>`);
index += 1;
}
const tag = ordered ? 'ol' : 'ul';
return {
html: `<${tag}>${items.join('')}</${tag}>`,
nextIndex: index,
};
}
function consumeMarkdownQuote(lines: string[], startIndex: number) {
const parts: string[] = [];
let index = startIndex;
while (index < lines.length && /^\s*>\s?/.test(lines[index] || '')) {
parts.push((lines[index] || '').replace(/^\s*>\s?/, ''));
index += 1;
}
return {
html: `<blockquote>${renderMarkdown(parts.join('\n'))}</blockquote>`,
nextIndex: index,
};
}
function consumeMarkdownFence(lines: string[], startIndex: number) {
const firstLine = String(lines[startIndex] || '');
const marker = firstLine.startsWith('~~~') ? '~~~' : '```';
const language = firstLine.slice(marker.length).trim().toLowerCase();
const codeLines: string[] = [];
let index = startIndex + 1;
while (index < lines.length && !String(lines[index] || '').startsWith(marker)) {
codeLines.push(String(lines[index] || ''));
index += 1;
}
if (index < lines.length) index += 1;
const classAttr = language && /^[a-z0-9_-]+$/i.test(language) ? ` class="language-${escapeHtml(language)}"` : '';
return {
html: `<pre><code${classAttr}>${escapeHtml(codeLines.join('\n'))}</code></pre>`,
nextIndex: index,
};
}
function consumeMarkdownParagraph(lines: string[], startIndex: number) {
const parts: string[] = [];
let index = startIndex;
while (index < lines.length) {
const line = String(lines[index] || '');
const trimmed = line.trim();
if (!trimmed) break;
if (/^#{1,6}\s+/.test(trimmed)) break;
if (/^\s*>\s?/.test(line)) break;
if (isMarkdownListLine(line, false) || isMarkdownListLine(line, true)) break;
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) break;
if (/^(-{3,}|\*{3,})$/.test(trimmed)) break;
parts.push(line);
index += 1;
}
const content = parts.map((line) => renderInlineMarkdown(line.trim())).join('<br />');
return {
html: `<p>${content}</p>`,
nextIndex: index,
};
}
function renderMarkdown(content: string): string {
const lines = normalizeLineEndings(content).split('\n');
const blocks: string[] = [];
let index = 0;
while (index < lines.length) {
const rawLine = String(lines[index] || '');
const line = rawLine.trim();
if (!line) {
index += 1;
continue;
}
if (line.startsWith('```') || line.startsWith('~~~')) {
const fenced = consumeMarkdownFence(lines, index);
blocks.push(fenced.html);
index = fenced.nextIndex;
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
blocks.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
index += 1;
continue;
}
if (/^\s*>\s?/.test(rawLine)) {
const quoted = consumeMarkdownQuote(lines, index);
blocks.push(quoted.html);
index = quoted.nextIndex;
continue;
}
if (isMarkdownListLine(rawLine, false)) {
const list = consumeMarkdownList(lines, index, false);
blocks.push(list.html);
index = list.nextIndex;
continue;
}
if (isMarkdownListLine(rawLine, true)) {
const list = consumeMarkdownList(lines, index, true);
blocks.push(list.html);
index = list.nextIndex;
continue;
}
if (/^(-{3,}|\*{3,})$/.test(line)) {
blocks.push('<hr>');
index += 1;
continue;
}
const paragraph = consumeMarkdownParagraph(lines, index);
blocks.push(paragraph.html);
index = paragraph.nextIndex;
}
return blocks.join('');
}
function sanitizeNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return escapeHtml(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const element = node as Element;
const tag = element.tagName.toLowerCase();
if (DROPPED_TAGS.has(tag)) {
return '';
}
const childrenHtml = Array.from(element.childNodes).map((child) => sanitizeNode(child)).join('');
if (!ALLOWED_TAGS.has(tag)) {
return childrenHtml;
}
const attrs: string[] = [];
if (tag === 'a') {
const safeHref = sanitizeUrl(element.getAttribute('href'));
if (safeHref) {
attrs.push(` href="${escapeHtml(safeHref)}"`);
attrs.push(' target="_blank"');
attrs.push(' rel="noopener noreferrer"');
}
const title = element.getAttribute('title');
if (title) {
attrs.push(` title="${escapeHtml(title)}"`);
}
} else if (tag === 'img') {
const safeSrc = sanitizeUrl(element.getAttribute('src'), true);
if (!safeSrc) return '';
attrs.push(` src="${escapeHtml(safeSrc)}"`);
const alt = element.getAttribute('alt');
if (alt) attrs.push(` alt="${escapeHtml(alt)}"`);
const title = element.getAttribute('title');
if (title) attrs.push(` title="${escapeHtml(title)}"`);
} else if (tag === 'code') {
const className = String(element.getAttribute('class') || '').trim();
if (/^language-[a-z0-9_-]+$/i.test(className)) {
attrs.push(` class="${escapeHtml(className)}"`);
}
} else if (tag === 'td' || tag === 'th') {
const colspan = Number.parseInt(String(element.getAttribute('colspan') || ''), 10);
const rowspan = Number.parseInt(String(element.getAttribute('rowspan') || ''), 10);
if (Number.isFinite(colspan) && colspan > 1) attrs.push(` colspan="${colspan}"`);
if (Number.isFinite(rowspan) && rowspan > 1) attrs.push(` rowspan="${rowspan}"`);
}
if (VOID_TAGS.has(tag)) {
return `<${tag}${attrs.join('')}>`;
}
return `<${tag}${attrs.join('')}>${childrenHtml}</${tag}>`;
}
function sanitizeAnnouncementHtml(html: string): string {
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
return renderPlainText(extractVisibleTextFallback(html));
}
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
return Array.from(doc.body.childNodes).map((node) => sanitizeNode(node)).join('');
}
function renderPlainText(content: string): string {
const paragraphs = normalizeLineEndings(content)
.split(/\n{2,}/)
.map((part) => part.trim())
.filter(Boolean);
if (paragraphs.length === 0) {
return `<p>${escapeHtml(String(content || '').trim())}</p>`;
}
return paragraphs
.map((part) => `<p>${escapeHtml(part).replace(/\n/g, '<br />')}</p>`)
.join('');
}
export function readClientTimeZone(): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
} catch {
return undefined;
}
}
export function resolveSiteAnnouncementTimeZone(
clientTimeZone?: string | null,
serverTimeZone?: string | null,
): string | undefined {
const client = String(clientTimeZone || '').trim();
if (client) return client;
const server = String(serverTimeZone || '').trim();
return server || undefined;
}
export function formatSiteAnnouncementSeenAt(
value: string | null | undefined,
timeZone = readClientTimeZone(),
): string {
return formatDateTimeLocal(value, 'zh-CN', timeZone);
}
export function renderSiteAnnouncementHtml(content: string): string {
const raw = String(content || '').trim();
if (!raw) return '<p>-</p>';
if (isLikelyHtml(raw)) {
return sanitizeAnnouncementHtml(raw);
}
if (isLikelyMarkdown(raw)) {
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
return sanitizeAnnouncementHtml(rendered || renderMarkdown(raw));
}
return renderPlainText(raw);
}
export function SiteAnnouncementContent({ content }: { content: string }) {
return (
<div
className="announcement-rich-content"
dangerouslySetInnerHTML={{ __html: renderSiteAnnouncementHtml(content) }}
/>
);
}
+10 -1
View File
@@ -16,6 +16,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
@@ -29,6 +30,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
@@ -44,6 +46,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
proxyUrl: '',
useSystemProxy: true,
customHeaders: '',
globalWeight: 0.8,
@@ -58,6 +61,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
proxyUrl: '',
useSystemProxy: true,
customHeaders: '',
globalWeight: 0.8,
@@ -74,6 +78,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://c.example.com',
externalCheckinUrl: '',
platform: '',
proxyUrl: '',
useSystemProxy: false,
customHeaders: '',
globalWeight: 1,
@@ -85,16 +90,20 @@ describe('buildSiteSaveAction', () => {
it('does not expose deprecated apiKey in site editor state', () => {
expect(emptySiteForm()).not.toHaveProperty('apiKey');
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
expect(emptySiteForm().proxyUrl).toBe('');
expect(siteFormFromSite({
name: 'site-d',
url: 'https://d.example.com',
externalCheckinUrl: null,
platform: 'new-api',
proxyUrl: null,
proxyUrl: 'http://127.0.0.1:8080',
customHeaders: '{"x-site-token":"alpha"}',
globalWeight: 1,
apiKey: 'sk-legacy-site-key',
})).not.toHaveProperty('apiKey');
expect(siteFormFromSite({
proxyUrl: 'http://127.0.0.1:8080',
}).proxyUrl).toBe('http://127.0.0.1:8080');
});
it('parses custom headers json into key value rows', () => {
+6 -1
View File
@@ -8,6 +8,7 @@ export type SiteForm = {
url: string;
externalCheckinUrl: string;
platform: string;
proxyUrl: string;
useSystemProxy: boolean;
customHeaders: SiteCustomHeaderField[];
globalWeight: string;
@@ -22,6 +23,7 @@ export type SiteSavePayload = {
url: string;
externalCheckinUrl: string;
platform: string;
proxyUrl: string;
useSystemProxy: boolean;
customHeaders: string;
globalWeight: number;
@@ -45,6 +47,7 @@ export function emptySiteForm(): SiteForm {
url: '',
externalCheckinUrl: '',
platform: '',
proxyUrl: '',
useSystemProxy: false,
customHeaders: [emptySiteCustomHeader()],
globalWeight: '1',
@@ -76,8 +79,9 @@ function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
}
}
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'useSystemProxy'>> & {
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
externalCheckinUrl?: string | null;
proxyUrl?: string | null;
useSystemProxy?: boolean | null;
customHeaders?: string | null;
globalWeight?: number | string | null;
@@ -89,6 +93,7 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' |
url: site.url ?? '',
externalCheckinUrl: site.externalCheckinUrl ?? '',
platform: site.platform ?? '',
proxyUrl: site.proxyUrl ?? '',
useSystemProxy: !!site.useSystemProxy,
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
globalWeight,
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import Settings from './Settings.js';
const { apiMock } = vi.hoisted(() => ({
@@ -12,6 +13,7 @@ const { apiMock } = vi.hoisted(() => ({
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
updateRuntimeSettings: vi.fn(),
triggerCheckinAll: vi.fn(),
},
}));
@@ -46,6 +48,8 @@ describe('Settings log cleanup schedule', () => {
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
@@ -70,7 +74,7 @@ describe('Settings log cleanup schedule', () => {
vi.clearAllMocks();
});
it('saves log cleanup fields together with other schedule settings', async () => {
it('saves schedule mode and interval fields together with other schedule settings', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
@@ -97,6 +101,8 @@ describe('Settings log cleanup schedule', () => {
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
@@ -107,4 +113,65 @@ describe('Settings log cleanup schedule', () => {
root?.unmount();
}
});
it('triggers a one-off checkin from the schedule card', async () => {
apiMock.triggerCheckinAll.mockResolvedValue({ success: true });
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const triggerButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '测试一次签到'
));
await act(async () => {
await triggerButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.triggerCheckinAll).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
it('renders schedule mode controls with modern selects and ghost action styling', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const triggerButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '测试一次签到'
));
expect(root.root.findAllByType('select')).toHaveLength(0);
expect(root.root.findAllByType(ModernSelect).length).toBeGreaterThanOrEqual(3);
expect(String(triggerButton.props.className || '')).toContain('btn-ghost');
} finally {
root?.unmount();
}
});
});
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
updateRuntimeSettings: vi.fn(),
testSystemProxy: vi.fn(),
},
}));
@@ -67,6 +68,16 @@ describe('Settings system proxy', () => {
success: true,
systemProxyUrl: 'http://127.0.0.1:7890',
});
apiMock.testSystemProxy.mockResolvedValue({
success: true,
proxyUrl: 'http://127.0.0.1:7890',
probeUrl: 'https://www.gstatic.com/generate_204',
finalUrl: 'https://www.gstatic.com/generate_204',
reachable: true,
ok: true,
statusCode: 204,
latencyMs: 321,
});
});
afterEach(() => {
@@ -112,4 +123,45 @@ describe('Settings system proxy', () => {
root?.unmount();
}
});
it('tests system proxy from settings', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const proxyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)'
));
await act(async () => {
proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } });
});
const testButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '测试系统代理'
));
await act(async () => {
testButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.testSystemProxy).toHaveBeenCalledWith({
proxyUrl: 'http://127.0.0.1:7890',
});
expect(collectText(root.root)).toContain('连通成功,延迟 321 ms');
} finally {
root?.unmount();
}
});
});
+142
View File
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import SiteAnnouncements from './SiteAnnouncements.js';
import { sidebarGroups } from '../App.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSiteAnnouncements: vi.fn(),
getSites: vi.fn(),
getRuntimeSettings: vi.fn(),
markSiteAnnouncementRead: vi.fn(),
markAllSiteAnnouncementsRead: vi.fn(),
clearSiteAnnouncements: vi.fn(),
syncSiteAnnouncements: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('./helpers/siteAnnouncementPresentation.js', async () => {
const actual = await vi.importActual<typeof import('./helpers/siteAnnouncementPresentation.js')>('./helpers/siteAnnouncementPresentation.js');
return {
...actual,
readClientTimeZone: () => 'Asia/Shanghai',
};
});
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function collectText(node: any): string {
const children = node?.children || [];
return children.map((child: any) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('SiteAnnouncements page', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([
{ id: 9, name: 'Sub Site', platform: 'sub2api' },
]);
apiMock.getRuntimeSettings.mockResolvedValue({
serverTimeZone: 'UTC',
});
apiMock.getSiteAnnouncements.mockResolvedValue([
{
id: 12,
siteId: 9,
platform: 'sub2api',
sourceKey: 'announcement:12',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
firstSeenAt: '2026-03-20 04:23:27',
lastSeenAt: '2026-03-20 04:23:27',
readAt: null,
},
]);
apiMock.clearSiteAnnouncements.mockResolvedValue({ success: true });
apiMock.markAllSiteAnnouncementsRead.mockResolvedValue({ success: true });
apiMock.syncSiteAnnouncements.mockResolvedValue({ success: true, queued: true, taskId: 'task-1' });
});
afterEach(() => {
vi.clearAllMocks();
});
it('exposes the renamed sidebar item and the site announcements entry', () => {
const consoleGroup = sidebarGroups.find((group) => group.label === '控制台');
const labels = (consoleGroup?.items || []).map((item) => item.label);
expect(labels).toContain('站点管理');
expect(labels).toContain('站点公告');
});
it('loads announcements, highlights the focused row, and clears local rows', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/site-announcements?focusAnnouncementId=12']}>
<Routes>
<Route path="/site-announcements" element={<SiteAnnouncements />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
{
createNodeMock: (element) => {
if (element.type === 'div') {
return {
scrollIntoView: () => undefined,
};
}
return {};
},
},
);
});
await flushMicrotasks();
expect(apiMock.getSiteAnnouncements).toHaveBeenCalled();
expect(apiMock.getSites).toHaveBeenCalled();
expect(apiMock.getRuntimeSettings).toHaveBeenCalled();
expect(JSON.stringify(root!.toJSON())).toContain('站点公告');
expect(JSON.stringify(root!.toJSON())).toContain('Maintenance');
expect(JSON.stringify(root!.toJSON())).toContain('2026/03/20 12:23:27');
const highlightedRows = root!.root.findAll((node) => {
const className = typeof node.props.className === 'string' ? node.props.className : '';
return className.includes('row-focus-highlight') && collectText(node).includes('Maintenance');
});
expect(highlightedRows).toHaveLength(1);
const clearButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('清空公告')
));
await act(async () => {
await clearButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.clearSiteAnnouncements).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
});
+19 -2
View File
@@ -72,6 +72,18 @@ type RouteCardProps = {
onToggleSourceGroup: (groupKey: string) => void;
};
function normalizeRoutingStrategy(value?: RouteRoutingStrategy | null): RouteRoutingStrategy {
if (value === 'round_robin' || value === 'stable_first') return value;
return 'weighted';
}
function getRoutingStrategyLabel(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRoutingStrategy(value);
if (strategy === 'round_robin') return tr('轮询');
if (strategy === 'stable_first') return tr('稳定优先');
return tr('权重随机');
}
function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
const presence = useAnimatedVisibility(open, 220);
if (!presence.shouldRender) return null;
@@ -120,7 +132,7 @@ function RouteCardInner({
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
const channelManagementDisabled = explicitGroupRoute;
const title = resolveRouteTitle(route);
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
const routingStrategy = normalizeRoutingStrategy(route.routingStrategy);
const routingStrategyOptions = [
{
value: 'weighted',
@@ -132,6 +144,11 @@ function RouteCardInner({
label: tr('轮询'),
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
},
{
value: 'stable_first',
label: tr('稳定优先'),
description: tr('按优先级优先选择当前最稳、最快、成功率更高的通道,不做随机分流'),
},
] as const;
const sensors = useSensors(
@@ -228,7 +245,7 @@ function RouteCardInner({
</span>
) : (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
{getRoutingStrategyLabel(routingStrategy)}
</span>
)}
@@ -7,6 +7,7 @@ import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js';
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
@@ -54,7 +55,15 @@ export function SortableChannelRow({
};
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
const tokenBinding = describeTokenBinding(tokenOptions, activeTokenId, channel.token?.name ?? null);
const tokenBinding = describeTokenBinding(
tokenOptions,
activeTokenId,
channel.token?.name ?? null,
{
connectionMode: resolveTokenBindingConnectionMode(channel.account),
accountName: channel.account?.username || `account-${channel.accountId}`,
},
);
return (
<div ref={setNodeRef} style={rowStyle}>
@@ -113,10 +122,10 @@ export function SortableChannelRow({
className="badge"
style={{
fontSize: 10,
background: tokenBinding.isFollowingAccountDefault
background: tokenBinding.badgeTone === 'info'
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
color: tokenBinding.isFollowingAccountDefault ? 'var(--color-info)' : 'var(--color-warning)',
color: tokenBinding.badgeTone === 'info' ? 'var(--color-info)' : 'var(--color-warning)',
}}
>
{tokenBinding.bindingModeLabel}
@@ -211,7 +220,7 @@ export function SortableChannelRow({
options={[
{
value: '0',
label: '跟随账号默认',
label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
@@ -3,6 +3,7 @@ import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js';
describe('tokenBindingPresentation', () => {
@@ -13,8 +14,10 @@ describe('tokenBindingPresentation', () => {
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('token-a');
expect(result.helperText).toContain('以后账号默认变化时会自动切换');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('当前生效:token-a');
});
@@ -25,6 +28,7 @@ describe('tokenBindingPresentation', () => {
], 1);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('default');
expect(result.helperText).toContain('它目前也是账号默认');
expect(result.helperText).toContain('不会跟着变');
@@ -37,8 +41,10 @@ describe('tokenBindingPresentation', () => {
], 2);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('backup');
expect(result.helperText).toContain('不会随账号默认变化');
expect(result.followOptionLabel).toBe('跟随账号默认');
});
it('describes follow-account-default mode when no default token exists yet', () => {
@@ -48,11 +54,76 @@ describe('tokenBindingPresentation', () => {
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('未设置默认令牌');
expect(result.helperText).toContain('当前账号还没有默认令牌');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
});
it('describes follow-api-setting mode for direct apikey connections', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'apikey',
accountName: 'elysiver_api',
});
expect(result.bindingModeLabel).toBe('API令牌');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('elysiver_api');
expect(result.helperText).toContain('连接「elysiver_api」保存的 API Key');
expect(result.followOptionLabel).toBe('固定使用:elysiver_api(跟随 API Key 设置)');
expect(result.followOptionDescription).toContain('API Key');
});
it('describes oauth direct binding without default-token wording', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('OAuth授权');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('mail@urlk.cn');
expect(result.helperText).toContain('OAuth 授权');
expect(result.helperText).not.toContain('默认令牌');
expect(result.followOptionLabel).toBe('固定使用:mail@urlk.cn(OAuth 授权)');
expect(result.followOptionDescription).toContain('OAuth 授权');
});
it('uses oauth fallback copy when a fixed token route is switched back to oauth direct binding', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 2, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.followOptionLabel).toBe('OAuth授权');
expect(result.followOptionDescription).toContain('mail@urlk.cn');
expect(result.followOptionDescription).not.toContain('当前生效:default');
});
it('resolves token binding connection mode from stored account fields', () => {
expect(resolveTokenBindingConnectionMode({
accessToken: '',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
})).toBe('apikey');
expect(resolveTokenBindingConnectionMode({
accessToken: 'session-token',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe('session');
expect(resolveTokenBindingConnectionMode({
accessToken: 'oauth-access-token',
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe('oauth');
});
it('formats fixed token options with clearer labels and descriptions', () => {
const token = {
id: 3,
@@ -8,11 +8,93 @@ export type TokenBindingOption = {
export type TokenBindingPresentation = {
isFollowingAccountDefault: boolean;
bindingModeLabel: string;
badgeTone: 'info' | 'warning';
effectiveTokenName: string;
helperText: string;
followOptionLabel: string;
followOptionDescription: string;
};
type TokenBindingConnectionMode = 'session' | 'apikey' | 'oauth';
type TokenBindingContext = {
connectionMode?: TokenBindingConnectionMode;
accountName?: string | null;
};
function parseExtraConfigHints(extraConfig?: string | null): {
credentialMode: Extract<TokenBindingConnectionMode, 'session' | 'apikey'> | null;
hasOauthProvider: boolean;
} {
if (typeof extraConfig !== 'string' || !extraConfig.trim()) {
return {
credentialMode: null,
hasOauthProvider: false,
};
}
try {
const parsed = JSON.parse(extraConfig) as {
credentialMode?: unknown;
oauth?: { provider?: unknown } | null;
};
const raw = String(parsed?.credentialMode || '').trim().toLowerCase();
return {
credentialMode: raw === 'session' || raw === 'apikey' ? raw : null,
hasOauthProvider: typeof parsed?.oauth?.provider === 'string' && parsed.oauth.provider.trim().length > 0,
};
} catch {}
return {
credentialMode: null,
hasOauthProvider: false,
};
}
function buildDirectBindingPresentation(
connectionMode: Extract<TokenBindingConnectionMode, 'apikey' | 'oauth'>,
accountName: string,
): TokenBindingPresentation {
if (connectionMode === 'oauth') {
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'OAuth授权',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」的 OAuth 授权,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(OAuth 授权)`,
followOptionDescription: `直接使用连接「${accountName}」的 OAuth 授权`,
};
}
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'API令牌',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」保存的 API Key,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(跟随 API Key 设置)`,
followOptionDescription: `直接使用连接「${accountName}」保存的 API Key`,
};
}
export function resolveTokenBindingConnectionMode(account?: {
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
} | null): TokenBindingConnectionMode {
const parsedHints = parseExtraConfigHints(account?.extraConfig);
if (parsedHints.hasOauthProvider) return 'oauth';
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'session') return 'session';
if (rawMode === 'apikey') return 'apikey';
if (parsedHints.credentialMode) return parsedHints.credentialMode;
return typeof account?.accessToken === 'string' && account.accessToken.trim()
? 'session'
: 'apikey';
}
export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null {
return options.find((option) => option.isDefault) || null;
}
@@ -21,21 +103,30 @@ export function describeTokenBinding(
options: TokenBindingOption[],
activeTokenId: number,
fallbackTokenName?: string | null,
context: TokenBindingContext = {},
): TokenBindingPresentation {
const defaultToken = getDefaultTokenOption(options);
const selectedToken = activeTokenId
? options.find((option) => option.id === activeTokenId) || null
: null;
const connectionMode = context.connectionMode || 'session';
const accountName = String(context.accountName || '').trim() || '当前连接';
if (!activeTokenId) {
if (connectionMode === 'apikey' || connectionMode === 'oauth') {
return buildDirectBindingPresentation(connectionMode, accountName);
}
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
return {
isFollowingAccountDefault: true,
bindingModeLabel: '跟随账号默认',
badgeTone: 'info',
effectiveTokenName,
helperText: defaultToken
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
: '跟随账号默认。当前账号还没有默认令牌。',
followOptionLabel: '跟随账号默认',
followOptionDescription: defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换',
@@ -46,13 +137,21 @@ export function describeTokenBinding(
return {
isFollowingAccountDefault: false,
bindingModeLabel: '固定令牌',
badgeTone: 'warning',
effectiveTokenName,
helperText: selectedToken?.isDefault
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
followOptionDescription: defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换',
followOptionLabel: connectionMode === 'oauth'
? 'OAuth授权'
: (connectionMode === 'apikey' ? 'API 设置' : '跟随账号默认'),
followOptionDescription: connectionMode === 'oauth'
? `直接使用连接「${accountName}」的 OAuth 授权`
: (connectionMode === 'apikey'
? `直接使用连接「${accountName}」保存的 API Key`
: (defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换')),
};
}
+4 -1
View File
@@ -4,7 +4,7 @@ import type { BrandInfo } from '../../components/BrandIcon.js';
export type RouteSortBy = 'modelPattern' | 'channelCount';
export type RouteSortDir = 'asc' | 'desc';
export type GroupFilter = null | '__all__' | number;
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
export type RouteRowKind = 'persisted' | 'zero_channel';
export type RouteMode = 'pattern' | 'explicit_group';
@@ -28,6 +28,9 @@ export type RouteChannel = {
cooldownUntil?: string | null;
account?: {
username: string | null;
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
};
site?: {
id: number;
@@ -127,4 +127,53 @@ describe('TokenRoutes routing strategy updates', () => {
root?.unmount();
}
});
it('supports switching to stable_first and keeps the optimistic label when refresh fails', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const expandButton = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
));
await act(async () => {
expandButton.props.onClick();
});
await flushMicrotasks();
const stableFirstOption = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& collectText(node).includes('稳定优先')
));
await act(async () => {
stableFirstOption.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { routingStrategy: 'stable_first' });
const strategyTrigger = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
&& collectText(node).includes('稳定优先')
));
expect(collectText(strategyTrigger)).toContain('稳定优先');
} finally {
root?.unmount();
}
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB