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:
+5
-2
@@ -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 />} />
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(可选,毫秒时间戳)"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'token', label: '令牌' },
|
||||
{ value: 'proxy', label: '代理' },
|
||||
{ value: 'status', label: '状态' },
|
||||
{ value: 'site_notice', label: '站点公告' },
|
||||
];
|
||||
|
||||
function levelLabel(level: string) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)' }} /> 保存中...</> : '更新下游访问令牌'}
|
||||
|
||||
@@ -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
@@ -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 }}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('"model": "gpt-5.4"');
|
||||
});
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,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 |
Reference in New Issue
Block a user