refactor: purge Node.js, Rust is the one true backend

- Moved boosapi-rs/* to repo root
- Deleted all Node.js source (src/server/, src/web/, scripts/)
- Deleted node_modules, package.json, tsconfig, vite.config
- Updated .gitignore and AGENTS.md for Rust-only stack
- Fixed static file path for frontend dist/web/
- Dockerfile stays the same (already Rust-only)
- Kept dist/web/ for Rust static serving
This commit is contained in:
boos
2026-05-18 09:11:29 +08:00
parent 725f44699a
commit 5d29f03d50
1165 changed files with 271 additions and 323571 deletions
-214
View File
@@ -1,214 +0,0 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api.js';
import { tr } from '../i18n.js';
import { SITE_DOCS_URL } from '../docsLink.js';
import { buildUpdateReminder } from './helpers/updateCenterPresentation.js';
const VERSION = '1.3.0';
const FEATURES = [
{ icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
{ icon: '🧠', title: '智能路由引擎', desc: '按成本、延迟、成功率自动选择最优通道,故障自动转移' },
{ icon: '📡', title: '多站点聚合', desc: '集中管理 New API / One API / OneHub / DoneHub / Veloera 等' },
{ icon: '🔍', title: '自动模型发现', desc: '上游新增模型自动出现在模型列表,零配置路由生成' },
{ icon: '🏪', title: '模型广场', desc: '跨站模型覆盖、定价对比、延迟与成功率实测数据' },
{ icon: '✅', title: '自动签到', desc: '定时签到 + 余额刷新,不再手动操心' },
{ icon: '🔔', title: '多渠道告警', desc: 'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒' },
{ icon: '📦', title: '轻量部署', desc: '单 Docker 容器,内置 SQLite,无外部依赖' },
];
const TECH_STACK = [
{ name: 'Fastify', desc: '高性能 Node.js 后端框架' },
{ name: 'React', desc: '用户界面库' },
{ name: 'TypeScript', desc: '端到端类型安全' },
{ name: 'Tailwind CSS v4', desc: '原子化样式框架' },
{ name: 'Drizzle ORM', desc: '轻量 TypeScript ORM' },
{ name: 'SQLite', desc: '零配置嵌入式数据库' },
];
const LINKS = [
{ label: 'GitHub', href: 'https://github.com/cita-777/boosapi', icon: '📂' },
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/boosapi', icon: '🐳' },
{ label: '站点文档', href: SITE_DOCS_URL, icon: '📚' },
];
export default function About() {
const [currentVersion, setCurrentVersion] = useState(`v${VERSION}`);
const [latestGitHubVersion, setLatestGitHubVersion] = useState('');
const [latestDockerHubVersion, setLatestDockerHubVersion] = useState('');
const [updateReminder, setUpdateReminder] = useState(() => buildUpdateReminder({
currentVersion: VERSION,
helper: null,
githubRelease: null,
dockerHubTag: null,
}));
useEffect(() => {
let cancelled = false;
const loadStatus = async () => {
try {
const status = await api.getUpdateCenterStatus() as {
currentVersion?: string;
githubRelease?: { normalizedVersion?: string; displayVersion?: string; tagName?: string | null; digest?: string | null } | null;
dockerHubTag?: { normalizedVersion?: string; displayVersion?: string; tagName?: string | null; digest?: string | null } | null;
helper?: { imageTag?: string | null; imageDigest?: string | null } | null;
};
const resolvedCurrentVersion = String(status.currentVersion || VERSION);
if (cancelled) return;
setCurrentVersion(`v${resolvedCurrentVersion}`);
setLatestGitHubVersion(String(status.githubRelease?.displayVersion || status.githubRelease?.normalizedVersion || ''));
setLatestDockerHubVersion(String(status.dockerHubTag?.displayVersion || status.dockerHubTag?.normalizedVersion || ''));
setUpdateReminder(buildUpdateReminder({
currentVersion: resolvedCurrentVersion,
helper: status.helper,
githubRelease: status.githubRelease,
dockerHubTag: status.dockerHubTag,
}));
} catch {
// ignore update-center lookup failures on about page
}
};
void loadStatus();
return () => {
cancelled = true;
};
}, []);
return (
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
{/* Header */}
<div className="page-header" style={{ marginBottom: 14 }}>
<h2 className="page-title">{tr('关于 BoosAPI')}</h2>
</div>
{/* Hero card */}
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}>
<img
src="/logo.png"
alt="BoosAPI"
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}
/>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>{currentVersion}</div>
</div>
</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。')}
</div>
</div>
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>更新提醒</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
<span className={`${updateReminder.badgeClassName} ${updateReminder.highlight ? 'stat-value-glow' : ''}`.trim()}>
{updateReminder.label}
</span>
<span className={updateReminder.highlight ? 'stat-value-glow' : ''} style={{ fontSize: 13, color: 'var(--color-text-primary)', fontWeight: 600 }}>
{updateReminder.detail}
</span>
</div>
<div style={{ display: 'grid', gap: 8, fontSize: 13 }}>
<div>GitHub 稳定版:{latestGitHubVersion || '暂无数据'}</div>
<div>Docker Hub:{latestDockerHubVersion || '暂无数据'}</div>
<div>
<Link to="/settings" style={{ color: 'var(--color-primary)', textDecoration: 'none', fontWeight: 600 }}>
前往更新中心
</Link>
</div>
</div>
</div>
{/* Features */}
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 10,
}}>
{FEATURES.map((f) => (
<div key={f.title} style={{
display: 'flex', gap: 10, padding: '8px 0',
borderBottom: '1px solid var(--color-border-light)',
}}>
<span style={{ fontSize: 18, lineHeight: '24px', flexShrink: 0 }}>{f.icon}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>
{tr(f.desc)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Tech Stack */}
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 10,
}}>
{TECH_STACK.map((t) => (
<div key={t.name} style={{
padding: '10px 14px',
borderRadius: 8,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-secondary)',
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>
{tr(t.desc)}
</div>
</div>
))}
</div>
</div>
{/* Links */}
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('项目链接')}</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{LINKS.map((l) => (
<a
key={l.label}
href={l.href}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: 8,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-secondary)',
color: 'var(--color-text-primary)',
textDecoration: 'none', fontSize: 13, fontWeight: 500,
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--color-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
>
<span>{l.icon}</span>
<span>{tr(l.label)}</span>
</a>
))}
</div>
</div>
{/* Privacy */}
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。')}
</div>
</div>
</div>
);
}
@@ -1,87 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import About from './About.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getUpdateCenterStatus: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('About update center', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getUpdateCenterStatus.mockResolvedValue({
currentVersion: '1.2.3',
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
},
dockerHubTag: {
normalizedVersion: 'latest',
displayVersion: 'latest @ sha256:efb2ee655386',
},
helper: {
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
},
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('shows current version, newer release summaries, and a highlighted update reminder', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<About />
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root.root);
expect(text).toContain('v1.2.3');
expect(text).toContain('GitHub 稳定版');
expect(text).toContain('1.3.0');
expect(text).toContain('Docker Hub');
expect(text).toContain('latest @ sha256:efb2ee655386');
expect(text).toContain('发现新版本');
expect(text).toContain('前往更新中心');
const highlightedReminder = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('stat-value-glow')
&& collectText(node).includes('发现新版本')
));
expect(collectText(highlightedReminder)).toContain('发现新版本');
} finally {
root?.unmount();
}
});
});
File diff suppressed because it is too large Load Diff
-571
View File
@@ -1,571 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api.js";
import { MobileCard, MobileField } from "../components/MobileCard.js";
import ResponsiveFilterPanel from "../components/ResponsiveFilterPanel.js";
import { useToast } from "../components/Toast.js";
import { useIsMobile } from "../components/useIsMobile.js";
import {
formatCheckinLogTime,
parseServerUtcDateTime,
} from "./helpers/checkinLogTime.js";
import { tr } from "../i18n.js";
type LogFilter = "all" | "success" | "failed" | "skipped";
type FailureReason = {
code: string;
category: string;
title: string;
actionHint: string;
detailHint: string;
};
function pad2(value: number) {
return String(value).padStart(2, "0");
}
function formatDateTimeInputValue(value: Date) {
return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}T${pad2(value.getHours())}:${pad2(value.getMinutes())}`;
}
function getTodayTimeRangeInput(now = new Date()): {
from: string;
to: string;
} {
const start = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
0,
0,
0,
0,
);
const end = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
23,
59,
0,
0,
);
return {
from: formatDateTimeInputValue(start),
to: formatDateTimeInputValue(end),
};
}
function parseLocalDateTimeInput(value: string): Date | null {
const text = value.trim();
if (!text) return null;
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
export default function CheckinLog() {
const [logs, setLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false);
const [filter, setFilter] = useState<LogFilter>("all");
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
const [showFilters, setShowFilters] = useState(false);
const isMobile = useIsMobile();
const toast = useToast();
function getStatus(log: any): "success" | "failed" | "skipped" {
const raw = (log.checkin_logs?.status || log.status || "failed") as string;
if (raw === "success" || raw === "skipped") return raw;
return "failed";
}
function getCreatedAtDate(log: any): Date | null {
const createdAt = log.checkin_logs?.createdAt || log.createdAt;
return parseServerUtcDateTime(createdAt);
}
const initialTimeRange = useMemo(() => getTodayTimeRangeInput(), []);
const [fromInput, setFromInput] = useState(initialTimeRange.from);
const [toInput, setToInput] = useState(initialTimeRange.to);
const fromRaw = useMemo(
() => parseLocalDateTimeInput(fromInput),
[fromInput],
);
const toRaw = useMemo(() => parseLocalDateTimeInput(toInput), [toInput]);
const fromMs = fromRaw?.getTime() ?? null;
const toMs = toRaw?.getTime() ?? null;
const toExclusiveMs = toMs === null ? null : toMs + 60_000;
const hasInvalidTimeRange = Boolean(
fromMs !== null && toMs !== null && fromMs >= toMs,
);
const timeFilteredLogs = useMemo(() => {
if (hasInvalidTimeRange) return [];
return logs.filter((log) => {
const createdAtDate = getCreatedAtDate(log);
if (!createdAtDate) return false;
const createdAtMs = createdAtDate.getTime();
if (fromMs !== null && createdAtMs < fromMs) return false;
if (toExclusiveMs !== null && createdAtMs >= toExclusiveMs) return false;
return true;
});
}, [fromMs, hasInvalidTimeRange, logs, toExclusiveMs]);
const statusFilteredLogs = useMemo(
() =>
filter === "all"
? timeFilteredLogs
: timeFilteredLogs.filter((log) => getStatus(log) === filter),
[filter, timeFilteredLogs],
);
// 兼容旧命名:后面渲染统一用 filtered
const filtered = statusFilteredLogs;
const countBy = useMemo(
() => (target: Exclude<LogFilter, "all">) =>
timeFilteredLogs.filter((log) => getStatus(log) === target).length,
[timeFilteredLogs],
);
const clearTimeRange = () => {
setFromInput("");
setToInput("");
};
const load = async () => {
setLoading(true);
try {
const data = await api.getCheckinLogs("limit=100");
setLogs(Array.isArray(data) ? data : []);
} catch (e: any) {
toast.error(e.message || "加载签到记录失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleTriggerAll = async () => {
setTriggering(true);
try {
const res = await api.triggerCheckinAll();
if (res?.queued) {
toast.info(res.message || "已开始签到,请稍后查看签到记录");
} else {
toast.success(res?.message || "签到已执行");
}
await load();
} catch (e: any) {
toast.error(e.message || "触发签到失败");
} finally {
setTriggering(false);
}
};
const statusLabel = (status: "success" | "failed" | "skipped") => {
if (status === "success") return "成功";
if (status === "skipped") return "跳过";
return "失败";
};
const statusClass = (status: "success" | "failed" | "skipped") => {
if (status === "success") return "badge-success";
if (status === "skipped") return "badge-muted";
return "badge-error";
};
const getFailureReason = (log: any): FailureReason | null => {
const reason = log.failureReason as FailureReason | undefined;
if (!reason || !reason.code) return null;
return reason;
};
const timeRangeControls = (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 10,
alignItems: "center",
}}
>
<label className="proxy-logs-time-field">
<span>开始</span>
<input
type="datetime-local"
value={fromInput}
max={toInput || undefined}
onChange={(e) => setFromInput(e.target.value)}
/>
</label>
<label className="proxy-logs-time-field">
<span>结束</span>
<input
type="datetime-local"
value={toInput}
min={fromInput || undefined}
onChange={(e) => setToInput(e.target.value)}
/>
</label>
<button
type="button"
className="btn btn-ghost proxy-logs-filter-reset"
onClick={clearTimeRange}
>
清空筛选
</button>
</div>
);
const filterTabs = (
<div className="pill-tabs">
{[
{ key: "all" as const, label: "全部", count: timeFilteredLogs.length },
{ key: "success" as const, label: "成功", count: countBy("success") },
{ key: "failed" as const, label: "失败", count: countBy("failed") },
{ key: "skipped" as const, label: "跳过", count: countBy("skipped") },
].map((tab) => (
<button
key={tab.key}
className={`pill-tab ${filter === tab.key ? "active" : ""}`}
onClick={() => setFilter(tab.key)}
>
{tab.label}{" "}
<span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.7 }}>
{tab.count}
</span>
</button>
))}
</div>
);
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr("签到记录")}</h2>
<button
onClick={handleTriggerAll}
disabled={triggering}
className="btn btn-soft-primary"
>
{triggering ? (
<>
<span className="spinner spinner-sm" />
触发中...
</>
) : (
"运行所有签到"
)}
</button>
</div>
<ResponsiveFilterPanel
isMobile={isMobile}
mobileOpen={showFilters}
onMobileOpen={() => setShowFilters(true)}
onMobileClose={() => setShowFilters(false)}
mobileTitle="筛选签到记录"
mobileContent={(
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{timeRangeControls}
{hasInvalidTimeRange && (
<div className="alert alert-error">
结束时间必须晚于开始时间
</div>
)}
{filterTabs}
</div>
)}
desktopContent={(
<div className="toolbar" style={{ marginBottom: "12px" }}>
<div style={{ minWidth: 280 }}>{filterTabs}</div>
<div
style={{
flex: "0 0 auto",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
{timeRangeControls}
</div>
{hasInvalidTimeRange && (
<div className="alert alert-error" style={{ width: "100%" }}>
结束时间必须晚于开始时间
</div>
)}
</div>
)}
/>
<div
className="card"
style={{
overflowX: "auto",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}}
>
{loading ? (
<div
style={{
padding: 24,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{[...Array(5)].map((_, i) => (
<div key={i} style={{ display: "flex", gap: 16 }}>
<div className="skeleton" style={{ width: 120, height: 16 }} />
<div className="skeleton" style={{ width: 80, height: 16 }} />
<div className="skeleton" style={{ width: 120, height: 16 }} />
<div className="skeleton" style={{ width: 70, height: 16 }} />
<div className="skeleton" style={{ flex: 1, height: 16 }} />
<div className="skeleton" style={{ width: 60, height: 16 }} />
</div>
))}
</div>
) : isMobile ? (
<div className="mobile-card-list">
{filtered.map((log: any) => {
const status = getStatus(log);
const reason = getFailureReason(log);
const isExpanded =
expandedLogId === (log.checkin_logs?.id || log.id);
const logId = log.checkin_logs?.id || log.id;
return (
<MobileCard
key={logId}
title={log.accounts?.username || "未知"}
headerActions={
<span
className={`badge ${statusClass(status)}`}
style={{ fontSize: 10 }}
>
{statusLabel(status)}
</span>
}
footerActions={
<button
type="button"
className="btn btn-link"
onClick={() =>
setExpandedLogId(isExpanded ? null : logId)
}
>
{isExpanded ? "收起" : "详情"}
</button>
}
>
<MobileField
label="时间"
value={formatCheckinLogTime(
log.checkin_logs?.createdAt || log.createdAt,
)}
/>
<MobileField
label="站点"
value={
log.sites?.url ? (
<a
href={log.sites.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span
className="badge badge-muted"
style={{ fontSize: 11 }}
>
{log.sites?.name || "-"}
</span>
</a>
) : (
<span
className="badge badge-muted"
style={{ fontSize: 11 }}
>
{log.sites?.name || "-"}
</span>
)
}
/>
<MobileField
label="分类"
value={
reason ? (
<span
className="badge badge-info"
data-tooltip={reason.detailHint}
>
{reason.title}
</span>
) : (
<span className="badge badge-muted">-</span>
)
}
/>
<MobileField
label="奖励"
value={log.checkin_logs?.reward || "-"}
/>
{isExpanded ? (
<div className="mobile-card-extra">
<MobileField
label="信息"
stacked
value={log.checkin_logs?.message || log.message}
/>
<MobileField
label="建议"
stacked
value={reason?.actionHint || "-"}
/>
</div>
) : null}
</MobileCard>
);
})}
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>时间</th>
<th>账号</th>
<th>站点</th>
<th>状态</th>
<th>分类</th>
<th>信息</th>
<th>建议</th>
<th>奖励</th>
</tr>
</thead>
<tbody>
{filtered.map((log: any) => {
const status = getStatus(log);
const reason = getFailureReason(log);
return (
<tr key={log.checkin_logs?.id || log.id}>
<td style={{ fontSize: 12, whiteSpace: "nowrap" }}>
{formatCheckinLogTime(
log.checkin_logs?.createdAt || log.createdAt,
)}
</td>
<td
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{log.accounts?.username || "未知"}
</td>
<td>
{log.sites?.url ? (
<a
href={log.sites.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span
className="badge badge-muted"
style={{ fontSize: 11 }}
>
{log.sites?.name || "-"}
</span>
</a>
) : (
<span
className="badge badge-muted"
style={{ fontSize: 11 }}
>
{log.sites?.name || "-"}
</span>
)}
</td>
<td>
<span className={`badge ${statusClass(status)}`}>
{statusLabel(status)}
</span>
</td>
<td>
{reason ? (
<span
className="badge badge-info"
data-tooltip={reason.detailHint}
>
{reason.title}
</span>
) : (
<span className="badge badge-muted">-</span>
)}
</td>
<td style={{ maxWidth: 360 }}>
<span
style={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{log.checkin_logs?.message || log.message}
</span>
</td>
<td style={{ maxWidth: 220 }}>
<span
style={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--color-text-secondary)",
fontSize: 12,
}}
data-tooltip={reason?.detailHint || ""}
>
{reason?.actionHint || "-"}
</span>
</td>
<td>{log.checkin_logs?.reward || "-"}</td>
</tr>
);
})}
</tbody>
</table>
)}
{!loading && filtered.length === 0 && (
<div className="empty-state">
<svg
className="empty-state-icon"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div className="empty-state-title">暂无签到记录</div>
<div className="empty-state-desc">点击“运行所有签到”开始执行</div>
</div>
)}
</div>
</div>
);
}
-37
View File
@@ -1,37 +0,0 @@
import React from 'react';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
React.useEffect(() => {
const el = document.documentElement;
const check = () => setIsDark(el.getAttribute('data-theme') !== 'light');
check();
const mo = new MutationObserver(check);
mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] });
return () => mo.disconnect();
}, []);
return (
<div
style={{
width: '100%',
height: 'calc(100vh - 56px)',
display: 'flex',
flexDirection: 'column',
background: isDark ? '#1a1a2e' : '#f5f5f5',
}}
>
<iframe
src="/comfyui"
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title="ComfyUI"
allow="clipboard-read; clipboard-write"
/>
</div>
);
}
-428
View File
@@ -1,428 +0,0 @@
import { useState, useRef, useEffect } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
images?: Array<{ url: string; character?: string; angle?: string }>;
scenes?: Array<{ id: number; description: string; prompt: string }>;
tts?: Array<{ character: string; text: string; url: string }>;
workflowJson?: any;
}
export default function ComfyUIAgent() {
const [messages, setMessages] = useState<Message[]>([
{
id: 'welcome',
role: 'system',
content: '🎬 你好!我是 AI 视频生成助手。请告诉我你的视频脚本或创意,我可以帮你:\n\n1️⃣ 分析脚本结构(角色、场景、对话)\n2️⃣ 生成角色 4 角度图像(正面/背面/左侧/右侧)\n3️⃣ 生成场景背景图\n4️⃣ 生成角色语音(TTS)\n5️⃣ 导出 ComfyUI 工作流\n\n直接发脚本给我,我们开始吧!',
},
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const chatEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingContent]);
const sendMessage = async () => {
if (!input.trim() || loading) return;
const userMsg: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: input.trim(),
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setLoading(true);
setStreamingContent('');
const abortController = new AbortController();
abortRef.current = abortController;
try {
const apiMessages = [...messages, userMsg].map((m) => ({
role: m.role === 'system' ? 'system' : m.role,
content: m.content,
}));
const resp = await fetch('/api/comfyui-agent/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token') || ''}`,
},
body: JSON.stringify({ messages: apiMessages }),
signal: abortController.signal,
});
if (!resp.ok) {
setStreamingContent('请求失败,请重试');
return;
}
const reader = resp.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
let textBuffer = '';
const images: Message['images'] = [];
const scenes: Message['scenes'] = [];
const ttsList: Message['tts'] = [];
let msgId = `agent-${Date.now()}`;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.startsWith('data: ')) continue;
const data = part.slice(6).trim();
if (!data) continue;
try {
const event = JSON.parse(data);
switch (event.type) {
case 'text':
textBuffer += event.content || '';
setStreamingContent(textBuffer);
break;
case 'character_prompts':
textBuffer += `\n\n📐 **${event.character}** 角色提示词已生成`;
setStreamingContent(textBuffer);
break;
case 'image':
images.push({ url: event.url, character: event.character, angle: event.angle });
textBuffer += `\n🖼️ 生成 ${event.character} [${event.angle}]`;
setStreamingContent(textBuffer);
break;
case 'scene':
scenes.push({ id: event.id, description: event.description, prompt: event.prompt });
textBuffer += `\n🌄 场景 ${event.id}: ${event.description}`;
setStreamingContent(textBuffer);
break;
case 'tts':
ttsList.push({ character: event.character, text: event.text, url: event.url });
textBuffer += `\n🔊 ${event.character}: "${event.text.slice(0, 30)}..."`;
setStreamingContent(textBuffer);
break;
case 'comfyui_workflow':
textBuffer += '\n\n📦 ComfyUI 工作流已就绪,点击上方「导出」按钮下载';
setStreamingContent(textBuffer);
break;
case 'done':
break;
}
} catch {
// skip malformed JSON
}
}
}
const finalContent = textBuffer || (await resp.text()).slice(0, 500);
setMessages((prev) => [
...prev,
{
id: msgId,
role: 'assistant',
content: finalContent,
images: images.length > 0 ? images : undefined,
scenes: scenes.length > 0 ? scenes : undefined,
tts: ttsList.length > 0 ? ttsList : undefined,
},
]);
setStreamingContent('');
} catch (e: any) {
if (e.name !== 'AbortError') {
setStreamingContent(`错误: ${e.message}`);
}
} finally {
setLoading(false);
abortRef.current = null;
}
};
const stopGeneration = () => {
abortRef.current?.abort();
setLoading(false);
if (streamingContent) {
setMessages((prev) => [
...prev,
{ id: `agent-${Date.now()}`, role: 'assistant', content: streamingContent },
]);
setStreamingContent('');
}
};
const exportWorkflow = () => {
// Simple ComfyUI workflow JSON for video generation
const workflow = {
name: 'AI Video Project',
version: 1,
nodes: messages
.filter((m) => m.images && m.images.length > 0)
.flatMap((m) =>
(m.images || []).map((img, i) => ({
id: `img-${i}`,
type: 'LoadImage',
url: img.url,
character: img.character,
angle: img.angle,
}))
),
};
const blob = new Blob([JSON.stringify(workflow, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'comfyui-workflow.json';
a.click();
URL.revokeObjectURL(url);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
return (
<div
style={{
width: '100%',
height: 'calc(100vh - 56px)',
display: 'flex',
flexDirection: 'column',
background: 'var(--color-bg, #0f0f1a)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
borderBottom: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface, #1a1a2e)',
}}
>
<h1 style={{ fontSize: '16px', fontWeight: 600, margin: 0, color: 'var(--color-text, #e0e0f0)' }}>
🎬 AI 视频助手(对话)
</h1>
<button
onClick={exportWorkflow}
style={{
padding: '6px 16px',
borderRadius: '6px',
border: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface-raised, #252540)',
color: 'var(--color-text, #e0e0f0)',
fontSize: '13px',
cursor: 'pointer',
}}
>
📦 导出 ComfyUI
</button>
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '16px 20px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
<div
style={{
maxWidth: '85%',
padding: '10px 14px',
borderRadius: '12px',
background:
msg.role === 'user'
? 'var(--color-primary, #6366f1)'
: msg.role === 'system'
? 'transparent'
: 'var(--color-surface-raised, #252540)',
color:
msg.role === 'user'
? '#fff'
: 'var(--color-text, #e0e0f0)',
fontSize: '14px',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{msg.content}
</div>
{/* Images */}
{msg.images && msg.images.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginTop: '8px',
}}
>
{msg.images.map((img, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<img
src={img.url}
alt={`${img.character || ''} ${img.angle || ''}`}
style={{
width: '150px',
height: '150px',
objectFit: 'cover',
borderRadius: '8px',
border: '1px solid var(--color-border, #2a2a3e)',
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div
style={{
fontSize: '11px',
color: 'var(--color-text-secondary, #888)',
marginTop: '2px',
}}
>
{img.character} {img.angle === 'front' ? '正面' : img.angle === 'back' ? '背面' : img.angle === 'left' ? '左侧' : img.angle === 'right' ? '右侧' : img.angle}
</div>
</div>
))}
</div>
)}
</div>
))}
{/* Streaming message */}
{streamingContent && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div
style={{
maxWidth: '85%',
padding: '10px 14px',
borderRadius: '12px',
background: 'var(--color-surface-raised, #252540)',
color: 'var(--color-text, #e0e0f0)',
fontSize: '14px',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{streamingContent}
<span style={{ animation: 'blink 1s step-end infinite', marginLeft: 2 }}>▌</span>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Input */}
<div
style={{
padding: '12px 20px',
borderTop: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface, #1a1a2e)',
}}
>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入脚本内容或创作需求..."
rows={2}
disabled={loading}
style={{
flex: 1,
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-bg, #0f0f1a)',
color: 'var(--color-text, #e0e0f0)',
fontSize: '14px',
resize: 'none',
outline: 'none',
}}
/>
{loading ? (
<button
onClick={stopGeneration}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
background: '#ef4444',
color: '#fff',
fontSize: '14px',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
■ 停止
</button>
) : (
<button
onClick={sendMessage}
disabled={!input.trim()}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
background: !input.trim() ? '#555' : '#6366f1',
color: '#fff',
fontSize: '14px',
cursor: !input.trim() ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
}}
>
发送
</button>
)}
</div>
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,234 +0,0 @@
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 DownstreamKeys from './DownstreamKeys.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
batchDownstreamApiKeys: vi.fn(),
updateDownstreamApiKey: vi.fn(),
deleteDownstreamApiKey: vi.fn(),
resetDownstreamApiKeyUsage: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === text
));
}
function findGhostButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === text
&& String(node.props.className || '').includes('btn btn-ghost')
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
function buildSummaryItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
keyMasked: `sk-k****${id}`,
enabled: true,
description: 'mobile smoke',
groupName: '项目A',
tags: ['移动端'],
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
createdAt: '2026-03-15T08:27:25.378Z',
updatedAt: '2026-03-15T08:27:25.378Z',
rangeUsage: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
successRate: 66.7,
totalTokens: 4200,
totalCost: 0.42,
},
...overrides,
};
}
function buildRawItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
key: `sk-mobile-${id}`,
keyMasked: `sk-k****${id}`,
description: 'mobile smoke',
groupName: '项目A',
tags: ['移动端'],
enabled: true,
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
...overrides,
};
}
describe('DownstreamKeys mobile layout', () => {
beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).document = {
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [buildSummaryItem(1), buildSummaryItem(2)],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [buildRawItem(1), buildRawItem(2)],
});
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(1),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
});
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
],
});
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1, 2], failedItems: [] });
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
delete (globalThis as any).document;
});
it('renders mobile cards and supports select-all-visible batch actions', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const cards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
expect(cards.length).toBe(2);
expect(collectText(root!.root)).toContain('全选可见');
const selectAllButton = findButtonByText(root!.root, '全选可见');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('已选 2 个密钥');
expect(collectText(root!.root)).toContain('取消全选');
const disableButton = findGhostButtonByText(root!.root, '禁用');
await act(async () => {
disableButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
ids: [1, 2],
action: 'disable',
});
} finally {
root?.unmount();
}
});
});
-909
View File
@@ -1,909 +0,0 @@
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 DownstreamKeys from './DownstreamKeys.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getAccountTokens: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
batchDownstreamApiKeys: vi.fn(),
updateDownstreamApiKey: vi.fn(),
deleteDownstreamApiKey: vi.fn(),
resetDownstreamApiKeyUsage: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
function buildSummaryItem(overrides?: Partial<any>) {
return {
id: 1,
name: 'smoke-key',
keyMasked: 'sk-s****0315',
enabled: true,
description: 'local smoke',
groupName: '项目A',
tags: ['移动端', 'VIP'],
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
createdAt: '2026-03-15T08:27:25.378Z',
updatedAt: '2026-03-15T08:27:25.378Z',
rangeUsage: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
successRate: 66.7,
totalTokens: 4200,
totalCost: 0.42,
},
...overrides,
};
}
function buildRawItem(overrides?: Partial<any>) {
return {
id: 1,
name: 'smoke-key',
key: 'sk-smoke-0315',
keyMasked: 'sk-s****0315',
description: 'local smoke',
groupName: '项目A',
tags: ['移动端', 'VIP'],
enabled: true,
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
(globalThis as any).document = {
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] });
apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] });
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 101,
username: '站点A账号',
apiToken: 'sk-default-a',
status: 'active',
credentialMode: 'session',
site: {
id: 201,
name: '站点A',
status: 'active',
},
},
{
id: 102,
username: '站点B账号',
apiToken: 'sk-default-b',
status: 'active',
credentialMode: 'session',
site: {
id: 202,
name: '站点B',
status: 'active',
},
},
]);
apiMock.getAccountTokens.mockResolvedValue([
{
id: 301,
accountId: 101,
name: 'token-a',
tokenGroup: 'group-a',
enabled: true,
valueStatus: 'ready',
account: {
id: 101,
username: '站点A账号',
},
site: {
id: 201,
name: '站点A',
},
},
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
});
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
{ startUtc: '2026-03-15T09:00:00.000Z', totalRequests: 1, totalTokens: 3000, totalCost: 0.3, successRate: 0 },
],
});
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1], failedItems: [] });
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
delete (globalThis as any).document;
});
describe('DownstreamKeys page', () => {
it('loads management data and renders merged row content', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeysSummary).toHaveBeenCalledWith({ range: '24h' });
expect(apiMock.getDownstreamApiKeys).toHaveBeenCalled();
expect(apiMock.getRoutesLite).toHaveBeenCalled();
const text = collectText(root!.root);
expect(text).toContain('下游密钥');
expect(text).toContain('范围概览');
expect(text).toContain('筛选与列表');
expect(text).toContain('smoke-key');
expect(text).toContain('sk-s****0315');
expect(text).toContain('默认群组');
expect(text).toContain('4.2K');
expect(text).toContain('主分组');
expect(text).toContain('移动端');
} finally {
root?.unmount();
}
});
it('filters rows locally by search and status', async () => {
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [
buildSummaryItem(),
buildSummaryItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', keyMasked: 'sk-b****0315' }),
],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [
buildRawItem(),
buildRawItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', key: 'sk-batch-0315', keyMasked: 'sk-b****0315', supportedModels: ['gpt-4o-mini'], allowedRouteIds: [] }),
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const input = root!.root.findAllByType('input').find((node) => node.props.placeholder === '搜索名称、备注、模型、主分组或标签');
await act(async () => {
input!.props.onChange({ target: { value: 'batch' } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('batch-key');
expect(collectText(root!.root)).not.toContain('smoke-key');
const select = root!.root.findAllByType('select').find((node) => collectText(node).includes('仅禁用'));
await act(async () => {
select!.props.onChange({ target: { value: 'disabled' } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('batch-key');
expect(collectText(root!.root)).not.toContain('smoke-key');
} finally {
root?.unmount();
}
});
it('supports create flow and drawer trend loading', async () => {
let root!: WebTestRenderer;
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();
expect(collectText(root!.root)).toContain('高级配置');
expect(collectText(root!.root)).not.toContain('站点倍率 JSON');
const inputs = root!.root.findAllByType('input');
const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
expect(tagInput?.props.style?.fontSize).toBe(13);
await act(async () => {
nameInput!.props.onChange({ target: { value: 'new-key' } });
keyInput!.props.onChange({ target: { value: 'sk-new-key-0315' } });
});
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: 'new-key',
key: 'sk-new-key-0315',
siteWeightMultipliers: {},
}));
const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0];
await act(async () => {
row.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledWith(1);
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '24h' });
expect(collectText(root!.root)).toContain('trend:2');
const range7d = root!.root.findAll((node) => node.type === 'button' && collectText(node) === '7d')[0];
await act(async () => {
range7d.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '7d' });
expect(collectText(root!.root)).toContain('12.4K');
expect(collectText(root!.root)).toContain('9');
expect(collectText(root!.root)).toContain('88.9%');
} finally {
root?.unmount();
}
});
it('does not refetch drawer trend repeatedly after a trend error toast', async () => {
apiMock.getDownstreamApiKeyTrend
.mockRejectedValueOnce(new Error('function date_trunc(unknown, text) does not exist'))
.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0];
await act(async () => {
row.props.onClick();
});
await flushMicrotasks();
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledTimes(1);
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledTimes(1);
const toastMessages = root!.root.findAll((node) => {
if (typeof node.props.className !== 'string') return false;
return node.props.className.includes('toast-error') && collectText(node).includes('function date_trunc(unknown, text) does not exist');
});
expect(toastMessages).toHaveLength(1);
} finally {
root?.unmount();
}
});
it('clears stale drawer overview and trend data when switching to another key', async () => {
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [
buildSummaryItem(),
buildSummaryItem({
id: 2,
name: 'batch-key',
keyMasked: 'sk-b****0315',
groupName: '项目B',
rangeUsage: {
totalRequests: 1,
successRequests: 1,
failedRequests: 0,
successRate: 100,
totalTokens: 12,
totalCost: 0.01,
},
}),
],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [
buildRawItem(),
buildRawItem({
id: 2,
name: 'batch-key',
key: 'sk-batch-0315',
keyMasked: 'sk-b****0315',
groupName: '项目B',
}),
],
});
apiMock.getDownstreamApiKeyOverview
.mockResolvedValueOnce({
success: true,
item: buildSummaryItem(),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
})
.mockImplementationOnce(() => new Promise(() => {}));
apiMock.getDownstreamApiKeyTrend
.mockResolvedValueOnce({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
{ startUtc: '2026-03-15T09:00:00.000Z', totalRequests: 1, totalTokens: 3000, totalCost: 0.3, successRate: 0 },
],
})
.mockImplementationOnce(() => new Promise(() => {}));
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rows = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function');
const smokeRow = rows.find((node) => collectText(node).includes('smoke-key'));
const batchRow = rows.find((node) => collectText(node).includes('batch-key'));
await act(async () => {
smokeRow!.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('固定窗口对比');
expect(collectText(root!.root)).toContain('trend:2');
await act(async () => {
batchRow!.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).not.toContain('固定窗口对比');
expect(collectText(root!.root)).not.toContain('trend:2');
} finally {
root?.unmount();
}
});
it('separates exact models from group routes in advanced config and uses single-column layout', async () => {
let root!: WebTestRenderer;
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('群组范围'));
const advancedGrid = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-grid')[0];
expect(modelPanel).toBeTruthy();
expect(groupPanel).toBeTruthy();
expect(collectText(modelPanel!)).toContain('gpt-4.1-mini');
expect(collectText(modelPanel!)).not.toContain('默认群组');
expect(collectText(modelPanel!)).not.toContain('claude-*');
expect(collectText(groupPanel!)).toContain('默认群组');
expect(collectText(groupPanel!)).not.toContain('GPT 4.1 Mini');
expect(advancedGrid.props.style?.gridTemplateColumns).toBe('1fr');
} finally {
root?.unmount();
}
});
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!: WebTestRenderer;
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('defaults new keys to 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!: WebTestRenderer;
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();
expect(collectText(modelPanel!)).toContain('已选 2 个模型');
expect(collectText(groupPanel!)).toContain('已选 2 个群组');
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: 'default-all-key' } });
keyInput!.props.onChange({ target: { value: 'sk-default-all-key-0323' } });
});
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: 'default-all-key',
key: 'sk-default-all-key-0323',
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!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false);
await act(async () => {
checkbox!.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('已选 1 个密钥');
const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0];
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
ids: [1],
action: 'enable',
});
} finally {
root?.unmount();
}
});
it('supports group and tag editing plus batch metadata update', async () => {
let root!: WebTestRenderer;
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 inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
const groupInput = inputs.find((node) => node.props.placeholder === '例如:VIP / 内部项目 / A组');
const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'grouped-key' } });
keyInput!.props.onChange({ target: { value: 'sk-grouped-key-0315' } });
groupInput!.props.onChange({ target: { value: '项目B' } });
tagInput!.props.onChange({ target: { value: '灰度,高优' } });
});
await flushMicrotasks();
await act(async () => {
tagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
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({
groupName: '项目B',
tags: ['灰度', '高优'],
}));
const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false);
await act(async () => {
checkbox!.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const batchMetadataBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量归类/标签'))[0];
await act(async () => {
batchMetadataBtn.props.onClick();
});
await flushMicrotasks();
const groupModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('统一设为主分组'));
const tagModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('追加标签'));
await act(async () => {
groupModeSelect!.props.onChange({ target: { value: 'set' } });
tagModeSelect!.props.onChange({ target: { value: 'append' } });
});
await flushMicrotasks();
const batchGroupInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '例如:VIP / 内部项目');
const batchTagInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '批量追加标签');
await act(async () => {
batchGroupInput!.props.onChange({ target: { value: '统一项目组' } });
batchTagInput!.props.onChange({ target: { value: '批量标签' } });
});
await flushMicrotasks();
await act(async () => {
batchTagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
await flushMicrotasks();
const applyBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('应用到所选密钥'))[0];
await act(async () => {
applyBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith(expect.objectContaining({
ids: [1],
action: 'updateMetadata',
groupOperation: 'set',
groupName: '统一项目组',
tagOperation: 'append',
tags: ['批量标签'],
}));
} finally {
root?.unmount();
}
});
it('lazy loads exclusion sources and submits excluded sites and credentials', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getAccountsSnapshot).not.toHaveBeenCalled();
expect(apiMock.getAccountTokens).not.toHaveBeenCalled();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getAccountsSnapshot).toHaveBeenCalledTimes(1);
expect(apiMock.getAccountTokens).toHaveBeenCalledTimes(1);
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('排除站点');
expect(text).toContain('排除 API Key/令牌');
expect(text).toContain('默认 API Key');
expect(text).toContain('group-a');
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: 'excluded-key' } });
keyInput!.props.onChange({ target: { value: 'sk-excluded-key-0405' } });
});
await flushMicrotasks();
const siteLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('站点B'))[0];
const tokenLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('token-a'))[0];
const defaultApiKeyLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('默认 API Key'))[0];
const siteCheckbox = siteLabel.findByType('input');
const tokenCheckbox = tokenLabel.findByType('input');
const defaultApiKeyCheckbox = defaultApiKeyLabel.findByType('input');
await act(async () => {
siteCheckbox.props.onChange({ target: { checked: true } });
tokenCheckbox.props.onChange({ target: { checked: true } });
defaultApiKeyCheckbox.props.onChange({ target: { checked: true } });
});
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: 'excluded-key',
key: 'sk-excluded-key-0405',
excludedSiteIds: [202],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: 201, accountId: 101, tokenId: 301 },
{ kind: 'default_api_key', siteId: 201, accountId: 101 },
],
}));
} finally {
root?.unmount();
}
});
});
File diff suppressed because it is too large Load Diff
-600
View File
@@ -1,600 +0,0 @@
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 ImportExport from './ImportExport.js';
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(),
error: vi.fn(),
info: vi.fn(),
toast: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
useToast: () => toastMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
const allApiHubV2Payload = JSON.stringify({
version: '2.0',
timestamp: 1735689600000,
accounts: {
accounts: [
{
id: 'managed-account',
site_url: 'https://newapi.example.com',
site_type: 'new-api',
site_name: 'Managed Site',
authType: 'access_token',
account_info: {
id: 7788,
username: 'managed-user',
access_token: 'managed-session-token',
},
},
{
id: 'direct-account',
site_url: 'https://api.openai.com',
site_type: 'openai',
site_name: 'OpenAI Direct',
authType: 'access_token',
account_info: {
username: 'openai-user',
access_token: 'sk-openai-account',
},
},
],
bookmarks: [
{
id: 'bookmark-1',
name: 'Ignored Bookmark',
url: 'https://bookmark.example.com',
},
],
pinnedAccountIds: ['direct-account'],
orderedAccountIds: ['managed-account', 'direct-account'],
last_updated: 1735689601000,
},
preferences: {
language: 'zh-CN',
},
channelConfigs: {
bySite: {
demo: { enabled: true },
},
},
tagStore: {
version: 1,
tagsById: {},
},
apiCredentialProfiles: {
version: 2,
profiles: [
{
id: 'profile-openai',
name: 'OpenAI Profile',
apiType: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-profile-openai',
tagIds: [],
notes: '',
createdAt: 1735689602000,
updatedAt: 1735689603000,
},
{
id: 'profile-gemini',
name: 'Gemini Profile',
apiType: 'google',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
apiKey: 'gemini-profile-key',
tagIds: [],
notes: '',
createdAt: 1735689604000,
updatedAt: 1735689605000,
},
],
lastUpdated: 1735689606000,
},
});
const nativeMetapiPayload = JSON.stringify({
version: '2.1',
timestamp: 1735689600000,
accounts: {
sites: [
{
id: 1,
name: 'Native Site',
url: 'https://native.example.com',
platform: 'new-api',
},
],
accounts: [
{
id: 1,
siteId: 1,
username: 'native-user',
accessToken: 'session-token',
apiToken: 'api-token',
status: 'active',
},
],
accountTokens: [
{
id: 1,
accountId: 1,
name: 'default',
token: 'sk-native',
enabled: true,
isDefault: true,
},
],
tokenRoutes: [
{
id: 1,
modelPattern: 'gpt-5-nano',
enabled: true,
},
],
routeChannels: [
{
id: 1,
routeId: 1,
accountId: 1,
tokenId: 1,
enabled: true,
manualOverride: false,
},
],
routeGroupSources: [],
siteDisabledModels: [
{
siteId: 1,
modelName: 'gpt-hidden',
},
],
manualModels: [
{
accountId: 1,
modelName: 'gpt-manual',
},
],
downstreamApiKeys: [
{
name: 'Shared Key',
key: 'downstream-native',
enabled: true,
supportedModels: '["gpt-5-nano"]',
},
],
},
preferences: {
settings: [
{ key: 'locale', value: 'zh-CN' },
],
},
});
describe('ImportExport', () => {
beforeEach(() => {
vi.clearAllMocks();
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: {
accounts: true,
preferences: true,
},
appliedSettings: [],
summary: {
importedSites: 3,
importedAccounts: 2,
importedProfiles: 2,
importedApiKeyConnections: 3,
skippedAccounts: 1,
ignoredSections: ['accounts.bookmarks', 'channelConfigs', 'tagStore'],
},
warnings: ['跳过 ALL-API-Hub 账号 skipped-account:authType=none 不支持离线迁移'],
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('shows ALL-API-Hub V2 preview counts and ignored sections', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
const textarea = root!.root.findByType('textarea');
await act(async () => {
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
});
await flushMicrotasks();
const rendered = collectText(root!.root);
expect(rendered).toContain('ALL-API-Hub V2');
expect(rendered).toContain('统计:账号 2 / 书签 1 / 独立 API 凭据 2');
expect(rendered).toContain('accounts.bookmarks、channelConfigs、tagStore');
} finally {
root?.unmount();
}
});
it('does not label native metapi backups as ALL-API-Hub V2', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
const textarea = root!.root.findByType('textarea');
await act(async () => {
textarea.props.onChange({ target: { value: nativeMetapiPayload } });
});
await flushMicrotasks();
const rendered = collectText(root!.root);
expect(rendered).not.toContain('ALL-API-Hub V2');
expect(rendered).toContain('统计:站点 1 / 账号 1 / 令牌 1 / 路由 1 / 通道 1 / 站点禁用模型 1 / 手工模型 1 / 下游 Key 1 / 设置 1');
} finally {
root?.unmount();
}
});
it('uses backend import summary in the completion toast', async () => {
const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
const textarea = root!.root.findByType('textarea');
await act(async () => {
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
});
await flushMicrotasks();
const importButton = root!.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('导入')
)).at(-1);
expect(importButton).toBeTruthy();
await act(async () => {
importButton!.props.onClick();
});
await flushMicrotasks();
expect(confirmSpy).toHaveBeenCalled();
expect(confirmSpy).toHaveBeenCalledWith(
'导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?',
);
expect(apiMock.importBackup).toHaveBeenCalledTimes(1);
expect(toastMock.success).toHaveBeenCalledWith(
expect.stringContaining('导入完成:连接与路由策略、系统设置'),
);
expect(toastMock.success).toHaveBeenCalledWith(
expect.stringContaining('站点 3 / 账号 2 / API Key 连接 3 / 跳过 1'),
);
expect(toastMock.success).toHaveBeenCalledWith(
expect.stringContaining('accounts.bookmarks、channelConfigs、tagStore'),
);
} finally {
root?.unmount();
}
});
it('loads webdav config and saves updates from the import/export page', async () => {
let root!: WebTestRenderer;
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('shows v2.1 config-backup wording and local-state notice', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = collectText(root!.root);
expect(rendered).toContain('Schema v2.1');
expect(rendered).toContain('导出全部(连接 + 路由 + 策略 + 设置)');
expect(rendered).toContain('仅导出连接与路由策略');
expect(rendered).toContain('覆盖备份中的连接/路由/策略配置,但会保留本机日志、公告、缓存和统计。');
} finally {
root?.unmount();
}
});
it('renders webdav export type with ModernSelect instead of a native select', async () => {
let root!: WebTestRenderer;
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);
const cronInput = root!.root.findAll((node) => (
node.type === 'input'
&& node.props.placeholder === '0 */6 * * *'
)).at(-1);
const selects = root!.root.findAllByType(ModernSelect);
const exportTypeSelect = selects.at(-1);
expect(fileUrlInput?.props.style).toEqual(expect.objectContaining({
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}));
expect(root!.root.findAll((node) => node.type === 'select')).toHaveLength(0);
expect(exportTypeSelect?.props.value).toBe('all');
expect(exportTypeSelect?.props.options).toEqual([
{ value: 'all', label: '全部' },
{ value: 'accounts', label: '连接与路由策略' },
{ value: 'preferences', label: '系统设置' },
]);
expect(cronInput?.props.style).toEqual(expect.objectContaining({
fontFamily: 'var(--font-mono)',
}));
} finally {
root?.unmount();
}
});
it('disables webdav actions while config has unsaved changes', async () => {
let root!: WebTestRenderer;
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!: WebTestRenderer;
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();
}
});
});
-881
View File
@@ -1,881 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import ModernSelect from '../components/ModernSelect.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
type BackupType = 'all' | 'accounts' | 'preferences';
type ParsedSummary = {
valid: boolean;
version: string;
timestampLabel: string;
hasAccounts: boolean;
hasPreferences: boolean;
hasLegacyData: boolean;
isAllApiHubV2: boolean;
sitesCount: number;
accountsCount: number;
bookmarksCount: number;
profilesCount: number;
tokensCount: number;
routesCount: number;
channelsCount: number;
siteDisabledModelsCount: number;
manualModelsCount: number;
downstreamApiKeysCount: number;
settingsCount: number;
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,
};
const WEBDAV_EXPORT_TYPE_OPTIONS = [
{ value: 'all', label: '全部' },
{ value: 'accounts', label: '连接与路由策略' },
{ value: 'preferences', label: '系统设置' },
] as const;
const formFieldLabelStyle: React.CSSProperties = {
fontSize: 13,
fontWeight: 500,
marginBottom: 8,
color: 'var(--color-text-secondary)',
};
const settingsInputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
boxSizing: 'border-box',
transition: 'border-color 0.2s',
};
const webdavToggleStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
cursor: 'pointer',
};
function downloadJsonFile(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function parseImportSummary(raw: string): ParsedSummary | null {
if (!raw.trim()) return null;
const invalidSummary = (): ParsedSummary => ({
valid: false,
version: '-',
timestampLabel: '未知',
hasAccounts: false,
hasPreferences: false,
hasLegacyData: false,
isAllApiHubV2: false,
sitesCount: 0,
accountsCount: 0,
bookmarksCount: 0,
profilesCount: 0,
tokensCount: 0,
routesCount: 0,
channelsCount: 0,
siteDisabledModelsCount: 0,
manualModelsCount: 0,
downstreamApiKeysCount: 0,
settingsCount: 0,
ignoredSections: [],
});
try {
const data = JSON.parse(raw) as any;
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return invalidSummary();
}
const accountsSection = (data.accounts && typeof data.accounts === 'object' && !Array.isArray(data.accounts))
? data.accounts
: null;
const preferencesSection = (data.preferences && typeof data.preferences === 'object' && !Array.isArray(data.preferences))
? data.preferences
: null;
const legacyAccounts = Boolean(data.data?.accounts || Array.isArray(data.accounts));
const legacyPrefs = Boolean(data.data?.preferences);
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
const isNativeBoosapiBackup = Boolean(
accountsSection
&& Array.isArray(accountsSection.sites)
&& Array.isArray(accountsSection.accountTokens)
&& Array.isArray(accountsSection.tokenRoutes)
&& Array.isArray(accountsSection.routeChannels)
);
const hasLegacyAccountRows = Array.isArray(accountsSection?.accounts)
&& accountsSection.accounts.some((row: any) => row && typeof row === 'object' && !Array.isArray(row) && (
'site_url' in row
|| 'site_type' in row
|| 'account_info' in row
|| 'cookieAuth' in row
|| 'authType' in row
|| 'sub2apiAuth' in row
));
const isAllApiHubV2 = Boolean(
accountsSection
&& !isNativeBoosapiBackup
&& hasLegacyAccountRows
&& Array.isArray(accountsSection.accounts)
&& (
(typeof data.version === 'string' && data.version.startsWith('2'))
|| 'last_updated' in accountsSection
|| Array.isArray(accountsSection.bookmarks)
|| Array.isArray(accountsSection.pinnedAccountIds)
|| Array.isArray(accountsSection.orderedAccountIds)
|| profilesCount > 0
)
);
const hasAccounts = Boolean(
data.type === 'accounts'
|| accountsSection
|| legacyAccounts,
);
const hasPreferences = Boolean(
data.type === 'preferences'
|| preferencesSection
|| legacyPrefs,
);
const ignoredSections: string[] = [];
if (bookmarksCount > 0) ignoredSections.push('accounts.bookmarks');
if (data.channelConfigs && typeof data.channelConfigs === 'object' && !Array.isArray(data.channelConfigs)) ignoredSections.push('channelConfigs');
if (data.tagStore && typeof data.tagStore === 'object' && !Array.isArray(data.tagStore)) ignoredSections.push('tagStore');
const toCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0);
const ts = data.timestamp !== undefined && data.timestamp !== null
? new Date(data.timestamp)
: null;
const timestampLabel = ts && !Number.isNaN(ts.getTime()) ? ts.toLocaleString() : '未知';
return {
valid: hasAccounts || hasPreferences,
version: typeof data.version === 'string' ? data.version : '1.0',
timestampLabel,
hasAccounts,
hasPreferences,
hasLegacyData: legacyAccounts || legacyPrefs,
isAllApiHubV2,
sitesCount: toCount(accountsSection?.sites),
accountsCount: toCount(accountsSection?.accounts),
bookmarksCount,
profilesCount,
tokensCount: toCount(accountsSection?.accountTokens),
routesCount: toCount(accountsSection?.tokenRoutes),
channelsCount: toCount(accountsSection?.routeChannels),
siteDisabledModelsCount: toCount(accountsSection?.siteDisabledModels),
manualModelsCount: toCount(accountsSection?.manualModels),
downstreamApiKeysCount: toCount(accountsSection?.downstreamApiKeys),
settingsCount: toCount(preferencesSection?.settings),
ignoredSections,
};
} catch {
return invalidSummary();
}
}
function buildImportSuccessMessage(result: any): string {
const sections: string[] = [];
if (result?.sections?.accounts) sections.push('连接与路由策略');
if (result?.sections?.preferences) sections.push('系统设置');
const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`];
if (result?.summary) {
const summary = result.summary;
parts.push(
[
`站点 ${summary.importedSites ?? 0}`,
`账号 ${summary.importedAccounts ?? 0}`,
`API Key 连接 ${summary.importedApiKeyConnections ?? summary.importedProfiles ?? 0}`,
`跳过 ${summary.skippedAccounts ?? 0}`,
].join(' / '),
);
if (Array.isArray(summary.ignoredSections) && summary.ignoredSections.length > 0) {
parts.push(`未原生导入 ${summary.ignoredSections.join('、')}`);
}
}
if (Array.isArray(result?.warnings) && result.warnings.length > 0) {
const preview = result.warnings.slice(0, 2).join(';');
parts.push(`提示:${preview}${result.warnings.length > 2 ? ` 等 ${result.warnings.length} 项` : ''}`);
}
return parts.join(';');
}
export default function ImportExport() {
const toast = useToast();
const [exportingType, setExportingType] = useState<BackupType | ''>('');
const [importing, setImporting] = useState(false);
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 {
const data = await api.exportBackup(type);
const date = new Date().toISOString().split('T')[0];
const fileName: Record<BackupType, string> = {
all: `boosapi-backup-${date}.json`,
accounts: `boosapi-accounts-${date}.json`,
preferences: `boosapi-preferences-${date}.json`,
};
downloadJsonFile(data, fileName[type]);
toast.success('导出成功');
} catch (err: any) {
toast.error(err?.message || '导出失败');
} finally {
setExportingType('');
}
};
const readFile = (file: File) => {
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
toast.error('请选择 JSON 格式的备份文件');
return;
}
setSelectedFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
setImportData(String(e.target?.result || ''));
};
reader.onerror = () => toast.error('读取文件失败');
reader.readAsText(file);
};
const handleImportFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
readFile(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) readFile(file);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
};
const handleImport = async () => {
if (!importData.trim()) {
toast.error('请先选择或粘贴 JSON 备份内容');
return;
}
if (!summary?.valid) {
toast.error('当前 JSON 结构无法识别');
return;
}
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
? true
: window.confirm('导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?');
if (!confirmed) {
return;
}
setImporting(true);
try {
const parsed = JSON.parse(importData);
const result = await api.importBackup(parsed);
toast.success(buildImportSuccessMessage(result));
setImportData('');
setSelectedFileName('');
} catch (err: any) {
toast.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
};
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 }}>
<div>
<h2 className="page-title" style={{ marginBottom: 6 }}>{tr('导入 / 导出')}</h2>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
支持配置型备份、分区备份与手动恢复。
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-muted" style={{ fontSize: 11 }}>Schema v2.1</span>
<span className="badge badge-warning" style={{ fontSize: 11 }}>敏感数据请离线保管</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(360px,1fr))', gap: 14 }}>
{/* ====== 导出区 ====== */}
<div className="card animate-slide-up stagger-1" style={{ 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span style={{ fontSize: 15, fontWeight: 700 }}>导出数据</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
将连接、路由策略与设置导出为 JSON 文件进行备份
</div>
<div style={{ display: 'grid', gap: 8 }}>
<button
onClick={() => handleExport('all')}
disabled={!!exportingType}
className="btn btn-primary"
style={{ justifyContent: 'space-between' }}
>
<span>导出全部(连接 + 路由 + 策略 + 设置)</span>
{exportingType === 'all' ? <span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> : null}
</button>
<button
onClick={() => handleExport('accounts')}
disabled={!!exportingType}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', justifyContent: 'space-between' }}
>
<span>仅导出连接与路由策略</span>
{exportingType === 'accounts' ? <span className="spinner spinner-sm" /> : null}
</button>
<button
onClick={() => handleExport('preferences')}
disabled={!!exportingType}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', justifyContent: 'space-between' }}
>
<span>仅导出系统设置</span>
{exportingType === 'preferences' ? <span className="spinner spinner-sm" /> : null}
</button>
</div>
</div>
{/* ====== 导入区 ====== */}
<div className="card animate-slide-up stagger-2" style={{ 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span style={{ fontSize: 15, fontWeight: 700 }}>导入数据</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
从备份文件恢复数据
</div>
<div style={{ display: 'grid', gap: 12 }}>
{/* ---- 拖拽上传区 ---- */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
style={{
border: `2px dashed ${dragOver ? 'var(--color-primary)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)',
padding: '28px 20px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: dragOver ? 'var(--color-primary-light)' : 'var(--color-bg)',
}}
>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
onChange={handleImportFile}
style={{ display: 'none' }}
/>
{selectedFileName ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<svg width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{selectedFileName}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
点击重新选择文件
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<svg width="36" height="36" fill="none" viewBox="0 0 24 24" stroke={dragOver ? 'var(--color-primary)' : 'var(--color-text-muted)'} style={{ transition: 'stroke 0.2s' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div style={{ fontSize: 13, fontWeight: 600, color: dragOver ? 'var(--color-primary)' : 'var(--color-text-secondary)' }}>
{dragOver ? '松开以导入文件' : '拖拽 JSON 备份文件到此处'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
或 <span style={{ color: 'var(--color-primary)', fontWeight: 500 }}>点击选择文件</span>
</div>
</div>
)}
</div>
{/* ---- 数据预览 ---- */}
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>数据预览</div>
<textarea
value={importData}
onChange={(e) => setImportData(e.target.value)}
placeholder="粘贴 JSON 数据或通过上面的拖拽区域导入..."
style={{
width: '100%',
minHeight: 100,
resize: 'vertical',
fontFamily: 'var(--font-mono)',
fontSize: 12,
padding: '10px 12px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
</div>
{/* ---- 解析摘要 ---- */}
{summary ? (
<div
style={{
borderRadius: 'var(--radius-sm)',
border: `1px solid ${summary.valid
? 'color-mix(in srgb, var(--color-success) 35%, transparent)'
: 'color-mix(in srgb, var(--color-danger) 35%, transparent)'}`,
background: summary.valid
? 'color-mix(in srgb, var(--color-success) 10%, transparent)'
: 'color-mix(in srgb, var(--color-danger) 10%, transparent)',
color: summary.valid ? 'var(--color-success)' : 'var(--color-danger)',
padding: '10px 12px',
fontSize: 12,
lineHeight: 1.6,
}}
>
{summary.valid ? (
<>
<div>结构有效,版本:{summary.version},时间:{summary.timestampLabel}</div>
<div>包含分区:{summary.hasAccounts ? '连接与路由策略' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}</div>
{summary.isAllApiHubV2 ? (
<>
<div>检测到 ALL-API-Hub V2 兼容备份:将离线迁移可用连接。</div>
<div>
统计:账号 {summary.accountsCount} / 书签 {summary.bookmarksCount} / 独立 API 凭据 {summary.profilesCount}
</div>
{summary.ignoredSections.length ? (
<div>不会原生导入:{summary.ignoredSections.join('、')}</div>
) : null}
</>
) : null}
{(summary.sitesCount
|| summary.accountsCount
|| summary.tokensCount
|| summary.routesCount
|| summary.channelsCount
|| summary.siteDisabledModelsCount
|| summary.manualModelsCount
|| summary.downstreamApiKeysCount
|| summary.settingsCount) ? (
<div>
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 站点禁用模型 {summary.siteDisabledModelsCount} / 手工模型 {summary.manualModelsCount} / 下游 Key {summary.downstreamApiKeysCount} / 设置 {summary.settingsCount}
</div>
) : null}
{summary.hasLegacyData ? <div>检测到兼容结构:将按兼容模式导入。</div> : null}
</>
) : (
<div>JSON 可解析,但结构不受支持。</div>
)}
</div>
) : null}
{/* ---- 操作按钮 ---- */}
<button
onClick={handleImport}
disabled={importing || !summary?.valid}
className="btn btn-primary"
style={{ width: '100%', padding: '10px 0', fontSize: 14, fontWeight: 600, borderRadius: 'var(--radius-sm)' }}
>
{importing ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 导入中...</> : '导入'}
</button>
</div>
</div>
</div>
<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(240px, 1fr))', gap: '16px 20px' }}>
<div style={{ gridColumn: '1 / -1' }}>
<div style={formFieldLabelStyle}>文件 URL</div>
<input
value={webdavConfig.fileUrl}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, fileUrl: e.target.value }))}
placeholder="https://dav.example.com/backups/boosapi.json"
style={settingsInputStyle}
/>
</div>
<div>
<div style={formFieldLabelStyle}>用户名</div>
<input
value={webdavConfig.username}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, username: e.target.value }))}
placeholder="可留空"
style={settingsInputStyle}
/>
</div>
<div>
<div style={formFieldLabelStyle}>密码</div>
<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={settingsInputStyle}
/>
{webdavConfig.hasPassword ? (
<label style={{ ...webdavToggleStyle, marginTop: 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: '' }));
}
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
/>
清空已保存密码
</label>
) : null}
</div>
<div>
<div style={formFieldLabelStyle}>导出分区</div>
<ModernSelect
value={webdavConfig.exportType}
onChange={(value) => setWebdavConfig((prev) => ({ ...prev, exportType: value as BackupType }))}
options={[...WEBDAV_EXPORT_TYPE_OPTIONS]}
/>
</div>
<div>
<div style={formFieldLabelStyle}>自动同步 Cron</div>
<input
value={webdavConfig.autoSyncCron}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncCron: e.target.value }))}
placeholder="0 */6 * * *"
style={{ ...settingsInputStyle, fontFamily: 'var(--font-mono)' }}
/>
</div>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginTop: 16 }}>
<label
style={{
...webdavToggleStyle,
color: webdavConfig.enabled ? 'var(--color-primary)' : 'var(--color-text-secondary)',
}}
>
<input
type="checkbox"
checked={webdavConfig.enabled}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, enabled: e.target.checked }))}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
/>
启用 WebDAV
</label>
<label
style={{
...webdavToggleStyle,
color: webdavConfig.autoSyncEnabled ? 'var(--color-primary)' : 'var(--color-text-secondary)',
}}
>
<input
type="checkbox"
checked={webdavConfig.autoSyncEnabled}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncEnabled: e.target.checked }))}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
/>
自动同步
</label>
</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. 导入连接分区会覆盖备份中的站点、账号、令牌、路由、禁用模型、手工模型和下游 Key 配置。</div>
<div>2. 覆盖备份中的连接/路由/策略配置,但会保留本机日志、公告、缓存和统计。</div>
<div>3. 为避免锁死管理界面,管理员登录令牌(`auth_token`)不会从备份导入。</div>
<div>4. 建议先导出一份"全部备份"再执行导入操作。</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,798 +0,0 @@
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 Models from './Models.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getModelsMarketplace: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Models marketplace text', () => {
const originalDocument = globalThis.document;
const originalMutationObserver = globalThis.MutationObserver;
const originalWindow = globalThis.window;
const originalMatchMedia = globalThis.matchMedia;
beforeEach(() => {
vi.clearAllMocks();
globalThis.document = {
documentElement: {
getAttribute: () => 'light',
},
} as unknown as Document;
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof MutationObserver;
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 1,
tokenCount: 1,
avgLatency: 320,
successRate: 98,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: 'Demo Site',
username: 'tester',
latency: 320,
balance: 12.5,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
],
});
});
afterEach(() => {
vi.clearAllMocks();
globalThis.document = originalDocument;
globalThis.MutationObserver = originalMutationObserver;
globalThis.window = originalWindow;
globalThis.matchMedia = originalMatchMedia;
});
it('renders readable Chinese labels and fallback descriptions for marketplace models', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const initialText = collectText(root!.root);
expect(initialText).toContain('品牌');
expect(initialText).toContain('排序方式');
expect(initialText).toContain('模型广场');
const cards = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('model-card')
&& typeof node.props.onClick === 'function'
));
expect(cards.length).toBeGreaterThan(0);
await act(async () => {
cards[0]!.props.onClick();
});
await flushMicrotasks();
const expandedText = collectText(root!.root);
expect(expandedText).toContain('当前上游仅返回模型 ID,未返回描述字段。');
expect(expandedText).toContain('基础信息');
expect(expandedText).toContain('站点');
expect(expandedText).toContain('余额');
} finally {
root?.unmount();
}
});
it('shows newly recognized brands in the marketplace filter panel', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'nvidia/vila',
accountCount: 1,
tokenCount: 1,
avgLatency: 210,
successRate: 97,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '公益站 A',
username: 'tester',
latency: 210,
balance: 6.5,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
{
name: 'deepl-zh-en',
accountCount: 1,
tokenCount: 1,
avgLatency: 160,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 2,
site: '公益站 B',
username: 'tester',
latency: 160,
balance: 8.8,
tokens: [{ id: 2, name: 'default', isDefault: true }],
},
],
},
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('NVIDIA');
expect(text).toContain('DeepL');
expect(text).not.toContain('其他未归类的模型');
} finally {
root?.unmount();
}
});
it('shows newly added provider fallback brands without losing vendor brands in the filter panel', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'openrouter/openrouter-auto',
accountCount: 1,
tokenCount: 1,
avgLatency: 180,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '平台站 A',
username: 'tester',
latency: 180,
balance: 9.9,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
{
name: 'deepinfra/meta-llama/llama-3.3-70b-instruct',
accountCount: 1,
tokenCount: 1,
avgLatency: 240,
successRate: 98,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 2,
site: '平台站 B',
username: 'tester',
latency: 240,
balance: 7.2,
tokens: [{ id: 2, name: 'default', isDefault: true }],
},
],
},
{
name: 'groq/compound-beta',
accountCount: 1,
tokenCount: 1,
avgLatency: 95,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 3,
site: '平台站 C',
username: 'tester',
latency: 95,
balance: 5.1,
tokens: [{ id: 3, name: 'default', isDefault: true }],
},
],
},
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('OpenRouter');
expect(text).toContain('Meta');
expect(text).toContain('Groq');
expect(text).not.toContain('其他未归类的模型');
} finally {
root?.unmount();
}
});
it('shows user-reported recognizable brands in the marketplace filter panel', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'xiaomi/mimo-v2-pro',
accountCount: 1,
tokenCount: 1,
avgLatency: 180,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '平台站 A',
username: 'tester',
latency: 180,
balance: 9.9,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
{
name: 'arcee-ai/trinity-mini',
accountCount: 1,
tokenCount: 1,
avgLatency: 240,
successRate: 98,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 2,
site: '平台站 B',
username: 'tester',
latency: 240,
balance: 7.2,
tokens: [{ id: 2, name: 'default', isDefault: true }],
},
],
},
{
name: 'amazon/nova-premier-v1',
accountCount: 1,
tokenCount: 1,
avgLatency: 95,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 3,
site: '平台站 C',
username: 'tester',
latency: 95,
balance: 5.1,
tokens: [{ id: 3, name: 'default', isDefault: true }],
},
],
},
{
name: 'LongCat-Flash-Lite',
accountCount: 1,
tokenCount: 1,
avgLatency: 95,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 4,
site: '平台站 D',
username: 'tester',
latency: 95,
balance: 5.1,
tokens: [{ id: 4, name: 'default', isDefault: true }],
},
],
},
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('Xiaomi MiMo');
expect(text).toContain('Arcee');
expect(text).toContain('Amazon Nova');
expect(text).toContain('LongCat');
expect(text).not.toContain('其他未归类的模型');
} finally {
root?.unmount();
}
});
it('keeps a visible mobile filter entry on small screens', async () => {
const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis;
nextWindow.innerWidth = 768;
nextWindow.addEventListener = nextWindow.addEventListener || (() => {});
nextWindow.removeEventListener = nextWindow.removeEventListener || (() => {});
nextWindow.matchMedia = (() => ({
matches: true,
media: '(max-width: 768px)',
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
globalThis.window = nextWindow;
globalThis.matchMedia = nextWindow.matchMedia;
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('筛选');
} finally {
root?.unmount();
}
});
it('keeps the mobile filter entry visible even while the first screen is still loading', async () => {
globalThis.window = {
innerWidth: 768,
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window & typeof globalThis;
apiMock.getModelsMarketplace.mockImplementation(() => new Promise(() => {}));
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
expect(collectText(root!.root)).toContain('筛选');
} finally {
root?.unmount();
}
});
it('limits expanded account and pricing detail to the selected site filter', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 2,
tokenCount: 3,
avgLatency: 500,
successRate: 96,
description: 'demo model',
tags: ['chat'],
supportedEndpointTypes: ['openai'],
pricingSources: [
{
siteId: 1,
siteName: '站点 A',
accountId: 1,
username: 'user-a',
ownerBy: null,
enableGroups: [],
groupPricing: {
default: {
quotaType: 0,
inputPerMillion: 1,
outputPerMillion: 2,
},
},
},
{
siteId: 2,
siteName: '站点 B',
accountId: 2,
username: 'user-b',
ownerBy: null,
enableGroups: [],
groupPricing: {
default: {
quotaType: 0,
inputPerMillion: 3,
outputPerMillion: 4,
},
},
},
],
accounts: [
{
id: 1,
site: '站点 A',
username: 'user-a',
latency: 320,
balance: 12.5,
tokens: [
{ id: 1, name: 'token-a-1', isDefault: true },
{ id: 2, name: 'token-a-2', isDefault: false },
],
},
{
id: 2,
site: '站点 B',
username: 'user-b',
latency: 680,
balance: 8.4,
tokens: [
{ id: 3, name: 'token-b-1', isDefault: true },
],
},
],
},
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const siteFilterItem = root!.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('filter-item')
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('站点 A')
));
await act(async () => {
siteFilterItem.props.onClick();
});
await flushMicrotasks();
const cards = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('model-card')
&& typeof node.props.onClick === 'function'
));
expect(cards.length).toBeGreaterThan(0);
await act(async () => {
cards[0]!.props.onClick();
});
await flushMicrotasks();
const expandedSections = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('model-card-expand')
));
expect(expandedSections.length).toBe(1);
const expandedText = collectText(expandedSections[0]!);
expect(expandedText).toContain('站点 A');
expect(expandedText).toContain('user-a');
expect(expandedText).toContain('token-a-1');
expect(expandedText).not.toContain('站点 B');
expect(expandedText).not.toContain('user-b');
expect(expandedText).not.toContain('token-b-1');
} finally {
root?.unmount();
}
});
it('re-sorts models using site-scoped counts after selecting a site filter', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 4,
tokenCount: 4,
avgLatency: 300,
successRate: 98,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '站点 A',
username: 'user-a-1',
latency: 300,
balance: 8,
tokens: [{ id: 1, name: 'token-a-1', isDefault: true }],
},
{
id: 2,
site: '站点 B',
username: 'user-b-1',
latency: 200,
balance: 8,
tokens: [{ id: 2, name: 'token-b-1', isDefault: true }],
},
{
id: 3,
site: '站点 B',
username: 'user-b-2',
latency: 250,
balance: 8,
tokens: [{ id: 3, name: 'token-b-2', isDefault: true }],
},
{
id: 4,
site: '站点 B',
username: 'user-b-3',
latency: 260,
balance: 8,
tokens: [{ id: 4, name: 'token-b-3', isDefault: true }],
},
],
},
{
name: 'claude-3-5-sonnet',
accountCount: 2,
tokenCount: 2,
avgLatency: 420,
successRate: 95,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 5,
site: '站点 A',
username: 'user-a-2',
latency: 410,
balance: 9,
tokens: [{ id: 5, name: 'token-a-2', isDefault: true }],
},
{
id: 6,
site: '站点 A',
username: 'user-a-3',
latency: 430,
balance: 9,
tokens: [{ id: 6, name: 'token-a-3', isDefault: true }],
},
],
},
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const siteFilterItem = root!.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('filter-item')
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('站点 A')
));
await act(async () => {
siteFilterItem.props.onClick();
});
await flushMicrotasks();
const cards = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.split(' ').includes('model-card')
&& typeof node.props.onClick === 'function'
));
expect(cards.length).toBe(2);
expect(collectText(cards[0]!)).toContain('claude-3-5-sonnet');
expect(collectText(cards[1]!)).toContain('gpt-4o');
} finally {
root?.unmount();
}
});
it('renders unknown latency instead of falling back to another site latency', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 2,
tokenCount: 2,
avgLatency: 680,
successRate: 93,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '站点 A',
username: 'user-a',
latency: null,
balance: 12,
tokens: [{ id: 1, name: 'token-a', isDefault: true }],
},
{
id: 2,
site: '站点 B',
username: 'user-b',
latency: 680,
balance: 12,
tokens: [{ id: 2, name: 'token-b', isDefault: true }],
},
],
},
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const siteFilterItem = root!.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('filter-item')
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('站点 A')
));
await act(async () => {
siteFilterItem.props.onClick();
});
await flushMicrotasks();
const latencyBadge = root!.root.find((node) => (
node.type === 'span'
&& node.props['data-tooltip'] === '平均延迟'
));
expect(String(latencyBadge.props.className || '')).toContain('badge-muted');
expect(collectText(latencyBadge)).toContain('延迟');
expect(collectText(latencyBadge)).toContain('—');
expect(collectText(root!.root)).not.toContain('680ms');
} finally {
root?.unmount();
}
});
});
File diff suppressed because it is too large Load Diff
-228
View File
@@ -1,228 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js';
type MonitorSite = {
id: string;
name: string;
url: string;
description: string;
requiresLinuxDoOAuth?: boolean;
};
type MonitorConfig = {
ldohCookieConfigured: boolean;
ldohCookieMasked?: string;
};
const MONITOR_SITES: MonitorSite[] = [
{
id: 'check-linux-do',
name: 'check.linux.do',
url: 'https://check.linux.do',
description: 'LinuxDo 可用性监控',
},
{
id: 'ldoh-105117',
name: 'ldoh.105117.xyz',
url: 'https://ldoh.105117.xyz',
description: 'LDOH 监控面板',
requiresLinuxDoOAuth: true,
},
];
export default function Monitors() {
const toast = useToast();
const [activeSiteId, setActiveSiteId] = useState(MONITOR_SITES[0].id);
const [reloadSeed, setReloadSeed] = useState(0);
const [loaded, setLoaded] = useState(false);
const [showFallbackHint, setShowFallbackHint] = useState(false);
const [monitorConfig, setMonitorConfig] = useState<MonitorConfig>({ ldohCookieConfigured: false });
const [cookieInput, setCookieInput] = useState('');
const [savingCookie, setSavingCookie] = useState(false);
const activeSite = useMemo(
() => MONITOR_SITES.find((site) => site.id === activeSiteId) || MONITOR_SITES[0],
[activeSiteId],
);
const loadMonitorConfig = async () => {
try {
const res = await api.getMonitorConfig();
setMonitorConfig({
ldohCookieConfigured: !!res?.ldohCookieConfigured,
ldohCookieMasked: typeof res?.ldohCookieMasked === 'string' ? res.ldohCookieMasked : '',
});
} catch (err: any) {
toast.error(err?.message || '加载监控配置失败');
}
};
useEffect(() => {
void loadMonitorConfig();
// Set HttpOnly monitor auth cookie for iframe proxy.
void api.initMonitorSession().catch(() => {});
}, []);
useEffect(() => {
setLoaded(false);
setShowFallbackHint(false);
const timer = window.setTimeout(() => {
setShowFallbackHint(true);
}, 4500);
void api.initMonitorSession().catch(() => {});
return () => window.clearTimeout(timer);
}, [activeSiteId, reloadSeed]);
const usingCookieProxy = activeSite.id === 'ldoh-105117' && monitorConfig.ldohCookieConfigured;
const oauthHintPresence = useAnimatedVisibility(Boolean(activeSite.requiresLinuxDoOAuth), 220);
const fallbackHintPresence = useAnimatedVisibility(showFallbackHint && !loaded, 180);
const directSiteUrl = `${activeSite.url.replace(/\/$/, '')}/`;
const iframeUrl = usingCookieProxy ? '/monitor-proxy/ldoh/' : directSiteUrl;
const ldohOauthUrl = `${directSiteUrl}api/oauth/initiate?returnTo=%2F`;
const handleSaveCookie = async () => {
setSavingCookie(true);
try {
await api.updateMonitorConfig({ ldohCookie: cookieInput.trim() || null });
await loadMonitorConfig();
setCookieInput('');
setReloadSeed((prev) => prev + 1);
toast.success('LDOH Cookie 已更新');
} catch (err: any) {
toast.error(err?.message || '保存 Cookie 失败');
} finally {
setSavingCookie(false);
}
};
const fallbackHint = usingCookieProxy
? '代理模式已启用:若仍无法加载,请检查 Cookie 是否过期后重新保存。'
: '当前站点可能禁止 iframe 内嵌,或 OAuth 跨站 Cookie 受限。建议先新窗口授权再回到此页刷新。';
return (
<div className="animate-fade-in monitor-page">
<div className="monitor-toolbar page-header">
<div>
<h2 className="page-title">{tr('监控内嵌')}</h2>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
在 BoosAPI 内查看外部站点监控页面。
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => setReloadSeed((prev) => prev + 1)}
data-tooltip="重新加载当前站点"
aria-label="重新加载当前站点"
>
刷新
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => window.open(directSiteUrl, '_blank', 'noopener,noreferrer')}
data-tooltip="在新窗口直接打开目标站点"
aria-label="在新窗口直接打开目标站点"
>
新窗口打开
</button>
</div>
</div>
<div className="monitor-tabs card">
{MONITOR_SITES.map((site) => (
<button
key={site.id}
type="button"
className={`monitor-tab ${site.id === activeSite.id ? 'active' : ''}`}
onClick={() => setActiveSiteId(site.id)}
>
<span>{site.name}</span>
<span className="monitor-tab-desc">{site.description}</span>
</button>
))}
</div>
{oauthHintPresence.shouldRender && (
<div className={`monitor-oauth-hint card panel-presence ${oauthHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{usingCookieProxy ? '已启用 Cookie 代理模式' : '该站点需要 LinuxDo OAuth 授权'}
</div>
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.65 }}>
{!usingCookieProxy && (
<>
1. 先点击“授权登录(新窗口)”完成 LinuxDo 登录。<br />
2. 回来后把 `ld_auth_session` 填到下方,启用 Cookie 代理内嵌。<br />
</>
)}
{usingCookieProxy && (
<>
当前使用服务端代理访问,不依赖跨站第三方 Cookie。<br />
已保存 Cookie:{monitorConfig.ldohCookieMasked || '(已配置)'}<br />
</>
)}
Cookie 过期后请重新粘贴保存。
</div>
<div className="monitor-cookie-row">
<input
value={cookieInput}
onChange={(e) => setCookieInput(e.target.value)}
placeholder="粘贴 ld_auth_session 或 ld_auth_session=xxx"
className="monitor-cookie-input"
/>
<button
type="button"
className="btn btn-primary"
onClick={handleSaveCookie}
disabled={savingCookie}
>
{savingCookie ? '保存中...' : (cookieInput.trim() ? '保存 Cookie' : '清空 Cookie')}
</button>
</div>
<div style={{ marginTop: 10, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => window.open(ldohOauthUrl, '_blank', 'noopener,noreferrer')}
>
授权登录(新窗口)
</button>
{usingCookieProxy && (
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => window.open('/monitor-proxy/ldoh/', '_blank', 'noopener,noreferrer')}
>
通过代理打开
</button>
)}
</div>
</div>
)}
<div className="monitor-frame-shell card">
{fallbackHintPresence.shouldRender && (
<div className={`monitor-hint panel-presence ${fallbackHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
{fallbackHint}
</div>
)}
<iframe
key={`${activeSite.id}-${reloadSeed}-${usingCookieProxy ? 'proxy' : 'direct'}`}
src={iframeUrl}
title={`monitor-${activeSite.id}`}
className="monitor-iframe"
onLoad={() => setLoaded(true)}
/>
</div>
</div>
);
}
-188
View File
@@ -1,188 +0,0 @@
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 NotificationSettings from './NotificationSettings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getRuntimeSettings: vi.fn(),
updateRuntimeSettings: vi.fn(),
testNotification: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('NotificationSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getRuntimeSettings.mockResolvedValue({
webhookUrl: '',
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: true,
telegramApiBaseUrl: 'https://tg-proxy.example.com',
telegramChatId: '-1001234567890',
telegramMessageThreadId: '77',
telegramBotTokenMasked: '1234****token',
telegramUseSystemProxy: false,
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUser: '',
smtpFrom: '',
smtpTo: '',
notifyCooldownSec: 300,
});
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
telegramApiBaseUrl: 'https://proxy.example.com/custom',
telegramMessageThreadId: '88',
telegramBotTokenMasked: '1234****token',
});
apiMock.testNotification.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('loads and saves telegram api base url and topic id', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<NotificationSettings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const proxyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '例如: https://your-proxy.example.com'
));
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) => (
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({
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!: WebTestRenderer;
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();
}
});
});
-515
View File
@@ -1,515 +0,0 @@
import React, { useEffect, useState } from 'react';
import { api, type RuntimeSettingsPayload } from '../api.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
type RuntimeSettings = {
webhookUrl: string;
barkUrl: string;
webhookEnabled: boolean;
barkEnabled: boolean;
serverChanEnabled: boolean;
telegramEnabled: boolean;
telegramApiBaseUrl: string;
telegramChatId: string;
telegramUseSystemProxy: boolean;
telegramMessageThreadId: string;
smtpEnabled: boolean;
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string;
smtpPassMasked?: string;
smtpFrom: string;
smtpTo: string;
serverChanKeyMasked?: string;
telegramBotTokenMasked?: string;
notifyCooldownSec: number;
};
export default function NotificationSettings() {
const [runtime, setRuntime] = useState<RuntimeSettings>({
webhookUrl: '',
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: false,
telegramApiBaseUrl: 'https://api.telegram.org',
telegramChatId: '',
telegramUseSystemProxy: false,
telegramMessageThreadId: '',
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUser: '',
smtpFrom: '',
smtpTo: '',
notifyCooldownSec: 300,
});
const [serverChanKey, setServerChanKey] = useState('');
const [telegramBotToken, setTelegramBotToken] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [loading, setLoading] = useState(true);
const [savingNotify, setSavingNotify] = useState(false);
const [testingNotify, setTestingNotify] = useState(false);
const toast = useToast();
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
transition: 'border-color 0.2s',
};
const loadSettings = async () => {
setLoading(true);
try {
const runtimeInfo = await api.getRuntimeSettings();
setRuntime({
webhookUrl: runtimeInfo.webhookUrl || '',
barkUrl: runtimeInfo.barkUrl || '',
webhookEnabled: runtimeInfo.webhookEnabled ?? true,
barkEnabled: runtimeInfo.barkEnabled ?? true,
serverChanEnabled: !!runtimeInfo.serverChanEnabled,
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,
smtpSecure: !!runtimeInfo.smtpSecure,
smtpUser: runtimeInfo.smtpUser || '',
smtpPassMasked: runtimeInfo.smtpPassMasked || '',
smtpFrom: runtimeInfo.smtpFrom || '',
smtpTo: runtimeInfo.smtpTo || '',
serverChanKeyMasked: runtimeInfo.serverChanKeyMasked || '',
telegramBotTokenMasked: runtimeInfo.telegramBotTokenMasked || '',
notifyCooldownSec: Number.isFinite(Number(runtimeInfo.notifyCooldownSec))
? Math.max(0, Math.trunc(Number(runtimeInfo.notifyCooldownSec)))
: 300,
});
} catch (err: any) {
toast.error(err?.message || '加载通知设置失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSettings();
}, []);
const saveNotify = async () => {
setSavingNotify(true);
try {
const payload: RuntimeSettingsPayload = {
webhookUrl: runtime.webhookUrl,
barkUrl: runtime.barkUrl,
webhookEnabled: runtime.webhookEnabled,
barkEnabled: runtime.barkEnabled,
serverChanEnabled: runtime.serverChanEnabled,
telegramEnabled: runtime.telegramEnabled,
telegramApiBaseUrl: runtime.telegramApiBaseUrl,
telegramChatId: runtime.telegramChatId,
telegramUseSystemProxy: runtime.telegramUseSystemProxy,
telegramMessageThreadId: runtime.telegramMessageThreadId,
smtpEnabled: runtime.smtpEnabled,
smtpHost: runtime.smtpHost,
smtpPort: runtime.smtpPort,
smtpSecure: runtime.smtpSecure,
smtpUser: runtime.smtpUser,
smtpFrom: runtime.smtpFrom,
smtpTo: runtime.smtpTo,
notifyCooldownSec: Math.max(0, Math.trunc(Number(runtime.notifyCooldownSec) || 0)),
};
if (serverChanKey.trim()) payload.serverChanKey = serverChanKey.trim();
if (telegramBotToken.trim()) payload.telegramBotToken = telegramBotToken.trim();
if (smtpPass.trim()) payload.smtpPass = smtpPass.trim();
const res = await api.updateRuntimeSettings(payload);
setRuntime((prev) => ({
...prev,
serverChanKeyMasked: res.serverChanKeyMasked || prev.serverChanKeyMasked,
telegramBotTokenMasked: res.telegramBotTokenMasked || prev.telegramBotTokenMasked,
smtpPassMasked: res.smtpPassMasked || prev.smtpPassMasked,
}));
setServerChanKey('');
setTelegramBotToken('');
setSmtpPass('');
toast.success('通知设置已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingNotify(false);
}
};
const testNotify = async () => {
setTestingNotify(true);
try {
const res = await api.testNotification();
toast.success(res?.message || '测试通知已发送');
} catch (err: any) {
toast.error(err?.message || '触发测试通知失败');
} finally {
setTestingNotify(false);
}
};
if (loading) {
return (
<div className="animate-fade-in">
<div className="skeleton" style={{ width: 220, height: 28, marginBottom: 20 }} />
<div className="skeleton" style={{ width: '100%', height: 320, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
return (
<div className="animate-fade-in" style={{ paddingBottom: 40 }}>
{/* 头部标题与操作 */}
<div className="page-header">
<h2 className="page-title">{tr('通知设置')}</h2>
<div className="page-actions">
<button onClick={testNotify} disabled={testingNotify} className="btn btn-success">
{testingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 发送中...</> : '发送测试通知'}
</button>
<button onClick={saveNotify} disabled={savingNotify} className="btn btn-primary">
{savingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存通知设置'}
</button>
</div>
</div>
<div style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="card animate-slide-up stagger-1" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8 }}>告警去噪与冷静期</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
相同告警在冷静期内不会重复推送;冷静期结束后会自动合并重复条数。
</div>
<div style={{ maxWidth: 260 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
冷静期(秒)
</div>
<input
type="number"
min={0}
value={runtime.notifyCooldownSec}
onChange={(e) => setRuntime((prev) => ({
...prev,
notifyCooldownSec: Math.max(0, Math.trunc(Number(e.target.value) || 0)),
}))}
style={inputStyle}
/>
</div>
</div>
{/* 卡片:Webhook & Bark */}
<div className="card animate-slide-up stagger-2" style={{ padding: 24, border: (runtime.webhookEnabled || runtime.barkEnabled) ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Webhook & Bark</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 HTTP URL 推送消息通知(自动识别企业微信、飞书格式)</div>
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.webhookEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Webhook</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.webhookEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, webhookEnabled: e.target.checked }))}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.barkEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Bark</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.barkEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, barkEnabled: e.target.checked }))}
/>
</label>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ opacity: runtime.webhookEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Webhook URL</div>
<input
value={runtime.webhookUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, webhookUrl: e.target.value }))}
placeholder="https://your-webhook-url (可选)"
style={inputStyle}
disabled={!runtime.webhookEnabled}
/>
</div>
<div style={{ opacity: runtime.barkEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Bark URL</div>
<input
value={runtime.barkUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, barkUrl: e.target.value }))}
placeholder="https://api.day.app/your_key (可选)"
style={inputStyle}
disabled={!runtime.barkEnabled}
/>
</div>
</div>
</div>
{/* 卡片:Server酱 */}
<div className="card animate-slide-up stagger-3" style={{ padding: 24, border: runtime.serverChanEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-warning-soft)', color: 'var(--color-warning)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Server酱 (SendKey)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>微信推送消息支持</div>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.serverChanEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Server酱</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.serverChanEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, serverChanEnabled: e.target.checked }))}
/>
</label>
</div>
<div style={{ opacity: runtime.serverChanEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 10 }}>
当前配置: {runtime.serverChanKeyMasked || '未设置'}
</code>
<input
type="password"
value={serverChanKey}
onChange={(e) => setServerChanKey(e.target.value)}
placeholder="输入新的 Server酱 Key(留空则不改)"
style={inputStyle}
disabled={!runtime.serverChanEnabled}
/>
</div>
</div>
{/* 卡片:Telegram */}
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.telegramEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 11l18-8-6 18-3-7-9-3z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Telegram Bot</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 Telegram 机器人推送消息通知</div>
</div>
</div>
<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' }}>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram API Base URL</div>
<input
value={runtime.telegramApiBaseUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramApiBaseUrl: e.target.value }))}
placeholder="例如: https://your-proxy.example.com"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
留空或使用默认值时直连官方 Telegram API;如需国内反代,可填写反代前缀。
</div>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram Chat ID</div>
<input
value={runtime.telegramChatId}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramChatId: e.target.value }))}
placeholder="例如: -1001234567890 或 @your_channel"
style={inputStyle}
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
{runtime.telegramBotTokenMasked && <span style={{ color: 'var(--color-primary)', marginLeft: 8, fontSize: 12 }}>(当前已设置)</span>}
</div>
<input
type="password"
value={telegramBotToken}
onChange={(e) => setTelegramBotToken(e.target.value)}
placeholder="输入新的 Bot Token(留空则不改)"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
</div>
</div>
</div>
{/* 卡片:SMTP 邮件设置 */}
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.smtpEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>邮件服务 (SMTP)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过电子邮件推送提醒</div>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.smtpEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 SMTP</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.smtpEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpEnabled: e.target.checked }))}
/>
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '16px 20px', opacity: runtime.smtpEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
{/* Host */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>SMTP 服务器</div>
<input
value={runtime.smtpHost}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpHost: e.target.value }))}
placeholder="例如: smtp.qq.com"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* Port & Secure */}
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>端口</div>
<input
type="number"
min={1}
value={runtime.smtpPort}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpPort: Number(e.target.value) || 0 }))}
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', paddingBottom: 12 }}>
<input
type="checkbox"
checked={runtime.smtpSecure}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpSecure: e.target.checked }))}
disabled={!runtime.smtpEnabled}
/>
启用 TLS/SSL
</label>
</div>
{/* User */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>账号用户</div>
<input
value={runtime.smtpUser}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpUser: e.target.value }))}
placeholder="SMTP 用户名"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* Pass */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
账号密码
{runtime.smtpPassMasked && <span style={{ color: 'var(--color-primary)', marginLeft: 8, fontSize: 12 }}>(当前已设置)</span>}
</div>
<input
type="password"
value={smtpPass}
onChange={(e) => setSmtpPass(e.target.value)}
placeholder="输入以更改密码..."
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* From */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>发件人地址</div>
<input
value={runtime.smtpFrom}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpFrom: e.target.value }))}
placeholder="例如: admin@example.com"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* To */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>接收地址</div>
<input
value={runtime.smtpTo}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpTo: e.target.value }))}
placeholder="例如: target@example.com"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-115
View File
@@ -1,115 +0,0 @@
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 ProgramLogs from './ProgramLogs.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
markEventRead: vi.fn(),
markAllEventsRead: vi.fn(),
clearEvents: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('ProgramLogs status label', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('treats summary with failed=0 as success', async () => {
apiMock.getEvents.mockResolvedValue([
{
id: 1,
type: 'status',
title: '同步全部账号令牌已完成(成功31/跳过0/失败0)',
message: '全部账号令牌同步完成:成功 31,跳过 0,失败 0',
level: 'info',
read: false,
createdAt: '2026-03-04T06:43:03.000Z',
},
]);
let root!: WebTestRenderer;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/events']}>
<ToastProvider>
<ProgramLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rows = root!.root.findAll((node) => node.type === 'tr');
const targetRow = rows.find((row) => collectText(row).includes('同步全部账号令牌已完成'));
expect(targetRow).toBeTruthy();
const tds = targetRow!.findAll((node) => node.type === 'td');
const statusCell = tds[5];
expect(collectText(statusCell).trim()).toBe('成功');
const statusBadge = statusCell.find((node) => node.type === 'span');
expect(String(statusBadge.props.className || '')).toContain('badge-success');
});
it('treats parenthesized counts with failed=0 as success', async () => {
apiMock.getEvents.mockResolvedValue([
{
id: 2,
type: 'status',
title: '同步全部账号令牌已完成',
message: '成功(15): a, b\n跳过(1): c\n失败(0): -',
level: 'info',
read: false,
createdAt: '2026-03-04T06:43:03.000Z',
},
]);
let root!: WebTestRenderer;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/events']}>
<ToastProvider>
<ProgramLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rows = root!.root.findAll((node) => node.type === 'tr');
const targetRow = rows.find((row) => collectText(row).includes('同步全部账号令牌已完成'));
expect(targetRow).toBeTruthy();
const tds = targetRow!.findAll((node) => node.type === 'td');
const statusCell = tds[5];
expect(collectText(statusCell).trim()).toBe('成功');
const statusBadge = statusCell.find((node) => node.type === 'span');
expect(String(statusBadge.props.className || '')).toContain('badge-success');
});
});
-439
View File
@@ -1,439 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
import { useToast } from '../components/Toast.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
import { tr } from '../i18n.js';
type ProgramEvent = {
id: number;
type: string;
title: string;
message?: string | null;
level: 'info' | 'warning' | 'error';
read: boolean;
relatedId?: number | null;
relatedType?: string | null;
createdAt?: string | null;
};
const PAGE_SIZE = 50;
const TYPE_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'checkin', label: '签到' },
{ value: 'balance', label: '余额' },
{ value: 'token', label: '令牌' },
{ value: 'proxy', label: '代理' },
{ value: 'status', label: '状态' },
{ value: 'site_notice', label: '站点公告' },
];
function levelLabel(level: string) {
if (level === 'error') return { label: '错误', cls: 'badge-error' };
if (level === 'warning') return { label: '警告', cls: 'badge-warning' };
return { label: '信息', cls: 'badge-info' };
}
function eventStatusLabel(row: ProgramEvent) {
const text = `${row.title || ''} ${row.message || ''}`.toLowerCase();
const parseCount = (pattern: RegExp): number | undefined => {
const match = text.match(pattern);
if (!match?.[1]) return undefined;
const value = Number.parseInt(match[1], 10);
return Number.isFinite(value) ? value : undefined;
};
const summary = {
success: parseCount(/成功[^\d]{0,6}(\d+)/i) ?? parseCount(/success(?:ful)?[^\d]{0,6}(\d+)/i),
skipped: parseCount(/跳过[^\d]{0,6}(\d+)/i) ?? parseCount(/skipped?[^\d]{0,6}(\d+)/i),
failed: parseCount(/失败[^\d]{0,6}(\d+)/i) ?? parseCount(/failed[^\d]{0,6}(\d+)/i),
};
if (summary.failed !== undefined || summary.success !== undefined || summary.skipped !== undefined) {
if ((summary.failed ?? 0) > 0) {
return { label: '失败', cls: 'badge-error' };
}
if ((summary.success ?? 0) > 0) {
return { label: '成功', cls: 'badge-success' };
}
if ((summary.skipped ?? 0) > 0) {
return { label: '跳过', cls: 'badge-warning' };
}
return { label: '成功', cls: 'badge-success' };
}
if (text.includes('失败') || text.includes('failed') || text.includes('error')) {
return { label: '失败', cls: 'badge-error' };
}
if (text.includes('跳过') || text.includes('skipped')) {
return { label: '跳过', cls: 'badge-warning' };
}
if (text.includes('进行中') || text.includes('已开始') || text.includes('running') || text.includes('pending')) {
return { label: '进行中', cls: 'badge-info' };
}
if (text.includes('成功') || text.includes('已完成') || text.includes('completed') || text.includes('finished')) {
return { label: '成功', cls: 'badge-success' };
}
if (row.level === 'error') return { label: '异常', cls: 'badge-error' };
if (row.level === 'warning') return { label: '警告', cls: 'badge-warning' };
return { label: '信息', cls: 'badge-info' };
}
export default function ProgramLogs() {
const [events, setEvents] = useState<ProgramEvent[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [filterType, setFilterType] = useState('');
const [onlyUnread, setOnlyUnread] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [markingAll, setMarkingAll] = useState(false);
const [clearing, setClearing] = useState(false);
const [rowLoading, setRowLoading] = useState<Record<number, boolean>>({});
const [showFilters, setShowFilters] = useState(false);
const isMobile = useIsMobile();
const toast = useToast();
const load = async (silent = false, append = false) => {
if (append) setLoadingMore(true);
else if (silent) setRefreshing(true);
else setLoading(true);
try {
const nextOffset = append ? offset : 0;
const params = new URLSearchParams();
params.set('limit', String(PAGE_SIZE));
params.set('offset', String(nextOffset));
if (filterType) params.set('type', filterType);
if (onlyUnread) params.set('read', 'false');
const rows = await api.getEvents(params.toString());
const safeRows = Array.isArray(rows) ? rows : [];
setEvents((prev) => (append ? [...prev, ...safeRows] : safeRows));
const loaded = append ? nextOffset + safeRows.length : safeRows.length;
setOffset(loaded);
setHasMore(safeRows.length >= PAGE_SIZE);
} catch (e: any) {
toast.error(e.message || '加载程序日志失败');
} finally {
setLoading(false);
setLoadingMore(false);
setRefreshing(false);
}
};
useEffect(() => {
load();
}, [filterType, onlyUnread]);
const visibleRows = useMemo(() => events, [events]);
const withRowLoading = async (id: number, fn: () => Promise<void>) => {
setRowLoading((prev) => ({ ...prev, [id]: true }));
try {
await fn();
} finally {
setRowLoading((prev) => ({ ...prev, [id]: false }));
}
};
const markOneRead = async (id: number) => {
await withRowLoading(id, async () => {
await api.markEventRead(id);
setEvents((prev) => {
if (onlyUnread) return prev.filter((item) => item.id !== id);
return prev.map((item) => (item.id === id ? { ...item, read: true } : item));
});
});
};
const markAllRead = async () => {
setMarkingAll(true);
try {
await api.markAllEventsRead();
if (onlyUnread) setEvents([]);
else setEvents((prev) => prev.map((item) => ({ ...item, read: true })));
toast.success('已标记全部为已读');
} catch (e: any) {
toast.error(e.message || '标记失败');
} finally {
setMarkingAll(false);
}
};
const clearAll = async () => {
setClearing(true);
try {
await api.clearEvents();
setEvents([]);
setOffset(0);
setHasMore(false);
toast.success('日志已清空');
} catch (e: any) {
toast.error(e.message || '清空失败');
} finally {
setClearing(false);
}
};
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={clearAll}
disabled={clearing}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空日志'}
</button>
</div>
</div>
<ResponsiveFilterPanel
isMobile={isMobile}
mobileOpen={showFilters}
onMobileOpen={() => setShowFilters(true)}
onMobileClose={() => setShowFilters(false)}
mobileTitle="筛选程序日志"
mobileContent={(
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
仅看未读
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
共 {visibleRows.length} 条
</div>
</div>
)}
desktopContent={(
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ minWidth: 170 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
仅看未读
</label>
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
共 {visibleRows.length} 条
</div>
</div>
)}
/>
<div className="card" style={{ overflowX: 'auto' }}>
{loading ? (
<div style={{ padding: 20 }}>
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
<div className="skeleton" style={{ width: '100%', height: 34 }} />
</div>
) : isMobile ? (
<div className="mobile-card-list">
{visibleRows.length > 0 ? visibleRows.map((row) => {
const level = levelLabel(row.level || 'info');
const eventStatus = eventStatusLabel(row);
return (
<MobileCard
key={row.id}
title={row.title || '-'}
headerActions={(
<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 10 }}>
{eventStatus.label}
</span>
)}
footerActions={(
row.read ? (
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
) : (
<button
type="button"
onClick={() => markOneRead(row.id)}
disabled={!!rowLoading[row.id]}
className="btn btn-link btn-link-primary"
>
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
</button>
)
)}
>
<MobileField label="时间" value={formatDateTimeLocal(row.createdAt)} />
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{row.type || '-'}</span>} />
<MobileField label="级别" value={<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.label}</span>} />
<MobileField label="内容" value={row.message || '-'} stacked />
</MobileCard>
);
}) : (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="empty-state-title">暂无日志</div>
<div className="empty-state-desc">当前筛选条件下没有程序日志。</div>
</div>
)}
</div>
) : visibleRows.length > 0 ? (
<table className="data-table program-logs-table">
<colgroup>
<col style={{ width: 170 }} />
<col style={{ width: 90 }} />
<col style={{ width: 90 }} />
<col style={{ width: 260 }} />
<col />
<col style={{ width: 110 }} />
<col style={{ width: 140 }} />
</colgroup>
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>级别</th>
<th>标题</th>
<th>内容</th>
<th>状态</th>
<th style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{visibleRows.map((row, idx) => {
const level = levelLabel(row.level || 'info');
const eventStatus = eventStatusLabel(row);
return (
<tr key={row.id} className={`animate-slide-up stagger-${Math.min(idx + 1, 5)}`}>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{formatDateTimeLocal(row.createdAt)}
</td>
<td>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{row.type || '-'}
</span>
</td>
<td>
<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>
{level.label}
</span>
</td>
<td className="program-logs-title-cell">
{row.title || '-'}
</td>
<td className="program-logs-content-cell">
{row.message || '-'}
</td>
<td>
<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>
{eventStatus.label}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
{row.read ? (
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
) : (
<span className="badge badge-warning" style={{ fontSize: 11 }}>未读</span>
)}
{!row.read && (
<button
onClick={() => markOneRead(row.id)}
disabled={!!rowLoading[row.id]}
className="btn btn-link btn-link-primary"
>
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="empty-state-title">暂无日志</div>
<div className="empty-state-desc">当前筛选条件下没有程序日志。</div>
</div>
)}
</div>
{!loading && visibleRows.length > 0 && hasMore && (
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={() => load(false, true)}
disabled={loadingMore}
style={{ border: '1px solid var(--color-border)', padding: '8px 16px' }}
>
{loadingMore ? <><span className="spinner spinner-sm" /> 加载中...</> : '加载更多'}
</button>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-240
View File
@@ -1,240 +0,0 @@
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<ReturnType<typeof setTimeout> | 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>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-368
View File
@@ -1,368 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { userApi } from '../api.js';
import { clearUserSession, getUserToken } from '../authSession.js';
interface UserProfile {
id: number;
username: string;
email: string;
role: string;
status: string;
createdAt: string;
}
interface ApiKey {
id: number;
name: string;
key?: string;
description?: string;
enabled: boolean;
createdAt?: string;
}
export default function UserDashboard() {
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Profile edit
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState('');
const [saveMsg, setSaveMsg] = useState('');
// Password change
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwMsg, setPwMsg] = useState('');
const [pwLoading, setPwLoading] = useState(false);
// New API key
const [showNewKey, setShowNewKey] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [keyLoading, setKeyLoading] = useState(false);
const checkAuth = useCallback(() => {
if (!getUserToken(localStorage)) {
navigate('/user/login');
return false;
}
return true;
}, [navigate]);
const loadData = useCallback(async () => {
if (!checkAuth()) return;
try {
const [profileRes, keysRes] = await Promise.all([
userApi.me(),
userApi.getApiKeys(),
]);
setProfile(profileRes.user);
setKeys(Array.isArray(keysRes) ? keysRes : (keysRes.keys || []));
} catch (err: any) {
setError(err?.message || '加载失败');
clearUserSession(localStorage);
navigate('/user/login');
} finally {
setLoading(false);
}
}, [checkAuth, navigate]);
useEffect(() => { loadData(); }, [loadData]);
const handleSaveName = async () => {
if (!newName.trim() || !profile) return;
try {
await userApi.updateMe({ username: newName.trim() });
setProfile({ ...profile, username: newName.trim() });
setEditingName(false);
setSaveMsg('已保存');
setTimeout(() => setSaveMsg(''), 2000);
} catch (err: any) {
setSaveMsg(err?.message || '保存失败');
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPw !== confirmPw) { setPwMsg('两次密码不一致'); return; }
if (newPw.length < 6) { setPwMsg('密码至少 6 个字符'); return; }
setPwLoading(true);
setPwMsg('');
try {
await userApi.changePassword({ oldPassword: oldPw, newPassword: newPw });
setPwMsg('密码已更新');
setOldPw(''); setNewPw(''); setConfirmPw('');
} catch (err: any) {
setPwMsg(err?.message || '修改失败');
} finally {
setPwLoading(false);
}
};
const handleCreateKey = async () => {
if (!newKeyName.trim()) return;
setKeyLoading(true);
try {
const result = await userApi.createApiKey({ name: newKeyName.trim() });
setCreatedKey(result.key);
setKeys((prev) => [...prev, result.key]);
setNewKeyName('');
} catch (err: any) {
setSaveMsg(err?.message || '创建失败');
} finally {
setKeyLoading(false);
}
};
const handleDeleteKey = async (id: number) => {
try {
await userApi.deleteApiKey(id);
setKeys((prev) => prev.filter((k) => k.id !== id));
} catch (err: any) {
setError(err?.message || '删除失败');
}
};
const handleLogout = () => {
clearUserSession(localStorage);
navigate('/user/login');
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: 'var(--color-bg, #0f0f1a)' }}>
<div className="spinner" />
</div>
);
}
if (!profile) {
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<div className="login-auth-stage">
<div className="login-auth-panel" style={{ textAlign: 'center', padding: '40px 24px' }}>
<p style={{ color: 'var(--color-text-muted)', marginBottom: 16 }}>{error || '未登录'}</p>
<button onClick={() => navigate('/user/login')} className="btn btn-primary">去登录</button>
</div>
</div>
</div>
</div>
);
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
boxSizing: 'border-box',
};
return (
<div style={{
minHeight: '100vh',
background: 'var(--color-bg, #0f0f1a)',
color: 'var(--color-text, #e0e0f0)',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}>
{/* Header */}
<header style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 24px', borderBottom: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface, #1a1a2e)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} />
<span style={{ fontSize: 16, fontWeight: 600 }}>BoosAPI</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>用户中心</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>{profile.email}</span>
<button onClick={handleLogout} className="btn btn-ghost" style={{ fontSize: 13 }}>退出</button>
</div>
</header>
<div style={{ maxWidth: 720, margin: '0 auto', padding: '24px 16px', display: 'flex', flexDirection: 'column', gap: 24 }}>
{error && (
<div className="alert alert-error">{error}</div>
)}
{/* Profile Section */}
<section style={{
background: 'var(--color-surface, #1a1a2e)',
borderRadius: 12, padding: 20,
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<h2 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 16px 0' }}>个人信息</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>用户名</div>
{editingName ? (
<div style={{ display: 'flex', gap: 8 }}>
<input value={newName} onChange={(e) => setNewName(e.target.value)} style={inputStyle} autoFocus />
<button onClick={handleSaveName} className="btn btn-primary" style={{ whiteSpace: 'nowrap' }}>保存</button>
<button onClick={() => setEditingName(false)} className="btn btn-ghost">取消</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14 }}>{profile.username}</span>
<button onClick={() => { setNewName(profile.username); setEditingName(true); }} className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }}>编辑</button>
</div>
)}
{saveMsg && <div style={{ fontSize: 12, color: 'var(--color-primary)', marginTop: 4 }}>{saveMsg}</div>}
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>邮箱</div>
<span style={{ fontSize: 14 }}>{profile.email}</span>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>角色</div>
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: profile.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
color: profile.role === 'admin' ? '#818cf8' : '#4ade80',
}}>
{profile.role === 'admin' ? '管理员' : '普通用户'}
</span>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>状态</div>
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: profile.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: profile.status === 'active' ? '#4ade80' : '#f87171',
}}>
{profile.status === 'active' ? '正常' : '已禁用'}
</span>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>注册时间</div>
<span style={{ fontSize: 14 }}>{profile.createdAt}</span>
</div>
</div>
</section>
{/* Password Change */}
<section style={{
background: 'var(--color-surface, #1a1a2e)',
borderRadius: 12, padding: 20,
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<h2 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 16px 0' }}>修改密码</h2>
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>当前密码</div>
<input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} style={inputStyle} required />
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>新密码</div>
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} style={inputStyle} required />
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>确认新密码</div>
<input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} style={inputStyle} required />
</div>
{pwMsg && <div style={{ fontSize: 12, color: pwMsg.includes('已更新') ? 'var(--color-primary)' : '#f87171' }}>{pwMsg}</div>}
<button type="submit" disabled={pwLoading || !oldPw || !newPw || !confirmPw} className="btn btn-primary" style={{ alignSelf: 'flex-start' }}>
{pwLoading ? '修改中...' : '修改密码'}
</button>
</form>
</section>
{/* API Keys */}
<section style={{
background: 'var(--color-surface, #1a1a2e)',
borderRadius: 12, padding: 20,
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>API 密钥</h2>
<button onClick={() => setShowNewKey(!showNewKey)} className="btn btn-primary" style={{ fontSize: 13 }}>
{showNewKey ? '取消' : '新建密钥'}
</button>
</div>
{showNewKey && (
<div style={{
padding: 12, marginBottom: 12, borderRadius: 8,
background: 'var(--color-bg, #0f0f1a)',
border: '1px solid var(--color-border, #2a2a3e)',
}}>
{createdKey ? (
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8 }}>密钥已创建,请立即复制(不会再次显示):</div>
<div style={{
padding: '8px 12px', borderRadius: 6, fontSize: 13, fontFamily: 'monospace',
background: 'rgba(99,102,241,0.1)', border: '1px solid rgba(99,102,241,0.3)',
wordBreak: 'break-all', marginBottom: 8,
}}>
{createdKey.key || 'sk-user-...'}
</div>
<button onClick={() => { setCreatedKey(null); setShowNewKey(false); }} className="btn btn-ghost" style={{ fontSize: 12 }}>关闭</button>
</div>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="密钥名称"
style={{ ...inputStyle, flex: 1 }}
autoFocus
/>
<button onClick={handleCreateKey} disabled={keyLoading || !newKeyName.trim()} className="btn btn-primary" style={{ whiteSpace: 'nowrap' }}>
{keyLoading ? '创建中...' : '创建'}
</button>
</div>
)}
</div>
)}
{keys.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', padding: '16px 0', textAlign: 'center' }}>
暂无 API 密钥
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{keys.map((key) => (
<div key={key.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 12px', borderRadius: 8,
background: 'var(--color-bg, #0f0f1a)',
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{key.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>
{key.key ? key.key.slice(0, 20) + '...' : ''}
{key.description && ` · ${key.description}`}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
fontSize: 11, padding: '2px 6px', borderRadius: 3,
background: key.enabled ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: key.enabled ? '#4ade80' : '#f87171',
}}>
{key.enabled ? '启用' : '禁用'}
</span>
<button onClick={() => handleDeleteKey(key.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '2px 8px' }}>删除</button>
</div>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
-107
View File
@@ -1,107 +0,0 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js';
export default function UserLogin() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim() || !password) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `登录失败 (${res.status})`);
}
const result = await res.json();
persistUserSession(localStorage, result.token);
navigate('/user/dashboard');
} catch (err: any) {
setError(err?.message || '登录失败,请检查邮箱和密码');
} finally {
setLoading(false);
}
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">用户登录</div>
</div>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<h2 className="login-auth-title">欢迎回来</h2>
<p className="login-auth-copy">使用邮箱和密码登录你的账号。</p>
<form onSubmit={handleSubmit}>
<label className="login-auth-label" htmlFor="user-email-input">邮箱</label>
<input
id="user-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="user-password-input" style={{ marginTop: 12 }}>密码</label>
<input
id="user-password-input"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="current-password"
required
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !email.trim() || !password}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 4 }}
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 登录中...</> : '登录'}
</button>
</form>
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
<span>
还没有账号?<Link to="/user/register" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>注册</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
</div>
</section>
</div>
</div>
);
}
-204
View File
@@ -1,204 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { api } from '../api.js';
interface User {
id: number;
username: string;
email: string;
role: string;
status: string;
createdAt: string;
}
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingUser, setEditingUser] = useState<number | null>(null);
const [editRole, setEditRole] = useState('');
const [editStatus, setEditStatus] = useState('');
const loadUsers = useCallback(async () => {
try {
const result = await api.getUsers();
setUsers(Array.isArray(result.users) ? result.users : []);
} catch (err: any) {
setError(err?.message || '加载用户列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadUsers(); }, [loadUsers]);
const handleUpdateUser = async (id: number) => {
try {
await api.updateUser(id, { role: editRole, status: editStatus });
setEditingUser(null);
await loadUsers();
} catch (err: any) {
setError(err?.message || '更新失败');
}
};
const handleDisableUser = async (id: number) => {
try {
await api.deleteUser(id);
await loadUsers();
} catch (err: any) {
setError(err?.message || '操作失败');
}
};
if (loading) {
return (
<div className="animate-fade-in" style={{ padding: 16 }}>
<div className="skeleton" style={{ width: 220, height: 24, marginBottom: 16 }} />
<div className="skeleton" style={{ width: '100%', height: 120, borderRadius: 12 }} />
</div>
);
}
return (
<div className="animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h1 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>用户管理</h1>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
共 {users.length} 个用户
</span>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
{/* Stats cards */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
{[
{ label: '总用户', value: users.length, color: '#818cf8' },
{ label: '管理员', value: users.filter((u) => u.role === 'admin').length, color: '#4ade80' },
{ label: '已禁用', value: users.filter((u) => u.status === 'disabled').length, color: '#f87171' },
].map((stat) => (
<div key={stat.label} style={{
flex: 1, padding: '14px 16px', borderRadius: 10,
background: 'var(--color-surface, #1a1a2e)',
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{stat.label}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: stat.color }}>{stat.value}</div>
</div>
))}
</div>
{/* Users table */}
<div style={{
background: 'var(--color-surface, #1a1a2e)',
borderRadius: 12, border: '1px solid var(--color-border, #2a2a3e)',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>ID</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>用户名</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>邮箱</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>角色</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>状态</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>注册时间</th>
<th style={{ padding: '12px 16px', textAlign: 'right', fontWeight: 600, color: 'var(--color-text-muted)' }}>操作</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={7} style={{ padding: 24, textAlign: 'center', color: 'var(--color-text-muted)' }}>
暂无用户
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: 12 }}>{user.id}</td>
<td style={{ padding: '12px 16px' }}>{user.username}</td>
<td style={{ padding: '12px 16px', color: 'var(--color-text-secondary)' }}>{user.email}</td>
<td style={{ padding: '12px 16px' }}>
{editingUser === user.id ? (
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
style={{
padding: '4px 8px', borderRadius: 4, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
) : (
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: user.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
color: user.role === 'admin' ? '#818cf8' : '#4ade80',
}}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
)}
</td>
<td style={{ padding: '12px 16px' }}>
{editingUser === user.id ? (
<select
value={editStatus}
onChange={(e) => setEditStatus(e.target.value)}
style={{
padding: '4px 8px', borderRadius: 4, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
>
<option value="active">active</option>
<option value="disabled">disabled</option>
</select>
) : (
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: user.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: user.status === 'active' ? '#4ade80' : '#f87171',
}}>
{user.status === 'active' ? '正常' : '禁用'}
</span>
)}
</td>
<td style={{ padding: '12px 16px', fontSize: 12, color: 'var(--color-text-muted)' }}>
{user.createdAt?.slice(0, 10) || '-'}
</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
{editingUser === user.id ? (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => handleUpdateUser(user.id)} className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }}>保存</button>
<button onClick={() => setEditingUser(null)} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 12px' }}>取消</button>
</div>
) : (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button
onClick={() => { setEditingUser(user.id); setEditRole(user.role); setEditStatus(user.status); }}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 12px' }}
>
编辑
</button>
{user.status !== 'disabled' && (
<button onClick={() => handleDisableUser(user.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '4px 12px' }}>
禁用
</button>
)}
</div>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
-139
View File
@@ -1,139 +0,0 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js';
export default function UserRegister() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !email.trim() || !password) return;
if (password !== confirmPassword) {
setError('两次密码不一致');
return;
}
if (password.length < 6) {
setError('密码至少 6 个字符');
return;
}
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), email: email.trim(), password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `注册失败 (${res.status})`);
}
const result = await res.json();
persistUserSession(localStorage, result.token);
navigate('/user/dashboard');
} catch (err: any) {
setError(err?.message || '注册失败');
} finally {
setLoading(false);
}
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">用户注册</div>
</div>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<h2 className="login-auth-title">创建账号</h2>
<p className="login-auth-copy">注册后即可使用 API 代理服务。</p>
<form onSubmit={handleSubmit}>
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
<input
id="reg-username-input"
type="text"
placeholder="你的昵称"
value={username}
onChange={(e) => { setUsername(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="username"
required
/>
<label className="login-auth-label" htmlFor="reg-email-input" style={{ marginTop: 12 }}>邮箱</label>
<input
id="reg-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="reg-password-input" style={{ marginTop: 12 }}>密码</label>
<input
id="reg-password-input"
type="password"
placeholder="至少 6 位"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
<label className="login-auth-label" htmlFor="reg-confirm-password-input" style={{ marginTop: 12 }}>确认密码</label>
<input
id="reg-confirm-password-input"
type="password"
placeholder="再次输入密码"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 4 }}
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 注册中...</> : '注册'}
</button>
</form>
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
<span>
已有账号?<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>登录</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
</div>
</section>
</div>
</div>
);
}
@@ -1,178 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
batchUpdateAccounts: vi.fn(),
refreshAccountHealth: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Accounts batch actions', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
apiMock.getSites.mockResolvedValue([
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
{
id: 2,
siteId: 1,
username: 'beta',
accessToken: 'session-beta',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
]);
apiMock.batchUpdateAccounts.mockResolvedValue({
success: true,
successIds: [1, 2],
failedItems: [],
});
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('refreshes balance for selected accounts through the batch toolbar', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const checkboxA = root.root.find((node) => node.props['data-testid'] === 'account-select-1');
const checkboxB = root.root.find((node) => node.props['data-testid'] === 'account-select-2');
await act(async () => {
checkboxA.props.onChange({ target: { checked: true } });
checkboxB.props.onChange({ target: { checked: true } });
});
const batchButton = root.root.find((node) => node.props['data-testid'] === 'accounts-batch-refresh-balance');
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateAccounts).toHaveBeenCalledWith({
ids: [1, 2],
action: 'refreshBalance',
});
} finally {
root?.unmount();
}
});
it('selects an account when clicking the row instead of only the checkbox', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const row = root.root.find((node) => node.props['data-testid'] === 'account-row-1');
await act(async () => {
row.props.onClick({ target: { closest: () => null } });
});
await flushMicrotasks();
const checkbox = root.root.find((node) => node.props['data-testid'] === 'account-select-1');
expect(checkbox.props.checked).toBe(true);
} finally {
root?.unmount();
}
});
it('selects an apikey connection when clicking the row in the apikey segment', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
siteId: 1,
username: 'session-user',
accessToken: 'session-alpha',
apiToken: 'sk-session',
credentialMode: 'session',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
{
id: 2,
siteId: 1,
username: '',
accessToken: '',
apiToken: 'sk-apikey',
credentialMode: 'apikey',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=apikey']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const row = root.root.find((node) => node.props['data-testid'] === 'account-row-2');
await act(async () => {
row.props.onClick({ target: { closest: () => null } });
});
await flushMicrotasks();
const checkbox = root.root.find((node) => node.props['data-testid'] === 'account-select-2');
expect(checkbox.props.checked).toBe(true);
} finally {
root?.unmount();
}
});
});
@@ -1,15 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Accounts centered modal adoption', () => {
it('uses CenteredModal for add/edit/rebind flows instead of inline panel cards', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Accounts.tsx'), 'utf8');
expect(source).toContain(
'import CenteredModal from "../components/CenteredModal.js";',
);
expect(source).toContain('<CenteredModal');
expect(source).not.toContain('className={`card panel-presence rebind-panel');
});
});
@@ -1,182 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
addAccount: vi.fn(),
addAccountAvailableModels: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Accounts CodingPlan initialization', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
apiMock.getAccounts.mockResolvedValue([]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Aliyun CodingPlan', url: 'https://coding.dashscope.aliyuncs.com/v1', platform: 'openai', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
apiMock.addAccount.mockResolvedValue({
id: 18,
siteId: 10,
tokenType: 'apikey',
queued: false,
});
apiMock.addAccountAvailableModels.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('preloads CodingPlan guidance, defaults skip-model-fetch, and seeds recommended models after add', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=apikey&create=1&siteId=10&initPreset=codingplan-openai']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('添加 API Key 连接');
expect(rendered).toContain('阿里云 CodingPlan / OpenAI');
expect(rendered).toContain('qwen3-coder-plus');
const checkedBoxes = root.root.findAll((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& node.props.checked === true
));
expect(checkedBoxes.length).toBeGreaterThanOrEqual(2);
const tokenInput = root.root.find((node) => (
node.type === 'textarea'
&& node.props.placeholder === '粘贴 API Key'
));
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('添加连接')
));
await act(async () => {
tokenInput.props.onChange({ target: { value: 'sk-codingplan-demo' } });
});
await act(async () => {
await addButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.addAccount).toHaveBeenCalledWith(expect.objectContaining({
siteId: 10,
accessToken: 'sk-codingplan-demo',
credentialMode: 'apikey',
skipModelFetch: true,
}));
expect(apiMock.addAccountAvailableModels).toHaveBeenCalledWith(
18,
expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5']),
);
} finally {
root?.unmount();
}
});
it('preloads vendor-specific recommendations for new code presets such as DeepSeek', async () => {
apiMock.getSites.mockResolvedValue([
{ id: 11, name: 'DeepSeek Official', url: 'https://api.deepseek.com/v1', platform: 'openai', status: 'active' },
]);
apiMock.addAccount.mockResolvedValue({
id: 19,
siteId: 11,
tokenType: 'apikey',
queued: false,
});
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=apikey&create=1&siteId=11&initPreset=deepseek-openai']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('DeepSeek / OpenAI');
expect(rendered).toContain('deepseek-chat');
expect(rendered).toContain('deepseek-reasoner');
const tokenInput = root.root.find((node) => (
node.type === 'textarea'
&& node.props.placeholder === '粘贴 API Key'
));
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('添加连接')
));
await act(async () => {
tokenInput.props.onChange({ target: { value: 'sk-deepseek-demo' } });
});
await act(async () => {
await addButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.addAccount).toHaveBeenCalledWith(expect.objectContaining({
siteId: 11,
accessToken: 'sk-deepseek-demo',
credentialMode: 'apikey',
skipModelFetch: true,
}));
expect(apiMock.addAccountAvailableModels).toHaveBeenCalledWith(
19,
['deepseek-chat', 'deepseek-reasoner'],
);
} finally {
root?.unmount();
}
});
});
@@ -1,135 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
async function renderAccounts(
initialEntry: string,
sites: Array<{ id: number; name: string; url?: string; platform: string; status: string }> = [
{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' },
],
) {
apiMock.getAccounts.mockResolvedValue([]);
apiMock.getSites.mockResolvedValue(sites);
apiMock.getAccountTokens.mockResolvedValue([]);
let root!: WebTestRenderer;
await act(async () => {
root = create(
<MemoryRouter initialEntries={[initialEntry]}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
return root!;
}
describe('Accounts create intent handling', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
});
afterEach(() => {
vi.clearAllMocks();
});
it('opens the session add modal and preselects the site for session create intent', async () => {
const root = await renderAccounts('/accounts?create=1&siteId=10');
try {
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('添加 Session 连接');
expect(rendered).not.toContain('添加 API Key 连接');
const selects = root.root.findAllByType(ModernSelect);
expect(selects[1]?.props.value).toBe('10');
} finally {
root?.unmount();
}
});
it('opens the apikey add modal and preselects the site for apikey create intent', async () => {
const root = await renderAccounts('/accounts?segment=apikey&create=1&siteId=10');
try {
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('添加 API Key 连接');
const selects = root.root.findAllByType(ModernSelect);
expect(selects[1]?.props.value).toBe('10');
} finally {
root?.unmount();
}
});
it('uses searchable site selectors for manual connection creation', async () => {
const root = await renderAccounts('/accounts', [
{ id: 10, name: 'Demo Site', url: 'https://demo.example.com', platform: 'new-api', status: 'active' },
{ id: 11, name: 'Codex Workspace', url: 'https://workspace.example.com', platform: 'codex', status: 'active' },
]);
try {
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-primary')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
const selects = root.root.findAllByType(ModernSelect);
expect(selects[1]?.props.searchable).toBe(true);
expect(selects[1]?.props.searchPlaceholder).toBe('筛选站点(名称 / 平台 / URL)');
expect(selects[1]?.props.options).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: '11',
label: 'Codex Workspace (codex)',
description: 'https://workspace.example.com',
}),
]),
);
} finally {
root?.unmount();
}
});
it('ignores create intent in the tokens segment', async () => {
const root = await renderAccounts('/accounts?segment=tokens&create=1&siteId=10');
try {
const rendered = JSON.stringify(root.toJSON());
expect(rendered).not.toContain('添加 Session 连接');
expect(rendered).not.toContain('添加 API Key 连接');
} finally {
root?.unmount();
}
});
});
-322
View File
@@ -1,322 +0,0 @@
import type { ReactNode } from 'react';
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 Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
updateAccount: vi.fn(),
updateSiteDisabledModels: vi.fn(),
rebuildRoutes: vi.fn(),
refreshAccountHealth: vi.fn(),
checkModels: vi.fn(),
getAccountModels: vi.fn(),
},
toastMock: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
toast: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: ReactNode }) => children,
useToast: () => toastMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('Accounts edit panel', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
apiMock.getSites.mockResolvedValue([
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
]);
apiMock.updateAccount.mockResolvedValue({ success: true });
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
apiMock.getAccountModels.mockResolvedValue({
siteId: 1,
siteName: 'Site A',
models: [
{ name: 'gpt-4', latencyMs: 120, disabled: false },
{ name: 'gpt-3.5', latencyMs: 80, disabled: false },
],
totalCount: 2,
disabledCount: 0,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('opens edit panel from account row action', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const editButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '编辑'
));
await act(async () => {
editButton.props.onClick();
});
await flushMicrotasks();
const usernameInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '账号名称'
));
expect(usernameInput.props.value).toBe('alpha');
} finally {
root?.unmount();
}
});
it('opens model modal when clicking model button', async () => {
apiMock.getAccountModels.mockResolvedValue({
siteId: 1,
siteName: 'Site A',
models: [
{ name: 'gpt-4', latencyMs: 120, disabled: false },
{ name: 'gpt-3.5', latencyMs: 80, disabled: false },
],
totalCount: 2,
disabledCount: 0,
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const modelButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn-link-info')
&& collectText(node).trim() === '模型'
));
expect(modelButtons.length).toBeGreaterThan(0);
await act(async () => {
await modelButtons[0]!.props.onClick();
});
await flushMicrotasks();
// Should call getAccountModels to open the model management modal
expect(apiMock.getAccountModels).toHaveBeenCalledWith(1);
} finally {
root?.unmount();
}
});
it('ignores stale model modal loads after switching accounts', async () => {
const firstLoad = deferred<any>();
const secondLoad = deferred<any>();
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
{
id: 2,
siteId: 2,
username: 'beta',
accessToken: 'session-beta',
status: 'active',
site: { id: 2, name: 'Site B', status: 'active', platform: 'new-api' },
},
]);
apiMock.getSites.mockResolvedValue([
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
{ id: 2, name: 'Site B', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountModels.mockImplementation((accountId: number) => {
if (accountId === 1) return firstLoad.promise;
if (accountId === 2) return secondLoad.promise;
return Promise.resolve({ siteId: accountId, siteName: 'Unknown', models: [] });
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const modelButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn-link-info')
&& collectText(node).trim() === '模型'
));
await act(async () => {
void modelButtons[0]!.props.onClick();
});
await flushMicrotasks();
await act(async () => {
void modelButtons[1]!.props.onClick();
});
await flushMicrotasks();
secondLoad.resolve({
siteId: 2,
siteName: 'Site B',
models: [{ name: 'model-b', latencyMs: 66, disabled: false }],
});
await flushMicrotasks();
firstLoad.resolve({
siteId: 1,
siteName: 'Site A',
models: [{ name: 'model-a', latencyMs: 33, disabled: false }],
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('模型管理 · Site B');
expect(rendered).toContain('model-b');
expect(rendered).not.toContain('model-a');
} finally {
root?.unmount();
}
});
it('reports route rebuild failure without claiming success', async () => {
apiMock.getAccountModels.mockResolvedValue({
siteId: 1,
siteName: 'Site A',
models: [{ name: 'gpt-4', latencyMs: 120, disabled: false }],
totalCount: 1,
disabledCount: 0,
});
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const modelButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn-link-info')
&& collectText(node).trim() === '模型'
));
await act(async () => {
await modelButton.props.onClick();
});
await flushMicrotasks();
const saveButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存'
));
await act(async () => {
await saveButtons[saveButtons.length - 1]!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateSiteDisabledModels).toHaveBeenCalledWith(1, []);
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
expect(toastMock.error).toHaveBeenCalledWith('模型禁用设置已保存,但路由重建失败,请手动刷新路由');
expect(toastMock.success).not.toHaveBeenCalledWith('模型禁用设置已保存,路由已重建');
} finally {
root?.unmount();
}
});
});
@@ -1,198 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
batchUpdateAccounts: vi.fn(),
refreshAccountHealth: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function collectText(node: any): string {
return (node.children || []).map((child: any) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findButtonByText(root: any, text: string) {
return root.find((node: any) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes(text)
));
}
describe('Accounts mobile actions', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
apiMock.getSites.mockResolvedValue([
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
{
id: 2,
siteId: 1,
username: 'beta',
accessToken: 'session-beta',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
]);
apiMock.batchUpdateAccounts.mockResolvedValue({
success: true,
successIds: [1, 2],
failedItems: [],
});
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('supports select-all-visible from the mobile toolbar', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'accounts-mobile-select-all');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
const batchButton = root.root.find((node) => node.props['data-testid'] === 'accounts-batch-refresh-balance');
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateAccounts).toHaveBeenCalledWith({
ids: [1, 2],
action: 'refreshBalance',
});
} finally {
root?.unmount();
}
});
it('clears only the visible segment selection when toggling mobile select-all off', async () => {
let root!: WebTestRenderer;
try {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
{
id: 2,
siteId: 1,
username: 'beta',
accessToken: '',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
]);
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const sessionCheckbox = root.root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& node.props['aria-label'] === '选择账号 alpha'
));
await act(async () => {
sessionCheckbox.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const apiKeySegmentButton = findButtonByText(root.root, 'API Key管理');
await act(async () => {
apiKeySegmentButton.props.onClick();
});
await flushMicrotasks();
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'accounts-mobile-select-all');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('已选 2 项');
const clearVisibleButton = root.root.find((node) => node.props['data-testid'] === 'accounts-mobile-select-all');
await act(async () => {
clearVisibleButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('已选 1 项');
const batchButton = root.root.find((node) => node.props['data-testid'] === 'accounts-batch-refresh-balance');
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateAccounts).toHaveBeenLastCalledWith({
ids: [1],
action: 'refreshBalance',
});
} finally {
root?.unmount();
}
});
});
-10
View File
@@ -1,10 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Accounts mobile layout', () => {
it('includes hints field in mobile expanded section', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Accounts.tsx'), 'utf8');
expect(source).toContain('label="提示"');
});
});
@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Accounts model modal extraction', () => {
it('delegates the account model management modal to a dedicated accounts component', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Accounts.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain(
'import AccountModelsModal from "./accounts/AccountModelsModal.js";',
);
expect(source).not.toContain('open={modelModal.open}');
});
});
@@ -1,105 +0,0 @@
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 Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Accounts proxy-only expired state', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
});
afterEach(() => {
vi.clearAllMocks();
});
it('does not show session-expired health copy or rebind action for proxy-only accounts', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: '',
balance: 0,
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
accessToken: 'api-key-only-token',
status: 'expired',
checkinEnabled: false,
runtimeHealth: {
state: 'unhealthy',
reason: '连接已过期,请更新 API Key',
},
capabilities: {
canCheckin: false,
canRefreshBalance: false,
proxyOnly: true,
},
siteId: 10,
site: { id: 10, name: '小呆api', status: 'active', url: 'https://example.com' },
},
]);
apiMock.getSites.mockResolvedValue([{ id: 10, name: '小呆api', platform: 'new-api', status: 'active' }]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=apikey']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).not.toContain('仅代理');
expect(rendered).toContain('已过期');
expect(rendered).toContain('连接已过期,请更新 API Key');
expect(rendered).not.toContain('访问令牌已过期');
const badgeTexts = root.root.findAll((node) => (
node.type === 'span'
&& typeof node.props.className === 'string'
&& node.props.className.includes('badge')
)).map((node) => collectText(node).trim());
expect(badgeTexts).toContain('已过期');
expect(badgeTexts).not.toContain('健康');
const actionTexts = root.root.findAll((node) => node.type === 'button').map((node) => collectText(node));
expect(actionTexts).not.toContain('重新绑定');
} finally {
root?.unmount();
}
});
});
@@ -1,100 +0,0 @@
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 Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Accounts rebind modal', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
});
afterEach(() => {
vi.clearAllMocks();
});
it('opens centered rebind modal when clicking rebind action', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'tester',
balance: 100,
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
accessToken: 'session-token',
status: 'expired',
checkinEnabled: true,
siteId: 10,
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
runtimeHealth: { state: 'unhealthy', reason: '访问令牌失效' },
},
]);
apiMock.getSites.mockResolvedValue([{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' }]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rebindButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '重新绑定'
));
await act(async () => {
rebindButton.props.onClick();
});
await flushMicrotasks();
const modal = root.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modal-content')
));
expect(String(modal.props.className)).toContain('modal-content');
expect(JSON.stringify(root.toJSON())).toContain('重新绑定 Session Token');
expect(JSON.stringify(root.toJSON())).toContain('粘贴新的 Session Token');
} finally {
root?.unmount();
}
});
});
@@ -1,116 +0,0 @@
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 Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
refreshBalance: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Accounts refresh action', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
});
afterEach(() => {
vi.clearAllMocks();
});
it('reloads account list even when refresh balance fails', async () => {
apiMock.getAccounts
.mockResolvedValueOnce([
{
id: 1,
username: 'tester',
balance: 100,
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
accessToken: 'session-token',
status: 'active',
checkinEnabled: true,
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
runtimeHealth: { state: 'healthy', reason: 'ok' },
},
])
.mockResolvedValueOnce([
{
id: 1,
username: 'tester',
balance: 100,
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
accessToken: 'session-token',
status: 'expired',
checkinEnabled: true,
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
runtimeHealth: { state: 'unhealthy', reason: '访问令牌失效' },
},
]);
apiMock.getSites.mockResolvedValue([{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' }]);
apiMock.refreshBalance.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const refreshButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn-link-primary')
&& collectText(node).trim() === '刷新'
));
expect(refreshButtons.length).toBeGreaterThan(0);
const refreshButton = refreshButtons[0]!;
await act(async () => {
await refreshButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getAccounts).toHaveBeenCalledTimes(2);
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('已过期');
} finally {
root?.unmount();
}
});
});
@@ -1,143 +0,0 @@
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 Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function collectText(node: ReactTestInstance): string {
return (node.children || [])
.map((child) => (typeof child === 'string' ? child : collectText(child)))
.join('');
}
describe('Accounts segmented connections view', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
});
afterEach(() => {
vi.clearAllMocks();
});
it('shows only apikey connections in the apikey segment and labels unnamed ones as API Key 连接', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'session-user',
accessToken: 'session-token',
apiToken: 'sk-session',
status: 'active',
credentialMode: 'session',
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
},
{
id: 2,
username: '',
accessToken: '',
apiToken: 'sk-apikey',
status: 'active',
credentialMode: 'apikey',
capabilities: { canCheckin: false, canRefreshBalance: false, proxyOnly: true },
site: { id: 11, name: 'Key Site', platform: 'new-api', status: 'active', url: 'https://key.example.com' },
},
]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
{ id: 11, name: 'Key Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=apikey']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('连接管理');
expect(rendered).toContain('账号管理');
expect(rendered).toContain('API Key管理');
expect(rendered).toContain('账号令牌管理');
expect(rendered).toContain('用于签到、余额、状态维护');
expect(rendered).toContain('只有 Base URL + Key 时使用,只负责代理调用');
expect(rendered).toContain('从账号同步或手动维护,供路由实际调用');
expect(rendered).toContain('Key Site');
expect(rendered).not.toContain('仅代理');
expect(rendered).not.toContain('session-user');
const segmentButtons = root.root.findAll((node) => {
if (node.type !== 'button') return false;
const text = collectText(node);
return text === '账号管理' || text === 'API Key管理' || text === '账号令牌管理';
});
expect(segmentButtons).toHaveLength(3);
expect(segmentButtons[0]?.props['data-tooltip-side']).toBe('bottom');
expect(segmentButtons[0]?.props['data-tooltip-align']).toBe('start');
expect(segmentButtons[1]?.props['data-tooltip-side']).toBe('bottom');
expect(segmentButtons[1]?.props['data-tooltip-align']).toBe('center');
expect(segmentButtons[2]?.props['data-tooltip-side']).toBe('bottom');
expect(segmentButtons[2]?.props['data-tooltip-align']).toBe('end');
} finally {
root?.unmount();
}
});
it('uses existing-site guidance instead of asking to add a site when the segment is empty but sites exist', async () => {
apiMock.getAccounts.mockResolvedValue([]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('暂无 Session 连接');
expect(rendered).toContain('请为现有站点添加 Session 连接');
expect(rendered).not.toContain('请先添加站点');
} finally {
root?.unmount();
}
});
});
@@ -1,94 +0,0 @@
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 Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children
.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
})
.join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Accounts tokens embedded header', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
});
afterEach(() => {
vi.clearAllMocks();
});
it('reuses the accounts page header when showing the 账号令牌 segment', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'session-user',
accessToken: 'session-token',
status: 'active',
credentialMode: 'session',
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
},
]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const pageHeaders = root.root.findAll((node) => {
const className = node.props?.className;
return typeof className === 'string' && className.split(/\s+/).includes('page-header');
});
expect(pageHeaders).toHaveLength(1);
const buttonTexts = root.root
.findAll((node) => node.type === 'button')
.map((node) => collectText(node));
expect(buttonTexts).toContain('同步站点令牌');
expect(buttonTexts).toContain('同步全部账号');
expect(buttonTexts).toContain('+ 新增令牌');
} finally {
root?.unmount();
}
});
});
@@ -1,120 +0,0 @@
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 Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
import {
buildAddAccountPrereqHint,
buildVerifyFailureHint,
normalizeVerifyFailureMessage,
} from './helpers/accountVerifyFeedback.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
verifyToken: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Accounts verify feedback', () => {
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
});
afterEach(() => {
vi.clearAllMocks();
});
it('shows connectivity guidance instead of blaming the token when verification request fails', async () => {
apiMock.getAccounts.mockResolvedValue([]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' },
]);
apiMock.verifyToken.mockRejectedValueOnce(new Error('Failed to fetch'));
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-primary')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
const selects = root.root.findAllByType(ModernSelect);
expect(selects.length).toBeGreaterThan(1);
await act(async () => {
selects[1]!.props.onChange('10');
});
const textareas = root.root.findAll((node) => node.type === 'textarea');
expect(textareas.length).toBeGreaterThan(0);
await act(async () => {
textareas[0]!.props.onChange({ target: { value: 'demo-token' } });
});
const verifyButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-ghost')
&& collectText(node).includes('Token')
));
await act(async () => {
await verifyButton.props.onClick();
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain(normalizeVerifyFailureMessage('Failed to fetch'));
expect(rendered).toContain(buildVerifyFailureHint({ success: false, message: 'Failed to fetch' })!);
expect(rendered).toContain(buildAddAccountPrereqHint({ success: false, message: 'Failed to fetch' }));
expect(rendered).not.toContain('请检查 Token 是否正确');
} finally {
root?.unmount();
}
});
});
@@ -1,231 +0,0 @@
import React from 'react';
import CenteredModal from '../../components/CenteredModal.js';
type AccountModelRow = {
name: string;
latencyMs: number | null;
disabled: boolean;
isManual?: boolean;
};
type AccountModelModalState = {
open: boolean;
account: any | null;
models: AccountModelRow[];
pendingDisabled: Set<string>;
loading: boolean;
saving: boolean;
siteName: string;
manualModelsInput: string;
addingManualModels: boolean;
};
type AccountModelsModalProps = {
modelModal: AccountModelModalState;
inputStyle: React.CSSProperties;
onClose: () => void;
onSave: () => void;
onRefresh: () => Promise<void> | void;
onToggleModelDisabled: (modelName: string) => void;
onSetPendingDisabled: (pendingDisabled: Set<string>) => void;
onManualInputChange: (value: string) => void;
onAddManualModels: () => Promise<void> | void;
};
export default function AccountModelsModal({
modelModal,
inputStyle,
onClose,
onSave,
onRefresh,
onToggleModelDisabled,
onSetPendingDisabled,
onManualInputChange,
onAddManualModels,
}: AccountModelsModalProps) {
return (
<CenteredModal
open={modelModal.open}
onClose={onClose}
title={modelModal.siteName ? `模型管理 · ${modelModal.siteName}` : '模型管理'}
maxWidth={600}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button
onClick={onSave}
disabled={modelModal.saving || modelModal.loading}
className="btn btn-primary"
>
{modelModal.saving ? <><span className="spinner spinner-sm" />保存中...</> : '保存'}
</button>
</>
)}
>
{modelModal.loading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 0', gap: 10 }}>
<span className="spinner" />
<span style={{ color: 'var(--color-text-muted)', fontSize: 14 }}>加载模型列表...</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{modelModal.models.length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 0' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🤖</div>
<div style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: 8 }}>暂无可用模型</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 16 }}>请先点击账号操作栏中的「刷新」或「模型」按钮获取模型</div>
<button
onClick={() => void onRefresh()}
className="btn btn-soft-primary"
>
立即获取模型
</button>
</div>
) : (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
checked={modelModal.pendingDisabled.size === 0}
ref={(el) => {
if (el) {
const total = modelModal.models.length;
const disabled = modelModal.pendingDisabled.size;
el.indeterminate = disabled > 0 && disabled < total;
}
}}
onChange={() => {
const allEnabled = modelModal.pendingDisabled.size === 0;
onSetPendingDisabled(allEnabled ? new Set(modelModal.models.map((model) => model.name)) : new Set());
}}
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15 }}
/>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
已启用 <strong style={{ color: 'var(--color-text-primary)' }}>{modelModal.models.length - modelModal.pendingDisabled.size}</strong> / {modelModal.models.length} 个模型
</span>
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => void onRefresh()}
disabled={modelModal.saving}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
刷新模型
</button>
<button
onClick={() => {
const next = new Set<string>();
for (const model of modelModal.models) {
if (!modelModal.pendingDisabled.has(model.name)) next.add(model.name);
}
onSetPendingDisabled(next);
}}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
反选
</button>
<button
onClick={() => onSetPendingDisabled(new Set(modelModal.models.map((model) => model.name)))}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
全部禁用
</button>
<button
onClick={() => onSetPendingDisabled(new Set())}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
>
全部启用
</button>
</div>
</div>
<div style={{
maxHeight: 280,
overflowY: 'auto',
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)',
}}>
{modelModal.models.map((model, idx) => {
const isDisabled = modelModal.pendingDisabled.has(model.name);
return (
<label
key={model.name}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '9px 14px',
cursor: 'pointer',
background: isDisabled ? 'var(--color-bg)' : undefined,
borderBottom: idx < modelModal.models.length - 1 ? '1px solid var(--color-border-light)' : undefined,
opacity: isDisabled ? 0.55 : 1,
transition: 'opacity 0.15s, background 0.15s',
}}
>
<input
type="checkbox"
checked={!isDisabled}
onChange={() => onToggleModelDisabled(model.name)}
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15, flexShrink: 0 }}
/>
<span style={{ flex: 1, fontSize: 13, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
{model.name}
</span>
{model.latencyMs != null ? (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{model.latencyMs}ms
</span>
) : null}
{model.isManual ? (
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0, padding: '0 4px' }}>手动</span>
) : null}
{isDisabled ? (
<span className="badge badge-error" style={{ fontSize: 10, flexShrink: 0 }}>禁用</span>
) : null}
</label>
);
})}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。
</div>
</>
)}
<div style={{ marginTop: 16, padding: '12px', background: 'var(--color-bg)', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--color-text-primary)' }}>手动添加可用模型</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8 }}>
如果您的账号支持某些未在上方列表中显示的模型,可以在此手动添加(多个以英文逗号分隔)。
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
placeholder="例如: gpt-4-custom, claude-3-5-sonnet-20241022"
value={modelModal.manualModelsInput}
onChange={(e) => onManualInputChange(e.target.value)}
style={{ ...inputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !modelModal.addingManualModels) {
void onAddManualModels();
}
}}
/>
<button
disabled={!modelModal.manualModelsInput.trim() || modelModal.addingManualModels}
onClick={() => void onAddManualModels()}
className="btn btn-primary btn-sm"
style={{ whiteSpace: 'nowrap' }}
>
{modelModal.addingManualModels ? <span className="spinner spinner-sm" /> : '添加'}
</button>
</div>
</div>
</div>
)}
</CenteredModal>
);
}
-18
View File
@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('CheckinLog mobile layout', () => {
it('uses the shared responsive filter scaffold for mobile filters', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/CheckinLog.tsx'), 'utf8');
expect(source).toMatch(/import\s+ResponsiveFilterPanel\s+from\s+['"]\.\.\/components\/ResponsiveFilterPanel\.js['"]/);
expect(source).toContain('<ResponsiveFilterPanel');
});
it('uses the shared mobile card header and footer action slots', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/CheckinLog.tsx'), 'utf8');
expect(source).toContain('const isMobile = useIsMobile();');
expect(source).toContain('headerActions={');
expect(source).toContain('footerActions={');
});
});
@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Dashboard mobile layout', () => {
it('uses the shared mobile breakpoint to collapse fixed desktop grids', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Dashboard.tsx'), 'utf8');
expect(source).toContain('import { useIsMobile } from "../components/useIsMobile.js";');
expect(source).toContain('const isMobile = useIsMobile()');
expect(source).toContain('gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr"');
expect(source).toContain('gridTemplateColumns: isMobile ? "1fr" : "1fr 300px"');
});
});
@@ -1,158 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { ToastProvider } from '../components/Toast.js';
import Dashboard from './Dashboard.js';
import { installDashboardSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDashboard: vi.fn(),
getDashboardSnapshot: vi.fn(),
getDashboardInsights: vi.fn(),
getSiteSnapshot: vi.fn(),
getSiteDistribution: vi.fn(),
getSiteTrend: vi.fn(),
getSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function isStatCard(node: ReactTestInstance): boolean {
return typeof node.props.className === 'string'
&& /(^|\s)stat-card(\s|$)/.test(node.props.className);
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Dashboard performance stat card', () => {
const originalDocument = globalThis.document;
beforeEach(() => {
vi.clearAllMocks();
installDashboardSnapshotCompat(apiMock);
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
apiMock.getSites.mockResolvedValue([]);
globalThis.document = {
visibilityState: 'visible',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getElementById: vi.fn(() => null),
} as unknown as Document;
});
afterEach(() => {
globalThis.document = originalDocument;
vi.clearAllMocks();
});
it('renders RPM and TPM inside a fifth stat card within the same dashboard grid', async () => {
apiMock.getDashboard.mockResolvedValue({
totalBalance: 0,
totalUsed: 0,
todaySpend: 0,
todayReward: 0,
activeAccounts: 0,
totalAccounts: 0,
todayCheckin: { success: 0, total: 0 },
proxy24h: { success: 0, total: 0, totalTokens: 606_573_377 },
performance: {
windowSeconds: 60,
requestsPerMinute: 17,
tokensPerMinute: 7_974,
},
modelAnalysis: null,
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
await flushMicrotasks();
const statGrid = root!.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('dashboard-stat-grid')
));
const statCards = statGrid.findAll(isStatCard);
expect(statCards).toHaveLength(5);
expect(collectText(statGrid)).toContain('性能指标');
expect(collectText(statGrid)).toContain('RPM');
expect(collectText(statGrid)).toContain('17');
expect(collectText(statGrid)).toContain('TPM');
expect(collectText(statGrid)).toContain('8K');
expect(collectText(statGrid)).toContain('24h Tokens');
expect(collectText(statGrid)).toContain('606.6M');
} finally {
root?.unmount();
}
});
it('shows five skeleton stat cards while dashboard data is still loading', async () => {
let resolveDashboard: ((value: Record<string, unknown>) => void) | undefined;
apiMock.getDashboard.mockImplementation(() => (
new Promise((resolve) => {
resolveDashboard = resolve as (value: Record<string, unknown>) => void;
})
));
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
const statGrid = root!.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('dashboard-stat-grid')
));
const statCards = statGrid.findAll(isStatCard);
expect(statCards).toHaveLength(5);
} finally {
if (resolveDashboard) {
resolveDashboard({
totalBalance: 0,
totalUsed: 0,
todaySpend: 0,
todayReward: 0,
activeAccounts: 0,
totalAccounts: 0,
todayCheckin: { success: 0, total: 0 },
proxy24h: { success: 0, total: 0, totalTokens: 0 },
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
modelAnalysis: null,
});
}
root?.unmount();
}
});
});
@@ -1,140 +0,0 @@
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 Dashboard from './Dashboard.js';
import { installDashboardSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDashboard: vi.fn(),
getDashboardSnapshot: vi.fn(),
getDashboardInsights: vi.fn(),
getSiteSnapshot: vi.fn(),
getSiteDistribution: vi.fn(),
getSiteTrend: vi.fn(),
getSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Dashboard site observability panel', () => {
const originalDocument = globalThis.document;
beforeEach(() => {
vi.clearAllMocks();
installDashboardSnapshotCompat(apiMock);
apiMock.getDashboard.mockResolvedValue({
totalBalance: 0,
totalUsed: 0,
todaySpend: 0,
todayReward: 0,
activeAccounts: 0,
totalAccounts: 0,
todayCheckin: { success: 0, total: 0 },
proxy24h: { success: 0, total: 0, totalTokens: 0 },
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
siteAvailability: [{
siteId: 1,
siteName: 'Demo Site',
siteUrl: 'https://example.com',
platform: 'new-api',
totalRequests: 8,
successCount: 6,
failedCount: 2,
availabilityPercent: 75,
averageLatencyMs: 320,
buckets: Array.from({ length: 24 }, (_, index) => ({
startUtc: new Date(Date.UTC(2026, 2, 11, index, 0, 0)).toISOString(),
label: `2026-03-11 ${String(index).padStart(2, '0')}:00:00`,
totalRequests: index < 8 ? 1 : 0,
successCount: index < 6 ? 1 : 0,
failedCount: index >= 6 && index < 8 ? 1 : 0,
availabilityPercent: index < 6 ? 100 : index < 8 ? 0 : null,
averageLatencyMs: index < 8 ? 320 : null,
})),
}],
modelAnalysis: null,
});
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
apiMock.getSites.mockResolvedValue([]);
globalThis.document = {
visibilityState: 'visible',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getElementById: vi.fn(() => null),
} as unknown as Document;
});
afterEach(() => {
globalThis.document = originalDocument;
vi.clearAllMocks();
});
it('renders site availability strips and summary metrics', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<ToastProvider>
<Dashboard />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const panel = root!.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('site-observability-panel')
));
const cells = panel.findAll((node) => (
node.type === 'a'
&& typeof node.props.className === 'string'
&& node.props.className.includes('site-availability-cell')
));
const logLink = panel.find((node) => (
node.type === 'a'
&& typeof node.props.className === 'string'
&& node.props.className.includes('site-observability-log-link')
));
expect(collectText(panel)).toContain('站点可用性观测');
expect(collectText(panel)).toContain('Demo Site');
expect(collectText(panel)).toContain('75%');
expect(collectText(panel)).toContain('320ms');
expect(logLink.props.title).toBe('查看日志');
expect(cells).toHaveLength(24);
expect(String(cells[0]?.props.title || '')).toContain('可用性 100%');
expect(String(cells[7]?.props.title || '')).toContain('可用性 0%');
expect(String(cells[0]?.props['data-tooltip'] || '')).toContain('时间:');
expect(String(cells[0]?.props['data-tooltip'] || '')).toContain('可用性:100%');
expect(String(cells[0]?.props['data-tooltip'] || '')).toContain('成功/失败:1/0');
expect(String(logLink.props.href || logLink.props.to || '')).toContain('/logs?siteId=1');
} finally {
root?.unmount();
}
});
});
@@ -1,135 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { ToastProvider } from '../components/Toast.js';
import Dashboard from './Dashboard.js';
import { installDashboardSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDashboard: vi.fn(),
getDashboardSnapshot: vi.fn(),
getDashboardInsights: vi.fn(),
getSiteSnapshot: vi.fn(),
getSiteDistribution: vi.fn(),
getSiteTrend: vi.fn(),
getSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Dashboard site speed buttons', () => {
const originalDocument = globalThis.document;
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.clearAllMocks();
installDashboardSnapshotCompat(apiMock);
apiMock.getDashboard.mockResolvedValue({
totalBalance: 0,
totalUsed: 0,
todaySpend: 0,
todayReward: 0,
activeAccounts: 0,
totalAccounts: 0,
todayCheckin: { success: 0, total: 0 },
proxy24h: { success: 0, total: 0, totalTokens: 0 },
modelAnalysis: null,
});
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
apiMock.getSites.mockResolvedValue([
{ id: 1, name: 'Demo Site', url: 'https://example.com', status: 'active' },
]);
globalThis.document = {
visibilityState: 'visible',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getElementById: vi.fn(() => null),
} as unknown as Document;
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }) as unknown as typeof fetch;
});
afterEach(() => {
vi.clearAllMocks();
globalThis.document = originalDocument;
globalThis.fetch = originalFetch;
});
it('updates site speed status without imperative document lookups', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
await flushMicrotasks();
const speedButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '测速'
));
await act(async () => {
await speedButton.props.onClick();
});
await flushMicrotasks();
expect(globalThis.document.getElementById).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
it('updates bulk site speed status without imperative document lookups', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
await flushMicrotasks();
const bulkSpeedButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '一键测速'
));
await act(async () => {
await bulkSpeedButton.props.onClick();
});
await flushMicrotasks();
expect(globalThis.document.getElementById).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
});
-98
View File
@@ -1,98 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { ToastProvider } from '../components/Toast.js';
import Dashboard from './Dashboard.js';
import { installDashboardSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDashboard: vi.fn(),
getDashboardSnapshot: vi.fn(),
getDashboardInsights: vi.fn(),
getSiteSnapshot: vi.fn(),
getSiteDistribution: vi.fn(),
getSiteTrend: vi.fn(),
getSites: vi.fn(),
getAccounts: vi.fn(),
getAccountTokens: vi.fn(),
getRoutes: vi.fn(),
startTestChatJob: vi.fn(),
getTestChatJob: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
describe('Dashboard hook order', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
apiMock.getSites.mockResolvedValue([]);
apiMock.getAccounts.mockResolvedValue([]);
apiMock.getAccountTokens.mockResolvedValue([]);
apiMock.getRoutes.mockResolvedValue([]);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
vi.clearAllMocks();
installDashboardSnapshotCompat(apiMock);
});
it('keeps hook order stable when switching from loading to loaded render', async () => {
let root!: WebTestRenderer;
let renderedTree = '';
const dashboardData = {
totalBalance: 0,
totalUsed: 0,
todaySpend: 0,
todayReward: 0,
activeAccounts: 0,
totalAccounts: 0,
todayCheckin: { success: 0, total: 0 },
proxy24h: { success: 0, total: 0, totalTokens: 0 },
modelAnalysis: null,
};
let resolveFirstDashboard: ((value: typeof dashboardData) => void) | null = null;
apiMock.getDashboard.mockImplementation(() => {
if (!resolveFirstDashboard) {
return new Promise((resolve) => {
resolveFirstDashboard = resolve as (value: typeof dashboardData) => void;
});
}
return Promise.resolve(dashboardData);
});
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
await act(async () => {
resolveFirstDashboard?.(dashboardData);
await Promise.resolve();
});
renderedTree = JSON.stringify(root?.toJSON() ?? null);
} finally {
root?.unmount();
}
const errorOutput = consoleErrorSpy.mock.calls
.map((args) => args.map((item) => String(item)).join(' '))
.join('\n');
expect(errorOutput).not.toContain('change in the order of Hooks called by Dashboard');
expect(renderedTree).not.toContain('快速上手');
});
});
@@ -1,254 +0,0 @@
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { api, type DownstreamApiKeyTrendResponse } from '../../api.js';
import { useToast } from '../../components/Toast.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { readClientTimeZone } from '../helpers/siteAnnouncementPresentation.js';
import {
formatCompactTokens,
formatIso,
formatMoney,
type OverviewResponse,
RangeToggle,
resolveOverviewUsageByRange,
StatusBadge,
TagChips,
TrendChartFallback,
type Range,
type SummaryItem,
} from './shared.js';
const DownstreamKeyTrendChart = lazy(() => import('../../components/charts/DownstreamKeyTrendChart.js'));
type DownstreamKeyTrendBucket = import('../../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket;
type DownstreamKeyDrawerProps = {
open: boolean;
onClose: () => void;
item: SummaryItem | null;
initialRange: Range;
};
export default function DownstreamKeyDrawer({
open,
onClose,
item,
initialRange,
}: DownstreamKeyDrawerProps) {
const toast = useToast();
const presence = useAnimatedVisibility(open, 220);
const [overview, setOverview] = useState<OverviewResponse | null>(null);
const [overviewLoading, setOverviewLoading] = useState(false);
const [trendRange, setTrendRange] = useState<Range>(initialRange);
const [trendLoading, setTrendLoading] = useState(false);
const [buckets, setBuckets] = useState<DownstreamKeyTrendBucket[]>([]);
const [trendBucketSeconds, setTrendBucketSeconds] = useState<number>(initialRange === 'all' ? 86400 : 3600);
const viewerTimeZone = useMemo(() => readClientTimeZone(), []);
useEffect(() => {
if (!open) return;
setTrendRange(initialRange);
setTrendBucketSeconds(initialRange === 'all' ? 86400 : 3600);
}, [open, initialRange]);
useEffect(() => {
if (!open || !item?.id) return;
let cancelled = false;
setOverview(null);
setOverviewLoading(true);
api.getDownstreamApiKeyOverview(item.id)
.then((res: any) => {
if (cancelled) return;
setOverview(res as OverviewResponse);
})
.catch((err: any) => {
if (cancelled) return;
toast.error(err?.message || '加载 Key 概览失败');
})
.finally(() => {
if (cancelled) return;
setOverviewLoading(false);
});
return () => {
cancelled = true;
};
}, [open, item?.id, toast]);
useEffect(() => {
if (!open || !item?.id) return;
let cancelled = false;
const fallbackBucketSeconds = trendRange === 'all' ? 86400 : 3600;
setBuckets([]);
setTrendBucketSeconds(fallbackBucketSeconds);
setTrendLoading(true);
const trendParams = trendRange === 'all' && viewerTimeZone
? { range: trendRange, timeZone: viewerTimeZone }
: { range: trendRange };
api.getDownstreamApiKeyTrend(item.id, trendParams)
.then((res: DownstreamApiKeyTrendResponse) => {
if (cancelled) return;
setBuckets(Array.isArray(res.buckets) ? res.buckets : []);
const nextBucketSeconds = Number(res.bucketSeconds);
setTrendBucketSeconds(Number.isFinite(nextBucketSeconds) && nextBucketSeconds > 0 ? nextBucketSeconds : fallbackBucketSeconds);
})
.catch((err: any) => {
if (cancelled) return;
toast.error(err?.message || '加载趋势失败');
})
.finally(() => {
if (cancelled) return;
setTrendLoading(false);
});
return () => {
cancelled = true;
};
}, [open, item?.id, trendRange, toast, viewerTimeZone]);
if (!presence.shouldRender) return null;
const currentRangeUsage = resolveOverviewUsageByRange(overview, trendRange) || item?.rangeUsage || null;
const panel = (
<div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={onClose}
style={{ justifyContent: 'flex-end', alignItems: 'stretch', padding: 0 }}
>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={(e) => e.stopPropagation()}
style={{
width: 'min(92vw, 560px)',
maxWidth: 560,
height: '100vh',
maxHeight: '100vh',
borderRadius: 0,
animation: presence.isVisible ? 'drawer-slide-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both' : 'drawer-slide-out 0.22s cubic-bezier(0.4, 0, 1, 1) both',
}}
>
<div className="modal-header" style={{ paddingTop: 18, paddingBottom: 12, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span>{item?.name || '--'}</span>
<StatusBadge enabled={!!item?.enabled} />
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{item?.keyMasked || '****'}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>
<span className={`badge ${item?.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{item?.groupName ? `主分组 · ${item.groupName}` : '未分组'}
</span>
<TagChips tags={item?.tags || []} accent maxVisible={4} />
</div>
</div>
<button className="btn btn-ghost" onClick={onClose} style={{ border: '1px solid var(--color-border)' }}>
关闭
</button>
</div>
<div className="modal-body" style={{ paddingTop: 0 }}>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>使用趋势</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>按选定时间窗口查看请求、Tokens 与成本变化。</div>
</div>
<RangeToggle range={trendRange} onChange={setTrendRange} />
</div>
<Suspense fallback={<TrendChartFallback height={260} />}>
<DownstreamKeyTrendChart buckets={buckets} bucketSeconds={trendBucketSeconds} loading={trendLoading} height={260} />
</Suspense>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
基础信息
</div>
{overviewLoading ? (
<div className="skeleton" style={{ width: '100%', height: 72, borderRadius: 'var(--radius-sm)' }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>最近使用</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.lastUsedAt)}</div>
</div>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计请求</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{(item?.usedRequests || 0).toLocaleString()}</div>
</div>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计成本</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatMoney(Number(item?.usedCost || 0))}</div>
</div>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>到期时间</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.expiresAt)}</div>
</div>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>主分组</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{item?.groupName || '未分组'}</div>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 6 }}>标签</div>
<TagChips tags={item?.tags || []} accent maxVisible={6} />
</div>
</div>
)}
</div>
<div className="card" style={{ padding: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
当前范围汇总
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>Tokens</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{formatCompactTokens(currentRangeUsage?.totalTokens || 0)}</div>
</div>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>请求数</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{(currentRangeUsage?.totalRequests || 0).toLocaleString()}</div>
</div>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>成功率</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{currentRangeUsage?.successRate == null ? '--' : `${currentRangeUsage.successRate}%`}</div>
</div>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>成本</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{formatMoney(Number(currentRangeUsage?.totalCost || 0))}</div>
</div>
</div>
</div>
{overview?.usage ? (
<div className="card" style={{ padding: 16, marginTop: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
固定窗口对比
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 12 }}>
{[
{ label: '24h', data: overview.usage.last24h },
{ label: '7d', data: overview.usage.last7d },
{ label: '全部', data: overview.usage.all },
].map((section) => (
<div key={section.label} style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 12 }}>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700, marginBottom: 8 }}>{section.label}</div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>Tokens</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700, marginBottom: 8 }}>{formatCompactTokens(section.data?.totalTokens || 0)}</div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>请求数</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700, marginBottom: 8 }}>{(section.data?.totalRequests || 0).toLocaleString()}</div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>成功率</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{section.data?.successRate == null ? '--' : `${section.data.successRate}%`}</div>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
);
return createPortal(panel, document.body);
}
@@ -1,658 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import CenteredModal from '../../components/CenteredModal.js';
import { generateDownstreamSkKey } from '../helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
export type DownstreamExcludedCredentialRef =
| {
kind: 'account_token';
siteId: number;
accountId: number;
tokenId: number;
}
| {
kind: 'default_api_key';
siteId: number;
accountId: number;
};
export type DownstreamKeyEditorForm = {
name: string;
key: string;
description: string;
groupName: string;
tags: string[];
maxCost: string;
maxRequests: string;
expiresAt: string;
enabled: boolean;
selectedModels: string[];
selectedGroupRouteIds: number[];
siteWeightMultipliersText: string;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
};
export type DownstreamSiteOption = {
siteId: number;
siteName: string;
accountCount: number;
};
export type DownstreamCredentialOption = {
key: string;
ref: DownstreamExcludedCredentialRef;
siteName: string;
accountName: string;
label: string;
detail: string;
};
type RouteSelectorItem = {
id: number;
modelPattern: string;
displayName?: string | null;
enabled: boolean;
};
function parseTagText(value: string): string[] {
return value
.split(/[\r\n,,]+/g)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeTags(values: string[]): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const raw of values) {
const value = String(raw || '').trim();
if (!value) continue;
const normalized = value.slice(0, 32);
const dedupeKey = normalized.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
result.push(normalized);
if (result.length >= 20) break;
}
return result;
}
function uniqStrings(values: string[]): string[] {
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))];
}
function uniqIds(values: number[]): number[] {
return [...new Set(values.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0).map((value) => Math.trunc(value)))];
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?]/.test(normalized);
}
function isGroupRouteOption(route: RouteSelectorItem): boolean {
return !isExactModelPattern(route.modelPattern);
}
function routeTitle(route: RouteSelectorItem): string {
const displayName = (route.displayName || '').trim();
return displayName || route.modelPattern;
}
function tagChipStyle(kind: 'normal' | 'accent' = 'normal'): React.CSSProperties {
return {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
border: '1px solid var(--color-border-light)',
color: kind === 'accent' ? 'var(--color-primary)' : 'var(--color-text-secondary)',
background: kind === 'accent'
? 'color-mix(in srgb, var(--color-primary) 10%, transparent)'
: 'var(--color-bg-card)',
};
}
function buildExcludedCredentialRefKey(ref: DownstreamExcludedCredentialRef): string {
return ref.kind === 'account_token'
? `${ref.kind}:${ref.siteId}:${ref.accountId}:${ref.tokenId}`
: `${ref.kind}:${ref.siteId}:${ref.accountId}`;
}
function normalizeExcludedSiteIds(values: number[]): number[] {
return uniqIds(values).sort((left, right) => left - right);
}
function normalizeExcludedCredentialRefs(values: DownstreamExcludedCredentialRef[]): DownstreamExcludedCredentialRef[] {
const deduped = new Map<string, DownstreamExcludedCredentialRef>();
for (const value of values) {
if (!value || !Number.isFinite(value.siteId) || !Number.isFinite(value.accountId)) continue;
if (value.kind === 'account_token') {
if (!Number.isFinite(value.tokenId)) continue;
const normalized: DownstreamExcludedCredentialRef = {
kind: 'account_token',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
tokenId: Math.trunc(value.tokenId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
continue;
}
const normalized: DownstreamExcludedCredentialRef = {
kind: 'default_api_key',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
}
return Array.from(deduped.values()).sort((left, right) => buildExcludedCredentialRefKey(left).localeCompare(buildExcludedCredentialRefKey(right)));
}
export function TagInput({
tags,
onChange,
suggestions = [],
placeholder,
}: {
tags: string[];
onChange: (tags: string[]) => void;
suggestions?: string[];
placeholder?: string;
}) {
const [draft, setDraft] = useState('');
useEffect(() => {
setDraft('');
}, [tags.length]);
const commitDraft = () => {
const nextTags = normalizeTags([...tags, ...parseTagText(draft)]);
if (nextTags.length !== tags.length) {
onChange(nextTags);
}
setDraft('');
};
const removeTag = (target: string) => {
onChange(tags.filter((tag) => tag !== target));
};
const suggestionPool = suggestions.filter((tag) => !tags.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 12);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)', padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => removeTag(tag)}
style={{ ...tagChipStyle('accent'), cursor: 'pointer' }}
title={`移除 ${tag}`}
>
<span>{tag}</span>
<span aria-hidden="true">×</span>
</button>
))}
</div>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && !draft && tags.length > 0) {
e.preventDefault();
onChange(tags.slice(0, -1));
}
}}
placeholder={placeholder || '输入标签后按回车或逗号'}
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0, fontSize: 13, lineHeight: 1.45 }}
/>
</div>
{suggestionPool.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{suggestionPool.map((tag) => (
<button
key={tag}
type="button"
className="btn btn-ghost"
style={{ ...tagChipStyle(), cursor: 'pointer' }}
onClick={() => onChange(normalizeTags([...tags, tag]))}
>
{tag}
</button>
))}
</div>
) : null}
</div>
);
}
export default function DownstreamKeyEditorModal({
open,
editingItem,
form,
onChange,
onClose,
onSave,
saving,
routeOptions,
groupSuggestions,
tagSuggestions,
exclusionSourceLoading,
siteOptions,
credentialOptions,
}: {
open: boolean;
editingItem: { id: number } | null;
form: DownstreamKeyEditorForm;
onChange: (updater: (prev: DownstreamKeyEditorForm) => DownstreamKeyEditorForm) => void;
onClose: () => void;
onSave: () => void;
saving: boolean;
routeOptions: RouteSelectorItem[];
groupSuggestions: string[];
tagSuggestions: string[];
exclusionSourceLoading: boolean;
siteOptions: DownstreamSiteOption[];
credentialOptions: DownstreamCredentialOption[];
}) {
const [modelSearch, setModelSearch] = useState('');
const [groupSearch, setGroupSearch] = useState('');
const [siteSearch, setSiteSearch] = useState('');
const [credentialSearch, setCredentialSearch] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
if (!open) {
setModelSearch('');
setGroupSearch('');
setSiteSearch('');
setCredentialSearch('');
setAdvancedOpen(false);
}
}, [open]);
const exactModels = useMemo(
() => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)),
[routeOptions],
);
const groupRouteOptions = useMemo(
() => routeOptions.filter(isGroupRouteOption),
[routeOptions],
);
const validGroupRouteIdSet = useMemo(
() => new Set(groupRouteOptions.map((route) => route.id)),
[groupRouteOptions],
);
const normalizedSelectedGroupRouteIds = useMemo(
() => uniqIds(form.selectedGroupRouteIds.filter((id) => validGroupRouteIdSet.has(id))),
[form.selectedGroupRouteIds, validGroupRouteIdSet],
);
const filteredModels = useMemo(() => {
const keyword = modelSearch.trim().toLowerCase();
if (!keyword) return exactModels;
return exactModels.filter((model) => model.toLowerCase().includes(keyword));
}, [exactModels, modelSearch]);
const filteredGroups = useMemo(() => {
const keyword = groupSearch.trim().toLowerCase();
if (!keyword) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const title = routeTitle(route).toLowerCase();
return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword);
});
}, [groupRouteOptions, groupSearch]);
const filteredSites = useMemo(() => {
const keyword = siteSearch.trim().toLowerCase();
if (!keyword) return siteOptions;
return siteOptions.filter((site) => site.siteName.toLowerCase().includes(keyword));
}, [siteOptions, siteSearch]);
const filteredCredentials = useMemo(() => {
const keyword = credentialSearch.trim().toLowerCase();
if (!keyword) return credentialOptions;
return credentialOptions.filter((item) => (
item.siteName.toLowerCase().includes(keyword)
|| item.accountName.toLowerCase().includes(keyword)
|| item.label.toLowerCase().includes(keyword)
|| item.detail.toLowerCase().includes(keyword)
));
}, [credentialOptions, credentialSearch]);
const selectedModelCount = form.selectedModels.length;
const selectedGroupCount = normalizedSelectedGroupRouteIds.length;
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.45,
};
return (
<CenteredModal
open={open}
onClose={onClose}
title={editingItem ? '编辑下游密钥' : '新增下游密钥'}
maxWidth={860}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={saving}>取消</button>
<button onClick={onSave} className="btn btn-primary" disabled={saving}>
{saving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
: (editingItem ? '保存修改' : '创建密钥')}
</button>
</>
)}
>
<div className="info-tip" style={{ marginBottom: 0 }}>
支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。
</div>
<div className="downstream-key-modal-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">名称</div>
<input value={form.name} onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">下游密钥</div>
<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>
<input
value={form.groupName}
onChange={(e) => onChange((prev) => ({ ...prev, groupName: e.target.value }))}
placeholder="例如:VIP / 内部项目 / A组"
list="downstream-group-suggestions"
style={inputStyle}
/>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">请求额度</div>
<input value={form.maxRequests} onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">成本额度</div>
<input value={form.maxCost} onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">过期时间</div>
<input type="datetime-local" value={form.expiresAt} onChange={(e) => onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} />
</div>
<label className="downstream-key-modal-toggle">
<input type="checkbox" checked={form.enabled} onChange={(e) => onChange((prev) => ({ ...prev, enabled: e.target.checked }))} />
<div>
<div className="downstream-key-modal-toggle-title">创建后立即启用</div>
<div className="downstream-key-modal-help">关闭后该密钥将无法继续分发请求</div>
</div>
</label>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">备注说明</div>
<textarea
value={form.description}
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
placeholder="填写业务场景、负责人或限制说明"
style={{ ...inputStyle, minHeight: 84, resize: 'vertical' }}
/>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">标签</div>
<TagInput
tags={form.tags}
onChange={(tags) => onChange((prev) => ({ ...prev, tags }))}
suggestions={tagSuggestions}
placeholder="输入标签后按回车或逗号,例如:移动端、VIP、项目A"
/>
<div className="downstream-key-modal-help">标签用于搜索、筛选和辅助归类,不影响路由与权限。</div>
</div>
<div className="downstream-key-advanced">
<button type="button" className={`downstream-key-advanced-toggle ${advancedOpen ? 'is-open' : ''}`.trim()} onClick={() => setAdvancedOpen((value) => !value)}>
<span>高级配置</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{advancedOpen ? '收起' : '展开'}</span>
</button>
{advancedOpen ? (
<div className="downstream-key-advanced-content">
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">站点倍率 JSON</div>
<textarea
value={form.siteWeightMultipliersText}
onChange={(e) => onChange((prev) => ({ ...prev, siteWeightMultipliersText: e.target.value }))}
placeholder={'例如:{\n "1": 1.2,\n "7": 0.8\n}'}
style={{ ...inputStyle, minHeight: 96, resize: 'vertical', fontFamily: 'var(--font-mono)' }}
/>
<div className="downstream-key-modal-help">用于对特定站点做分发倍率微调;留空或 `{}` 表示走默认倍率。</div>
</div>
<div className="downstream-key-advanced-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-advanced-panel">
<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>
<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>
</div>
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={modelSearch} onChange={(e) => setModelSearch(e.target.value)} placeholder="搜索模型" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配模型</div>
) : filteredModels.map((model) => {
const checked = form.selectedModels.includes(model);
return (
<label key={model} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedModels: checked ? prev.selectedModels.filter((item) => item !== model) : [...prev.selectedModels, model],
}))}
/>
<code style={{ color: 'var(--color-text-primary)', fontSize: 12 }}>{model}</code>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<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>
<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>
</div>
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={groupSearch} onChange={(e) => setGroupSearch(e.target.value)} placeholder="搜索群组或模型模式" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredGroups.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配群组</div>
) : filteredGroups.map((route) => {
const checked = normalizedSelectedGroupRouteIds.includes(route.id);
return (
<label key={route.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedGroupRouteIds: checked
? prev.selectedGroupRouteIds.filter((item) => item !== route.id)
: uniqIds([...prev.selectedGroupRouteIds.filter((item) => validGroupRouteIdSet.has(item)), route.id]),
}))}
style={{ marginTop: 2 }}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>
{routeTitle(route)}
{!route.enabled ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--color-danger)' }}>已禁用</span> : null}
</div>
<code style={{ display: 'block', marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
</div>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<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>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, excludedSiteIds: [] }))}>清空</button>
</div>
<div className="downstream-key-modal-meta">已排除 {form.excludedSiteIds.length} 个站点</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={siteSearch} onChange={(e) => setSiteSearch(e.target.value)} placeholder="搜索站点" />
</div>
<div style={{ maxHeight: 240, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{exclusionSourceLoading ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载站点与令牌中...</div>
) : filteredSites.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可排除站点</div>
) : filteredSites.map((site) => {
const checked = form.excludedSiteIds.includes(site.siteId);
return (
<label key={site.siteId} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange((prev) => ({
...prev,
excludedSiteIds: normalizeExcludedSiteIds(
e.target.checked
? [...prev.excludedSiteIds, site.siteId]
: prev.excludedSiteIds.filter((item) => item !== site.siteId),
),
}))}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>{site.siteName}</div>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>{site.accountCount} 个账号</div>
</div>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">排除 API Key/令牌</div>
<div className="downstream-key-modal-help">支持排除显式令牌,以及 `tokenId` 为空时实际使用的默认 API Key。</div>
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, excludedCredentialRefs: [] }))}>清空</button>
</div>
<div className="downstream-key-modal-meta">已排除 {form.excludedCredentialRefs.length} 个凭证</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={credentialSearch} onChange={(e) => setCredentialSearch(e.target.value)} placeholder="搜索站点 / 账号 / 令牌" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{exclusionSourceLoading ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载站点与令牌中...</div>
) : filteredCredentials.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可排除 API Key/令牌</div>
) : filteredCredentials.map((item) => {
const checked = form.excludedCredentialRefs.some((ref) => buildExcludedCredentialRefKey(ref) === buildExcludedCredentialRefKey(item.ref));
return (
<label key={item.key} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange((prev) => ({
...prev,
excludedCredentialRefs: normalizeExcludedCredentialRefs(
e.target.checked
? [...prev.excludedCredentialRefs, item.ref]
: prev.excludedCredentialRefs.filter((ref) => buildExcludedCredentialRefKey(ref) !== buildExcludedCredentialRefKey(item.ref)),
),
}))}
style={{ marginTop: 2 }}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>{item.label}</div>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>
{item.siteName} / {item.accountName}
</div>
<div style={{ marginTop: 2, fontSize: 11, color: 'var(--color-text-muted)' }}>{item.detail}</div>
</div>
</label>
);
})}
</div>
</div>
</div>
</div>
) : null}
</div>
<datalist id="downstream-group-suggestions">
{groupSuggestions.map((group) => <option key={group} value={group} />)}
</datalist>
</CenteredModal>
);
}
-169
View File
@@ -1,169 +0,0 @@
import React from 'react';
export type Range = '24h' | '7d' | 'all';
export type SummaryItem = {
id: number;
name: string;
keyMasked: string;
enabled: boolean;
description: string | null;
groupName: string | null;
tags: string[];
expiresAt: string | null;
maxCost: number | null;
usedCost: number;
maxRequests: number | null;
usedRequests: number;
supportedModels: string[];
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: Array<
| { kind: 'account_token'; siteId: number; accountId: number; tokenId: number }
| { kind: 'default_api_key'; siteId: number; accountId: number }
>;
lastUsedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
rangeUsage: {
totalRequests: number;
successRequests: number;
failedRequests: number;
successRate: number | null;
totalTokens: number;
totalCost: number;
};
};
export type AggregateUsage = {
totalRequests: number;
successRequests: number;
failedRequests: number;
successRate: number | null;
totalTokens: number;
totalCost: number;
};
export type OverviewResponse = {
success: boolean;
item: SummaryItem;
usage: null | {
last24h: AggregateUsage | null;
last7d: AggregateUsage | null;
all: AggregateUsage | null;
};
};
export function formatIso(value: string | null | undefined): string {
const text = (value || '').trim();
if (!text) return '--';
const date = new Date(text);
if (Number.isNaN(date.getTime())) return text;
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function formatMoney(value: number): string {
if (!Number.isFinite(value)) return '$0';
if (value >= 1) return `$${value.toFixed(3)}`;
return `$${value.toFixed(6)}`;
}
export function formatCompactTokens(value: number): string {
if (!Number.isFinite(value)) return '0';
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`;
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return String(Math.trunc(value));
}
export function TagChips({
tags,
accent = false,
maxVisible = 3,
}: {
tags: string[];
accent?: boolean;
maxVisible?: number;
}) {
if (!Array.isArray(tags) || tags.length === 0) {
return <span className="badge badge-muted" style={{ fontSize: 11 }}>无标签</span>;
}
const visible = tags.slice(0, maxVisible);
const hidden = tags.length - visible.length;
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{visible.map((tag) => (
<span
key={tag}
className={`badge ${accent ? 'badge-info' : 'badge-muted'}`}
style={{ fontSize: 11 }}
>
{tag}
</span>
))}
{hidden > 0 ? <span className="badge badge-muted" style={{ fontSize: 11 }}>{`+${hidden}`}</span> : null}
</div>
);
}
export function resolveOverviewUsageByRange(
overview: OverviewResponse | null,
range: Range,
): AggregateUsage | null {
if (!overview?.usage) return null;
if (range === '24h') return overview.usage.last24h;
if (range === '7d') return overview.usage.last7d;
return overview.usage.all;
}
export function TrendChartFallback({ height = 260 }: { height?: number }) {
return (
<div className="card" style={{ padding: 16 }}>
<div className="skeleton" style={{ width: 140, height: 28, borderRadius: 'var(--radius-sm)', marginBottom: 10 }} />
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
export function RangeToggle({ range, onChange }: { range: Range; onChange: (r: Range) => void }) {
const base: React.CSSProperties = {
padding: '6px 12px',
fontSize: 12,
fontWeight: 600,
border: '1px solid var(--color-border)',
background: 'var(--color-bg-card)',
color: 'var(--color-text-muted)',
cursor: 'pointer',
};
const active: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#fff',
borderColor: 'var(--color-primary)',
};
return (
<div style={{ display: 'inline-flex', borderRadius: 'var(--radius-sm)', overflow: 'hidden' }}>
<button type="button" onClick={() => onChange('24h')} style={{ ...base, ...(range === '24h' ? active : {}), borderRight: 'none' }}>
24h
</button>
<button type="button" onClick={() => onChange('7d')} style={{ ...base, ...(range === '7d' ? active : {}), borderRight: 'none' }}>
7d
</button>
<button type="button" onClick={() => onChange('all')} style={{ ...base, ...(range === 'all' ? active : {}), borderTopRightRadius: 'var(--radius-sm)', borderBottomRightRadius: 'var(--radius-sm)' }}>
全部
</button>
</div>
);
}
export function StatusBadge({ enabled }: { enabled: boolean }) {
return (
<span className={`badge ${enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{enabled ? '启用' : '禁用'}
</span>
);
}
@@ -1,13 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('DownstreamKeys drawer extraction', () => {
it('delegates the downstream key detail drawer to a dedicated component', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DownstreamKeys.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("import DownstreamKeyDrawer from './downstream-keys/DownstreamKeyDrawer.js'");
expect(source).not.toContain('function Drawer({');
expect(source).not.toContain('return createPortal(panel, document.body);');
});
});
@@ -1,223 +0,0 @@
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 DownstreamKeys from './DownstreamKeys.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
batchDownstreamApiKeys: vi.fn(),
updateDownstreamApiKey: vi.fn(),
deleteDownstreamApiKey: vi.fn(),
resetDownstreamApiKeyUsage: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
function buildSummaryItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
keyMasked: `sk-m****0${id}`,
enabled: true,
description: `mobile item ${id}`,
groupName: '项目A',
tags: ['移动端'],
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
createdAt: '2026-03-15T08:27:25.378Z',
updatedAt: '2026-03-15T08:27:25.378Z',
rangeUsage: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
successRate: 66.7,
totalTokens: 4200,
totalCost: 0.42,
},
...overrides,
};
}
function buildRawItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
key: `sk-mobile-0${id}`,
keyMasked: `sk-m****0${id}`,
description: `mobile item ${id}`,
groupName: '项目A',
tags: ['移动端'],
enabled: true,
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
...overrides,
};
}
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === text
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe('DownstreamKeys mobile layout', () => {
beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).document = {
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [buildSummaryItem(1), buildSummaryItem(2)],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [buildRawItem(1), buildRawItem(2)],
});
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(1),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
});
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
success: true,
buckets: [],
});
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1, 2], failedItems: [] });
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
delete (globalThis as any).document;
});
it('renders mobile cards and supports select-all-visible batch actions', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('筛选');
expect(collectText(root!.root)).toContain('全选可见');
const mobileCards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
expect(mobileCards).toHaveLength(2);
const selectAllButton = findButtonByText(root!.root, '全选可见');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
const batchEnableButton = findButtonByText(root!.root, '启用');
await act(async () => {
batchEnableButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
ids: [1, 2],
action: 'enable',
});
} finally {
root?.unmount();
}
});
});
@@ -1,38 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
isTruthyFlag,
parsePositiveInt,
resolveAccountCredentialMode,
} from './accountConnection.js';
describe('accountConnection helpers', () => {
it('resolves account credential mode from explicit mode, capabilities, and token presence', () => {
expect(resolveAccountCredentialMode({ credentialMode: 'apikey' })).toBe('apikey');
expect(resolveAccountCredentialMode({ capabilities: { proxyOnly: true } })).toBe('apikey');
expect(resolveAccountCredentialMode({ accessToken: ' session-token ' })).toBe('session');
expect(resolveAccountCredentialMode({
credentialMode: 'session',
capabilities: { proxyOnly: true },
accessToken: 'api-token-ignored-by-explicit-mode',
})).toBe('session');
expect(resolveAccountCredentialMode({})).toBe('apikey');
});
it('parses positive integers from query values', () => {
expect(parsePositiveInt('42')).toBe(42);
expect(parsePositiveInt(' 0 ')).toBe(0);
expect(parsePositiveInt('abc')).toBe(0);
expect(parsePositiveInt('42abc')).toBe(0);
expect(parsePositiveInt('1e3')).toBe(0);
expect(parsePositiveInt('1.5')).toBe(0);
expect(parsePositiveInt(null)).toBe(0);
});
it('treats common truthy query flags as enabled', () => {
expect(isTruthyFlag('1')).toBe(true);
expect(isTruthyFlag(' TRUE ')).toBe(true);
expect(isTruthyFlag('yes')).toBe(true);
expect(isTruthyFlag('no')).toBe(false);
expect(isTruthyFlag(null)).toBe(false);
});
});
@@ -1,25 +0,0 @@
export function resolveAccountCredentialMode(account: any): 'session' | 'apikey' {
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'apikey') return 'apikey';
if (rawMode === 'session') return 'session';
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
return account.capabilities.proxyOnly ? 'apikey' : 'session';
}
return typeof account?.accessToken === 'string' && account.accessToken.trim()
? 'session'
: 'apikey';
}
export function parsePositiveInt(input: string | null): number {
const normalized = String(input || '').trim();
if (!/^\d+$/.test(normalized)) return 0;
const value = Number.parseInt(normalized, 10);
if (!Number.isFinite(value) || value <= 0) return 0;
return value;
}
export function isTruthyFlag(input: string | null): boolean {
if (!input) return false;
const normalized = input.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
@@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildAddAccountPrereqHint,
buildVerifyFailureHint,
normalizeVerifyFailureMessage,
} from './accountVerifyFeedback.js';
describe('account verify feedback', () => {
it('treats fetch failures as connectivity issues instead of token mistakes', () => {
expect(normalizeVerifyFailureMessage('Failed to fetch')).not.toBe('Failed to fetch');
expect(buildVerifyFailureHint({ success: false, message: 'Failed to fetch' })).not.toBeNull();
expect(buildAddAccountPrereqHint({ success: false, message: 'Failed to fetch' })).toMatch(/metapi|站点|代理/i);
});
it('treats timeout failures as reachability issues instead of token mistakes', () => {
const timeoutMessage = 'Token verification timed out (10s)';
expect(normalizeVerifyFailureMessage(timeoutMessage)).toBe(timeoutMessage);
expect(buildVerifyFailureHint({ success: false, message: timeoutMessage })).toMatch(/Token|站点|代理|超时/i);
expect(buildAddAccountPrereqHint({ success: false, message: timeoutMessage })).toMatch(/站点|代理|超时/i);
});
it('keeps token guidance for actual credential failures', () => {
const invalidHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
expect(invalidHint).toMatch(/Token/i);
expect(buildAddAccountPrereqHint({ success: false, message: 'Token invalid' })).toMatch(/Token/i);
});
it('normalizes invalid user-id mismatch messages for display', () => {
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
.toMatch(/ID/i);
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
.not.toContain('does not match this token');
});
it('prioritizes user-id guidance when the site requires it', () => {
expect(buildVerifyFailureHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toBeNull();
expect(buildAddAccountPrereqHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toMatch(/ID/i);
});
it('treats invalid user-id as a mismatch instead of a missing-value hint', () => {
const invalidUserIdHint = buildVerifyFailureHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
const genericTokenHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
const invalidUserIdPrereq = buildAddAccountPrereqHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
const genericPrereq = buildAddAccountPrereqHint({ success: false, message: 'Token invalid' });
expect(invalidUserIdHint).toMatch(/ID/i);
expect(invalidUserIdHint).not.toBe(genericTokenHint);
expect(invalidUserIdPrereq).toMatch(/ID/i);
expect(invalidUserIdPrereq).not.toBe(genericPrereq);
});
});
@@ -1,72 +0,0 @@
type VerifyResultLike = {
success?: boolean;
needsUserId?: boolean;
invalidUserId?: boolean;
message?: string | null;
} | null | undefined;
function normalizeMessageText(message: unknown): string {
return typeof message === 'string' ? message.trim() : '';
}
export function isNetworkFailureMessage(message: unknown): boolean {
const lowered = normalizeMessageText(message).toLowerCase();
return lowered.includes('failed to fetch')
|| lowered.includes('fetch failed')
|| lowered.includes('networkerror')
|| lowered.includes('load failed');
}
export function isTimeoutFailureMessage(message: unknown): boolean {
const lowered = normalizeMessageText(message).toLowerCase();
return lowered.includes('timed out')
|| lowered.includes('timeout')
|| lowered.includes('请求超时');
}
export function normalizeVerifyFailureMessage(message: unknown): string {
const text = normalizeMessageText(message);
if (!text) return '验证失败';
const lowered = text.toLowerCase();
if (isNetworkFailureMessage(text)) {
return '无法连接到 BoosAPI 服务端,请检查服务状态或网络连接';
}
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
}
return text;
}
export function buildVerifyFailureHint(result: VerifyResultLike): string | null {
if (!result || result.success || result.needsUserId) return null;
if (result.invalidUserId) {
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
}
if (isNetworkFailureMessage(result.message)) {
return '这不是 Token 错误判断。请检查 BoosAPI 服务是否在线,以及目标站点或代理是否可达。';
}
if (isTimeoutFailureMessage(result.message)) {
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
}
return '请检查 Token 是否正确';
}
export function buildAddAccountPrereqHint(result: VerifyResultLike): string {
if (!result) {
return '请先点击“验证 Token”,验证成功后才能添加账号。';
}
if (result.success) return '';
if (result.needsUserId) {
return '请先补充用户 ID 并重新验证,验证成功后才能添加账号。';
}
if (result.invalidUserId) {
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
}
if (isNetworkFailureMessage(result.message)) {
return '验证请求未成功完成,请先检查 BoosAPI 服务、站点网络或代理配置。';
}
if (isTimeoutFailureMessage(result.message)) {
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
}
return '请先点击“验证 Token”,验证成功后才能添加账号。';
}
@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getAccountsAddPanelStyle } from './accountsPanelStyle.js';
describe('getAccountsAddPanelStyle', () => {
it('preserves spacing and raises stacking context so select dropdown is clickable', () => {
const style = getAccountsAddPanelStyle();
expect(style.padding).toBe(20);
expect(style.marginBottom).toBe(16);
expect(style.position).toBe('relative');
expect(style.zIndex).toBeGreaterThan(0);
expect(style.overflow).toBe('visible');
});
});
@@ -1,9 +0,0 @@
export function getAccountsAddPanelStyle() {
return {
padding: 20,
marginBottom: 16,
position: 'relative' as const,
zIndex: 10,
overflow: 'visible' as const,
};
}
@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
formatCheckinLogTime,
formatDateLocal,
formatDateTimeMinuteLocal,
} from './checkinLogTime.js';
describe('formatCheckinLogTime', () => {
it('formats sqlite UTC timestamp as local display time', () => {
const formatted = formatCheckinLogTime('2026-02-25 03:51:58', 'en-US', 'UTC');
expect(formatted).toBe('02/25/2026, 03:51:58');
});
it('keeps original value for invalid timestamp', () => {
expect(formatCheckinLogTime('invalid-time', 'en-US', 'UTC')).toBe('invalid-time');
});
it('formats local date with minute precision helpers', () => {
expect(formatDateLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026');
expect(formatDateTimeMinuteLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026, 03:51');
});
it('supports postgres-style timezone offset without colon', () => {
expect(formatDateTimeMinuteLocal('2026-03-05 20:14:39+08', 'en-US', 'UTC')).toBe('03/05/2026, 12:14');
expect(formatDateTimeMinuteLocal('2026-03-05T20:14:39+0800', 'en-US', 'UTC')).toBe('03/05/2026, 12:14');
});
it('supports unix timestamp strings', () => {
expect(formatDateTimeMinuteLocal('1709640000', 'en-US', 'UTC')).toBe('03/05/2024, 12:00');
});
});
-106
View File
@@ -1,106 +0,0 @@
function parseServerUtcDate(value: string | null | undefined): { date: Date | null; raw: string } {
if (!value) return { date: null, raw: '' };
const raw = String(value).trim();
if (!raw) return { date: null, raw };
if (/^\d{10,13}$/.test(raw)) {
const asNumber = Number(raw);
if (Number.isFinite(asNumber)) {
const epochMs = raw.length === 13 ? asNumber : asNumber * 1000;
const date = new Date(epochMs);
if (!Number.isNaN(date.getTime())) {
return { date, raw };
}
}
}
let normalized = raw;
if (!normalized.includes('T') && normalized.includes(' ')) {
normalized = normalized.replace(' ', 'T');
}
normalized = normalized.replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
normalized = normalized.replace(/([+-]\d{2})$/, '$1:00');
const hasTimeZone = /[zZ]$|[+-]\d{2}:\d{2}$/.test(normalized);
if (!hasTimeZone) {
normalized = `${normalized}Z`;
}
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return { date: null, raw };
}
return { date, raw };
}
export function parseServerUtcDateTime(value: string | null | undefined): Date | null {
return parseServerUtcDate(value).date;
}
function formatWithParts(
value: string | null | undefined,
options: Intl.DateTimeFormatOptions,
locale = 'zh-CN',
timeZone?: string,
): string {
const { date, raw } = parseServerUtcDate(value);
if (!date) return raw || '-';
return new Intl.DateTimeFormat(locale, {
...options,
...(timeZone ? { timeZone } : {}),
}).format(date);
}
export function formatDateTimeLocal(
value: string | null | undefined,
locale = 'zh-CN',
timeZone?: string,
): string {
return formatWithParts(value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}, locale, timeZone);
}
export function formatDateTimeMinuteLocal(
value: string | null | undefined,
locale = 'zh-CN',
timeZone?: string,
): string {
return formatWithParts(value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}, locale, timeZone);
}
export function formatDateLocal(
value: string | null | undefined,
locale = 'zh-CN',
timeZone?: string,
): string {
return formatWithParts(value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}, locale, timeZone);
}
export function formatCheckinLogTime(
value: string | null | undefined,
locale?: string,
timeZone?: string,
): string {
return formatDateTimeLocal(value, locale, timeZone);
}
@@ -1,83 +0,0 @@
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);
expect(isConversationUploadedFileSupported(claudeCapability, {
filename: 'paper.pdf',
mimeType: 'application/octet-stream',
})).toBe(true);
});
});
@@ -1,128 +0,0 @@
import type { PlaygroundProtocol } from './modelTesterSession.js';
import {
buildConversationAcceptList,
detectConversationFileKind,
} from '../../../shared/conversationFileTypes.js';
export type ConversationFileTransportMode = 'native' | 'inline_only' | 'unsupported';
export type ConversationFileCapability = {
supported: boolean;
imageMode: ConversationFileTransportMode;
audioMode: ConversationFileTransportMode;
documentMode: ConversationFileTransportMode;
reason: string;
};
type ConversationFileDescriptor = {
filename?: string | null;
mimeType?: string | null;
};
const isSupportedMode = (mode: ConversationFileTransportMode): boolean => mode !== 'unsupported';
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 {
return buildConversationAcceptList({
document: isSupportedMode(capability.documentMode),
image: isSupportedMode(capability.imageMode),
audio: isSupportedMode(capability.audioMode),
});
}
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 = detectConversationFileKind(file);
if (kind === 'document') return isSupportedMode(capability.documentMode);
if (kind === 'image') return isSupportedMode(capability.imageMode);
if (kind === 'audio') return isSupportedMode(capability.audioMode);
return false;
}
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveInitialConnectionSegment } from './defaultConnectionSegment.js';
describe('defaultConnectionSegment', () => {
it('maps management-style platforms to the session segment', () => {
expect(resolveInitialConnectionSegment('new-api')).toBe('session');
expect(resolveInitialConnectionSegment('one-api')).toBe('session');
expect(resolveInitialConnectionSegment('anyrouter')).toBe('session');
expect(resolveInitialConnectionSegment('veloera')).toBe('session');
expect(resolveInitialConnectionSegment('one-hub')).toBe('session');
expect(resolveInitialConnectionSegment('done-hub')).toBe('session');
expect(resolveInitialConnectionSegment('sub2api')).toBe('session');
expect(resolveInitialConnectionSegment('codex')).toBe('session');
});
it('maps API-key-only platforms to the apikey segment', () => {
expect(resolveInitialConnectionSegment('openai')).toBe('apikey');
expect(resolveInitialConnectionSegment('claude')).toBe('apikey');
expect(resolveInitialConnectionSegment('gemini')).toBe('apikey');
expect(resolveInitialConnectionSegment('cliproxyapi')).toBe('apikey');
});
it('falls back to the apikey segment for unknown platforms', () => {
expect(resolveInitialConnectionSegment('')).toBe('apikey');
expect(resolveInitialConnectionSegment('unknown-platform')).toBe('apikey');
});
});
@@ -1,26 +0,0 @@
export type InitialConnectionSegment = 'session' | 'apikey';
const SESSION_FIRST_PLATFORMS = new Set([
'new-api',
'one-api',
'anyrouter',
'veloera',
'one-hub',
'done-hub',
'sub2api',
'codex',
]);
const API_KEY_FIRST_PLATFORMS = new Set([
'openai',
'claude',
'gemini',
'cliproxyapi',
]);
export function resolveInitialConnectionSegment(platform?: string | null): InitialConnectionSegment {
const normalized = String(platform || '').trim().toLowerCase();
if (SESSION_FIRST_PLATFORMS.has(normalized)) return 'session';
if (API_KEY_FIRST_PLATFORMS.has(normalized)) return 'apikey';
return 'apikey';
}
-22
View File
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { fuzzyMatch } from './fuzzySearch.js';
describe('fuzzyMatch', () => {
it('matches plain substring case-insensitively', () => {
expect(fuzzyMatch('claude-opus-4-6', 'OPUS')).toBe(true);
expect(fuzzyMatch('gpt-4o-mini', 'gpt')).toBe(true);
});
it('matches after removing separators', () => {
expect(fuzzyMatch('gpt-5.2-codex', 'gpt52')).toBe(true);
expect(fuzzyMatch('re:^claude-(opus|sonnet)-4-5$', 'claude45')).toBe(true);
});
it('supports subsequence fuzzy matching', () => {
expect(fuzzyMatch('cerebras-llama-3.1-8b', 'cll318')).toBe(true);
});
it('returns false when query cannot be matched', () => {
expect(fuzzyMatch('claude-opus-4-6', 'gemini')).toBe(false);
});
});
-32
View File
@@ -1,32 +0,0 @@
function normalize(value: string): string {
return (value || '').toLowerCase().trim();
}
function compact(value: string): string {
return normalize(value).replace(/[^a-z0-9]/g, '');
}
function isSubsequence(text: string, query: string): boolean {
if (!query) return true;
let qi = 0;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === query[qi]) qi += 1;
if (qi >= query.length) return true;
}
return false;
}
export function fuzzyMatch(text: string, query: string): boolean {
const q = normalize(query);
if (!q) return true;
const t = normalize(text);
if (t.includes(q)) return true;
const compactText = compact(t);
const compactQuery = compact(q);
if (!compactQuery) return true;
if (compactText.includes(compactQuery)) return true;
return isSubsequence(compactText, compactQuery);
}
@@ -1,6 +0,0 @@
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}`;
}
-61
View File
@@ -1,61 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './listSorting.js';
type Item = {
id: number;
isPinned?: boolean | null;
sortOrder?: number | null;
balance?: number | null;
};
function ids(items: Item[]): number[] {
return items.map((item) => item.id);
}
describe('sortItemsForDisplay', () => {
const base: Item[] = [
{ id: 1, isPinned: false, sortOrder: 2, balance: 5 },
{ id: 2, isPinned: true, sortOrder: 1, balance: 1 },
{ id: 3, isPinned: false, sortOrder: 0, balance: 20 },
{ id: 4, isPinned: true, sortOrder: 0, balance: 10 },
];
it('keeps pinned items first in custom mode', () => {
const mode: SortMode = 'custom';
const sorted = sortItemsForDisplay(base, mode, (item) => item.balance || 0);
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
});
it('sorts by balance desc while keeping pinned items first', () => {
const sorted = sortItemsForDisplay(base, 'balance-desc', (item) => item.balance || 0);
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
});
it('sorts by balance asc while keeping pinned items first', () => {
const sorted = sortItemsForDisplay(base, 'balance-asc', (item) => item.balance || 0);
expect(ids(sorted)).toEqual([2, 4, 1, 3]);
});
});
describe('buildCustomReorderUpdates', () => {
const list: Item[] = [
{ id: 10, isPinned: true, sortOrder: 0 },
{ id: 11, isPinned: true, sortOrder: 1 },
{ id: 20, isPinned: false, sortOrder: 0 },
{ id: 21, isPinned: false, sortOrder: 1 },
];
it('reorders only inside the same pinned group', () => {
const updates = buildCustomReorderUpdates(list, 20, 'up');
// First unpinned item cannot move above pinned group.
expect(updates).toEqual([]);
});
it('returns normalized sortOrder updates after moving down', () => {
const updates = buildCustomReorderUpdates(list, 20, 'down');
expect(updates).toEqual([
{ id: 21, sortOrder: 0 },
{ id: 20, sortOrder: 1 },
]);
});
});
-76
View File
@@ -1,76 +0,0 @@
export type SortMode = 'custom' | 'balance-desc' | 'balance-asc';
type SortableBase = {
id: number;
isPinned?: boolean | null;
sortOrder?: number | null;
};
export function sortItemsForDisplay<T extends SortableBase>(
items: T[],
mode: SortMode,
getBalance: (item: T) => number,
): T[] {
const list = [...items];
const customComparator = (a: T, b: T) => {
const aPinned = a.isPinned ? 1 : 0;
const bPinned = b.isPinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aOrder = Number.isFinite(a.sortOrder as number) ? Number(a.sortOrder) : Number.MAX_SAFE_INTEGER;
const bOrder = Number.isFinite(b.sortOrder as number) ? Number(b.sortOrder) : Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.id - b.id;
};
if (mode === 'custom') {
return list.sort(customComparator);
}
return list.sort((a, b) => {
const aPinned = a.isPinned ? 1 : 0;
const bPinned = b.isPinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aBalance = Number.isFinite(getBalance(a)) ? getBalance(a) : 0;
const bBalance = Number.isFinite(getBalance(b)) ? getBalance(b) : 0;
if (aBalance !== bBalance) {
return mode === 'balance-desc' ? bBalance - aBalance : aBalance - bBalance;
}
return customComparator(a, b);
});
}
export function buildCustomReorderUpdates<T extends SortableBase>(
items: T[],
targetId: number,
direction: 'up' | 'down',
): Array<{ id: number; sortOrder: number }> {
const sorted = sortItemsForDisplay(items, 'custom', () => 0);
const target = sorted.find((item) => item.id === targetId);
if (!target) return [];
const targetPinned = !!target.isPinned;
const group = sorted.filter((item) => !!item.isPinned === targetPinned);
const index = group.findIndex((item) => item.id === targetId);
if (index < 0) return [];
const swapIndex = direction === 'up' ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= group.length) return [];
const next = [...group];
const temp = next[index];
next[index] = next[swapIndex];
next[swapIndex] = temp;
const updates: Array<{ id: number; sortOrder: number }> = [];
next.forEach((item, idx) => {
const prev = Number.isFinite(item.sortOrder as number) ? Number(item.sortOrder) : Number.MAX_SAFE_INTEGER;
if (prev !== idx) {
updates.push({ id: item.id, sortOrder: idx });
}
});
return updates;
}
@@ -1,937 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import * as modelTesterSessionModule from './modelTesterSession.js';
import {
DEBUG_TABS,
DEFAULT_INPUTS,
DEFAULT_MODE_STATE,
DEFAULT_PARAMETER_ENABLED,
MODEL_TESTER_SESSION_VERSION,
MESSAGE_STATUS,
buildApiPayload,
buildEmbeddingsRequestEnvelope,
buildFileUploadRequestEnvelope,
buildGeminiNativeConversationProxyEnvelope,
buildRawProxyRequestEnvelope,
buildSearchRequestEnvelope,
attachForcedChannelToEnvelope,
collectModelTesterModelNames,
countConversationTurns,
createConversationUserMessage,
extractConversationUploadedFilesFromMessage,
filterModelTesterModelNames,
parseCustomRequestBody,
parseModelTesterSession,
serializeModelTesterSession,
syncMessagesToCustomRequestBody,
toApiMessages,
type ChatMessage,
type ModelTesterSessionState,
} from './modelTesterSession.js';
describe('modelTesterSession', () => {
it('counts only user messages as turns', () => {
const turns = countConversationTurns([
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
{ id: '2', role: 'assistant', content: 'hi', createAt: 2 },
{ id: '3', role: 'system', content: 'meta', createAt: 3 },
{ id: '4', role: 'user', content: 'again', createAt: 4 },
]);
expect(turns).toBe(2);
});
it('serializes and parses full playground session state', () => {
const state: ModelTesterSessionState = {
version: MODEL_TESTER_SESSION_VERSION,
input: 'draft',
inputs: {
...DEFAULT_INPUTS,
mode: 'search',
protocol: 'gemini',
targetFormat: 'gemini',
model: 'gemini-2.5-pro',
systemPrompt: 'be concise',
temperature: 0.6,
top_p: 0.9,
max_tokens: 2048,
frequency_penalty: 0.1,
presence_penalty: 0.2,
seed: 12,
stream: true,
searchMaxResults: 7,
},
parameterEnabled: {
...DEFAULT_PARAMETER_ENABLED,
max_tokens: true,
seed: true,
},
messages: [
{ id: 'm1', role: 'user', content: 'hello', createAt: 1 },
{ id: 'm2', role: 'assistant', content: 'hi', createAt: 2, status: MESSAGE_STATUS.COMPLETE },
],
pendingPayload: {
method: 'POST',
path: '/v1/search',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: false,
forcedChannelId: 44,
jsonBody: { model: '__search', query: 'hello', max_results: 7 },
},
pendingJobId: 'job-1',
forcedChannelId: 44,
customRequestMode: true,
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',
searchAllowedDomains: 'openai.com, google.com',
},
};
const serialized = serializeModelTesterSession(state);
const restored = parseModelTesterSession(serialized);
expect(restored).toEqual(state);
});
it('supports parsing legacy session format into conversation/openai defaults', () => {
const restored = parseModelTesterSession(JSON.stringify({
model: 'gpt-4o',
temperature: 0.5,
input: 'legacy',
messages: [{ role: 'user', content: 'hello' }],
pendingPayload: null,
}));
expect(restored?.inputs.model).toBe('gpt-4o');
expect(restored?.inputs.protocol).toBe('openai');
expect(restored?.inputs.mode).toBe('conversation');
expect(restored?.inputs.temperature).toBe(0.5);
expect(restored?.parameterEnabled).toEqual(DEFAULT_PARAMETER_ENABLED);
});
it('returns null for malformed or missing session payload', () => {
expect(parseModelTesterSession(null)).toBeNull();
expect(parseModelTesterSession('not-json')).toBeNull();
expect(parseModelTesterSession(JSON.stringify({ messages: [] }))).toBeNull();
});
it('drops loading assistant placeholders when building API payload messages', () => {
const payloadMessages = toApiMessages([
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
{ id: '2', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING },
{ id: '3', role: 'assistant', content: 'done', createAt: 3, status: MESSAGE_STATUS.COMPLETE },
]);
expect(payloadMessages).toEqual([
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'done' },
]);
});
it('builds conversation payload as generic proxy envelope', () => {
const payload = buildApiPayload(
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
{
...DEFAULT_INPUTS,
model: 'gpt-4o-mini',
protocol: 'openai',
systemPrompt: 'You are helpful.',
temperature: 0.5,
top_p: 0.8,
max_tokens: 200,
frequency_penalty: 0.2,
presence_penalty: 0.3,
seed: 42,
stream: true,
},
{
temperature: true,
top_p: false,
max_tokens: true,
frequency_penalty: true,
presence_penalty: false,
seed: true,
},
);
expect(payload).toEqual({
method: 'POST',
path: '/v1/chat/completions',
requestKind: 'json',
stream: true,
jobMode: false,
rawMode: false,
jsonBody: {
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'hello' },
],
stream: true,
temperature: 0.5,
max_tokens: 200,
frequency_penalty: 0.2,
seed: 42,
},
});
});
it('attaches a forced channel id to tester envelopes without mutating the request body', () => {
const base = buildEmbeddingsRequestEnvelope('hello', {
...DEFAULT_INPUTS,
model: 'text-embedding-3-small',
});
const payload = attachForcedChannelToEnvelope(base, 42);
expect(payload).toEqual({
method: 'POST',
path: '/v1/embeddings',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: false,
forcedChannelId: 42,
jsonBody: {
model: 'text-embedding-3-small',
input: 'hello',
},
});
});
it('builds multipart upload envelopes for /v1/files', () => {
expect(buildFileUploadRequestEnvelope({
name: 'paper.pdf',
mimeType: 'application/pdf',
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
})).toEqual({
method: 'POST',
path: '/v1/files',
requestKind: 'multipart',
stream: false,
jobMode: false,
rawMode: false,
multipartFields: {
purpose: 'assistants',
},
multipartFiles: [
{
field: 'file',
name: 'paper.pdf',
mimeType: 'application/pdf',
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
},
],
});
});
it('creates user conversation messages that preserve uploaded file references', () => {
const message = createConversationUserMessage('请总结附件', [
{
fileId: 'file-metapi-123',
filename: 'paper.pdf',
mimeType: 'application/pdf',
},
]);
expect(message.role).toBe('user');
expect(message.content).toBe('请总结附件');
expect(message.parts).toEqual([
{
type: 'input_file',
fileId: 'file-metapi-123',
filename: 'paper.pdf',
mimeType: 'application/pdf',
},
]);
});
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(
[{
id: 'u1',
role: 'user',
content: '请总结上传文件',
createAt: 1,
parts: [
{
type: 'input_file',
fileId: 'file_123',
filename: 'notes.txt',
mimeType: 'text/plain',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'gpt-5',
protocol: 'responses',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
model: 'gpt-5',
input: [
{
role: 'user',
content: [
{ type: 'input_text', text: '请总结上传文件' },
{ type: 'input_file', file_id: 'file_123', filename: 'notes.txt' },
],
},
],
stream: false,
temperature: 0.7,
});
});
it('serializes assistant history as output_text for responses payloads', () => {
const payload = buildApiPayload(
[
{ id: 'u1', role: 'user', content: '先看附件', createAt: 1 },
{ id: 'a1', role: 'assistant', content: '我已经看过摘要', createAt: 2 },
],
{
...DEFAULT_INPUTS,
model: 'gpt-5.2',
protocol: 'responses',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
model: 'gpt-5.2',
input: [
{
role: 'user',
content: [
{ type: 'input_text', text: '先看附件' },
],
},
{
role: 'assistant',
content: [
{ type: 'output_text', text: '我已经看过摘要' },
],
},
],
stream: false,
temperature: 0.7,
});
});
it('builds gemini conversation envelope with generationConfig', () => {
const payload = buildApiPayload(
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
{
...DEFAULT_INPUTS,
model: 'gemini-2.5-pro',
protocol: 'gemini',
systemPrompt: 'system text',
temperature: 0.2,
max_tokens: 300,
},
{
...DEFAULT_PARAMETER_ENABLED,
max_tokens: true,
},
);
expect(payload.path).toBe('/v1beta/models/gemini-2.5-pro:generateContent');
expect(payload.jsonBody).toEqual({
systemInstruction: { parts: [{ text: 'system text' }] },
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: 300 },
});
});
it('builds gemini native proxy envelope without loading assistant placeholders', () => {
const payload = buildGeminiNativeConversationProxyEnvelope(
[
{ id: 'u1', role: 'user', content: 'hello', createAt: 1 },
{ id: 'a1', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING },
{ id: 'a2', role: 'assistant', content: 'partial', createAt: 3, status: MESSAGE_STATUS.INCOMPLETE },
{ id: 'a3', role: 'assistant', content: 'done', createAt: 4, status: MESSAGE_STATUS.COMPLETE },
],
{
...DEFAULT_INPUTS,
model: 'gemini-2.5-pro',
protocol: 'gemini',
systemPrompt: 'system text',
stream: true,
},
{
...DEFAULT_PARAMETER_ENABLED,
max_tokens: true,
},
);
expect(payload.path).toBe('/gemini/v1beta/models/gemini-2.5-pro:generateContent?alt=sse');
expect(payload.jobMode).toBe(false);
expect(payload.jsonBody).toEqual({
systemInstruction: { parts: [{ text: 'system text' }] },
contents: [
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'done' }] },
],
generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
});
});
it('serializes conversation file parts into openai and responses payloads', () => {
const message = {
id: 'u1',
role: 'user' as const,
content: 'summarize this',
createAt: 1,
parts: [
{
type: 'input_file' as const,
fileId: 'file_metapi_123',
filename: 'paper.pdf',
mimeType: 'application/pdf',
},
],
};
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: 'summarize this' },
{
type: 'file',
file: {
file_id: 'file_metapi_123',
filename: 'paper.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: 'summarize this' },
{
type: 'input_file',
file_id: 'file_metapi_123',
filename: 'paper.pdf',
},
],
},
],
stream: false,
temperature: 0.7,
});
});
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',
path: '/v1/embeddings',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: false,
jsonBody: {
model: 'text-embedding-3-large',
input: 'hello',
},
});
expect(buildSearchRequestEnvelope(
{ ...DEFAULT_INPUTS, model: '__search', searchMaxResults: 3 },
{ ...DEFAULT_MODE_STATE, searchQuery: 'what is ai', searchAllowedDomains: 'openai.com', searchBlockedDomains: 'example.com' },
)).toEqual({
method: 'POST',
path: '/v1/search',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: false,
jsonBody: {
model: '__search',
query: 'what is ai',
max_results: 3,
allowed_domains: ['openai.com'],
blocked_domains: ['example.com'],
},
});
});
it('parses raw custom body without dropping unknown fields', () => {
const parsed = parseCustomRequestBody('{"model":"gpt-5","include":["foo"],"reasoning":{"effort":"high"}}');
expect(parsed).toEqual({
model: 'gpt-5',
include: ['foo'],
reasoning: { effort: 'high' },
});
});
it('syncs messages into custom request body while preserving unknown fields', () => {
const synced = syncMessagesToCustomRequestBody(
'{"model":"legacy","metadata":{"trace":"keep"}}',
[{ id: '1', role: 'user', content: 'new', createAt: 1 }],
{ ...DEFAULT_INPUTS, model: 'gpt-4o', protocol: 'responses', systemPrompt: 'system' },
);
expect(JSON.parse(synced)).toEqual({
model: 'gpt-4o',
metadata: { trace: 'keep' },
input: 'new',
instructions: 'system',
stream: false,
temperature: 0.7,
});
});
it('builds raw proxy envelope for passthrough mode', () => {
expect(buildRawProxyRequestEnvelope('POST', '/v1/responses', 'json', '{"foo":1}', { stream: true })).toEqual({
method: 'POST',
path: '/v1/responses',
requestKind: 'json',
stream: true,
jobMode: false,
rawMode: true,
rawJsonText: '{"foo":1}',
});
});
it('merges marketplace models with exact enabled route models for tester options', () => {
const modelNames = collectModelTesterModelNames(
{
models: [
{ name: 'gpt-4o-mini' },
{ name: 'bge-large-en-v1.5' },
],
},
[
{ modelPattern: 'BAAI/bge-large-en-v1.5', enabled: true },
{ modelPattern: 'claude-*', enabled: true },
{ modelPattern: 'gemini-2.5-pro', enabled: false },
],
);
expect(modelNames).toEqual([
'gpt-4o-mini',
'bge-large-en-v1.5',
'BAAI/bge-large-en-v1.5',
]);
});
it('filters models by keyword and keeps best matches first', () => {
const filtered = filterModelTesterModelNames(
[
'BAAI/bge-large-en-v1.5',
'text-embedding-3-large',
'bge-m3',
],
'bge',
);
expect(filtered).toEqual([
'bge-m3',
'BAAI/bge-large-en-v1.5',
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,85 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
mergeMarketplaceMetadata,
shouldHydrateMarketplaceMetadata,
type MarketplaceModelMetadataShape,
} from './modelsMarketplaceMetadata.js';
interface TestModel extends MarketplaceModelMetadataShape {
name: string;
accountCount: number;
}
function createModel(overrides: Partial<TestModel>): TestModel {
return {
name: 'gpt-4o',
accountCount: 1,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
...overrides,
};
}
describe('modelsMarketplaceMetadata', () => {
it('identifies when metadata hydration is needed', () => {
const models = [
createModel({ name: 'gpt-4o', description: 'base model' }),
createModel({ name: 'claude-sonnet-4' }),
];
expect(shouldHydrateMarketplaceMetadata(models)).toBe(true);
});
it('identifies when metadata is already complete', () => {
const models = [
createModel({
name: 'gpt-4o',
description: 'base model',
tags: ['chat'],
supportedEndpointTypes: ['chat'],
pricingSources: [{ source: 'site-a' }],
}),
createModel({
name: 'claude-sonnet-4',
description: 'anthropic model',
tags: ['reasoning'],
supportedEndpointTypes: ['chat'],
pricingSources: [{ source: 'site-b' }],
}),
];
expect(shouldHydrateMarketplaceMetadata(models)).toBe(false);
});
it('merges metadata into current models without overriding non-metadata fields', () => {
const base = [
createModel({ name: 'GPT-4O', accountCount: 3 }),
createModel({ name: 'claude-sonnet-4', accountCount: 5 }),
];
const detail = [
createModel({
name: 'gpt-4o',
accountCount: 99,
description: 'OpenAI flagship model',
tags: ['openai'],
supportedEndpointTypes: ['chat', 'responses'],
pricingSources: [{ source: 'site-a' }],
}),
];
const merged = mergeMarketplaceMetadata(base, detail);
expect(merged).toEqual([
{
...base[0],
description: 'OpenAI flagship model',
tags: ['openai'],
supportedEndpointTypes: ['chat', 'responses'],
pricingSources: [{ source: 'site-a' }],
},
base[1],
]);
});
});
@@ -1,46 +0,0 @@
export interface MarketplaceModelMetadataShape {
description: string | null;
tags: string[];
supportedEndpointTypes: string[];
pricingSources: unknown[];
}
interface MarketplaceModelLike extends MarketplaceModelMetadataShape {
name: string;
}
function normalizeModelName(name: string): string {
return name.trim().toLowerCase();
}
function hasMarketplaceMetadata(model: MarketplaceModelMetadataShape): boolean {
if (typeof model.description === 'string' && model.description.trim().length > 0) return true;
if (Array.isArray(model.tags) && model.tags.length > 0) return true;
if (Array.isArray(model.supportedEndpointTypes) && model.supportedEndpointTypes.length > 0) return true;
if (Array.isArray(model.pricingSources) && model.pricingSources.length > 0) return true;
return false;
}
export function shouldHydrateMarketplaceMetadata<T extends MarketplaceModelMetadataShape>(models: T[]): boolean {
return models.some((model) => !hasMarketplaceMetadata(model));
}
export function mergeMarketplaceMetadata<T extends MarketplaceModelLike>(baseModels: T[], detailedModels: T[]): T[] {
const detailedByName = new Map<string, T>();
for (const model of detailedModels) {
detailedByName.set(normalizeModelName(model.name), model);
}
return baseModels.map((model) => {
const detail = detailedByName.get(normalizeModelName(model.name));
if (!detail) return model;
return {
...model,
description: detail.description ?? null,
tags: Array.isArray(detail.tags) ? detail.tags : [],
supportedEndpointTypes: Array.isArray(detail.supportedEndpointTypes) ? detail.supportedEndpointTypes : [],
pricingSources: Array.isArray(detail.pricingSources) ? detail.pricingSources : [],
};
});
}
@@ -1,90 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildAccountFocusPath,
buildEventNavigationPath,
buildSiteFocusPath,
clearFocusParams,
readFocusAccountIntent,
readFocusAnnouncementId,
readFocusSiteId,
} from './navigationFocus.js';
describe('navigationFocus helpers', () => {
it('builds focus paths for site and account', () => {
expect(buildSiteFocusPath(12)).toBe('/sites?focusSiteId=12');
expect(buildSiteFocusPath(0)).toBe('/sites');
expect(buildAccountFocusPath(34)).toBe('/accounts?focusAccountId=34');
expect(buildAccountFocusPath(34, { openRebind: true })).toBe('/accounts?focusAccountId=34&openRebind=1');
expect(buildAccountFocusPath(-1)).toBe('/accounts');
});
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,
openRebind: true,
});
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=true')).toEqual({
accountId: 22,
openRebind: true,
});
expect(readFocusAccountIntent('?focusAccountId=22')).toEqual({
accountId: 22,
openRebind: false,
});
});
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('');
});
it('builds event navigation path by related entity', () => {
expect(buildEventNavigationPath({
relatedType: 'account',
relatedId: 18,
type: 'token',
})).toBe('/accounts?focusAccountId=18&openRebind=1');
expect(buildEventNavigationPath({
relatedType: 'account',
relatedId: 18,
type: 'checkin',
})).toBe('/accounts?focusAccountId=18');
expect(buildEventNavigationPath({
relatedType: 'site',
relatedId: 9,
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,
type: 'proxy',
})).toBe('/routes');
expect(buildEventNavigationPath({
relatedType: 'update_center',
relatedId: null,
type: 'status',
})).toBe('/settings');
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
expect(buildEventNavigationPath({})).toBe('/events');
});
});
-118
View File
@@ -1,118 +0,0 @@
import { isTruthyFlag } from './accountConnection.js';
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';
function normalizePositiveId(input: unknown): number | null {
const value = Number.parseInt(String(input ?? ''), 10);
if (!Number.isFinite(value) || value <= 0) return null;
return value;
}
export function buildSiteFocusPath(siteId: number): string {
const normalizedId = normalizePositiveId(siteId);
if (!normalizedId) return '/sites';
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' },
): string {
const normalizedId = normalizePositiveId(accountId);
if (!normalizedId) return '/accounts';
const params = new URLSearchParams();
if (options?.segment && options.segment !== 'session') params.set('segment', options.segment);
params.set(FOCUS_ACCOUNT_ID_KEY, String(normalizedId));
if (options?.openRebind) params.set(OPEN_REBIND_KEY, '1');
return `/accounts?${params.toString()}`;
}
export function buildTokenFocusPath(tokenId: number): string {
const normalizedId = normalizePositiveId(tokenId);
if (!normalizedId) return '/accounts?segment=tokens';
const params = new URLSearchParams();
params.set('segment', 'tokens');
params.set(FOCUS_TOKEN_ID_KEY, String(normalizedId));
return `/accounts?${params.toString()}`;
}
export function readFocusSiteId(search: string): number | null {
const params = new URLSearchParams(search);
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 {
accountId: normalizePositiveId(params.get(FOCUS_ACCOUNT_ID_KEY)),
openRebind: isTruthyFlag(params.get(OPEN_REBIND_KEY)),
};
}
export function readFocusTokenId(search: string): number | null {
const params = new URLSearchParams(search);
return normalizePositiveId(params.get(FOCUS_TOKEN_ID_KEY));
}
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);
const next = params.toString();
return next ? `?${next}` : '';
}
export function buildEventNavigationPath(event: {
relatedType?: string | null;
relatedId?: number | null;
type?: string | null;
}): string {
const relatedType = (event.relatedType || '').toLowerCase();
const relatedId = normalizePositiveId(event.relatedId);
const eventType = (event.type || '').toLowerCase();
if (relatedType === 'account' && relatedId) {
return buildAccountFocusPath(relatedId, { openRebind: eventType === 'token' });
}
if (relatedType === 'site' && relatedId) {
return buildSiteFocusPath(relatedId);
}
if (relatedType === 'site_announcement' && relatedId) {
return buildAnnouncementFocusPath(relatedId);
}
if (relatedType === 'update_center') {
return '/settings';
}
if (relatedType === 'route') {
return '/routes';
}
if (eventType === 'proxy') {
return '/logs';
}
if (eventType === 'checkin') {
return '/checkin';
}
if (eventType === 'site_notice') {
return '/site-announcements';
}
return '/events';
}
@@ -1,17 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getInitialVisibleCount, getNextVisibleCount } from './progressiveRender.js';
describe('progressiveRender', () => {
it('returns bounded initial visible count', () => {
expect(getInitialVisibleCount(0, 40)).toBe(0);
expect(getInitialVisibleCount(10, 40)).toBe(10);
expect(getInitialVisibleCount(120, 40)).toBe(40);
});
it('returns next visible count without exceeding total', () => {
expect(getNextVisibleCount(0, 120, 40)).toBe(40);
expect(getNextVisibleCount(40, 120, 40)).toBe(80);
expect(getNextVisibleCount(80, 120, 40)).toBe(120);
expect(getNextVisibleCount(200, 120, 40)).toBe(120);
});
});
@@ -1,17 +0,0 @@
export function getInitialVisibleCount(total: number, chunkSize: number): number {
if (!Number.isFinite(total) || total <= 0) return 0;
const normalizedChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? Math.trunc(chunkSize) : 1;
return Math.min(Math.trunc(total), normalizedChunk);
}
export function getNextVisibleCount(currentVisible: number, total: number, chunkSize: number): number {
const normalizedTotal = Number.isFinite(total) && total > 0 ? Math.trunc(total) : 0;
if (normalizedTotal <= 0) return 0;
const normalizedCurrent = Number.isFinite(currentVisible) && currentVisible > 0
? Math.trunc(currentVisible)
: 0;
const normalizedChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? Math.trunc(chunkSize) : 1;
return Math.min(normalizedTotal, normalizedCurrent + normalizedChunk);
}
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest';
import { parseProxyLogPathMeta } from './proxyLogPathMeta.js';
describe('parseProxyLogPathMeta', () => {
it('parses downstream and upstream paths from prefixed message', () => {
const parsed = parseProxyLogPathMeta('[downstream:/v1/responses] [upstream:/v1/chat/completions] {"error":"x"}');
expect(parsed.downstreamPath).toBe('/v1/responses');
expect(parsed.upstreamPath).toBe('/v1/chat/completions');
expect(parsed.errorMessage).toBe('{"error":"x"}');
});
it('supports historical upstream-only logs', () => {
const parsed = parseProxyLogPathMeta('[upstream:/v1/messages] messages is required');
expect(parsed.downstreamPath).toBe(null);
expect(parsed.upstreamPath).toBe('/v1/messages');
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);
expect(parsed.upstreamPath).toBe(null);
expect(parsed.errorMessage).toBe('network timeout');
});
});
-24
View File
@@ -1,24 +0,0 @@
import { parseProxyLogMetadata, type ParsedProxyLogMetadata } from '../../../shared/proxyLogMeta.js';
type ProxyLogPathMeta = {
clientFamily: string | null;
sessionId: string | null;
downstreamPath: string | null;
upstreamPath: string | null;
usageSource: ParsedProxyLogMetadata['usageSource'];
errorMessage: string;
};
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
const raw = typeof message === 'string' ? message.trim() : '';
const parsed = parseProxyLogMetadata(raw);
return {
clientFamily: parsed.clientKind,
sessionId: parsed.sessionId,
downstreamPath: parsed.downstreamPath,
upstreamPath: parsed.upstreamPath,
usageSource: parsed.usageSource,
errorMessage: parsed.messageText,
};
}
@@ -1,64 +0,0 @@
import { normalizeTokenRouteMode } from '../../../shared/tokenRouteContract.js';
export type RouteListVisibilityItem = {
id: number;
modelPattern: string;
displayName?: string | null;
routeMode?: string | null;
sourceRouteIds?: number[];
enabled: boolean;
};
function normalizeRouteMode(routeMode: string | null | undefined): 'pattern' | 'explicit_group' {
return normalizeTokenRouteMode(routeMode);
}
function isExplicitGroupRoute(route: Pick<RouteListVisibilityItem, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
function hasCustomDisplayName(route: Pick<RouteListVisibilityItem, 'modelPattern' | 'displayName'>): boolean {
const displayName = (route.displayName || '').trim();
const modelPattern = (route.modelPattern || '').trim();
return !!displayName && displayName !== modelPattern;
}
export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
routes: T[],
isExactModelPattern: (pattern: string) => boolean,
matchesModelPattern: (model: string, pattern: string) => boolean,
): T[] {
const exactModelNames = new Set(
routes
.filter((route) => !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
.map((route) => (route.modelPattern || '').trim())
.filter(Boolean),
);
const coveringGroups = routes.filter((route) => (
route.enabled
&& (
(isExplicitGroupRoute(route) && ((route.displayName || '').trim().length > 0) && (route.sourceRouteIds || []).length > 0)
|| (!isExplicitGroupRoute(route) && !isExactModelPattern(route.modelPattern) && hasCustomDisplayName(route))
)
));
if (coveringGroups.length === 0) return routes;
return routes.filter((route) => {
if (isExplicitGroupRoute(route)) return true;
if (!isExactModelPattern(route.modelPattern)) return true;
if (hasCustomDisplayName(route)) return true;
const exactModel = (route.modelPattern || '').trim();
if (!exactModel) return true;
return !coveringGroups.some((groupRoute) => (
groupRoute.id !== route.id
&& !exactModelNames.has((groupRoute.displayName || '').trim())
&& (
(isExplicitGroupRoute(groupRoute) && (groupRoute.sourceRouteIds || []).includes(route.id))
|| (!isExplicitGroupRoute(groupRoute) && matchesModelPattern(exactModel, groupRoute.modelPattern))
)
));
});
}
@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildRouteMissingTokenIndex,
normalizeMissingTokenModels,
type MissingTokenModelsByName,
} from './routeMissingTokenHints.js';
function matchesModelPattern(model: string, pattern: string): boolean {
if (!pattern) return false;
if (pattern === '*') return true;
if (pattern.endsWith('*')) return model.startsWith(pattern.slice(0, -1));
return model === pattern;
}
describe('buildRouteMissingTokenIndex', () => {
it('matches missing-token models by route pattern and deduplicates accounts', () => {
const routes = [
{ id: 1, modelPattern: 'claude-*' },
{ id: 2, modelPattern: 'gpt-4o-mini' },
{ id: 3, modelPattern: '' },
];
const missingByModel: MissingTokenModelsByName = {
'claude-opus-4-6': [
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
],
'claude-code-4-6': [
{ accountId: 22, username: 'bob', siteId: 2, siteName: 'site-b' },
],
'gpt-4o-mini': [
{ accountId: 33, username: 'charlie', siteId: 3, siteName: 'site-c' },
],
};
const index = buildRouteMissingTokenIndex(routes, missingByModel, matchesModelPattern);
expect(index[1].map((item) => item.modelName)).toEqual(['claude-code-4-6', 'claude-opus-4-6']);
expect(index[1][1].accounts).toEqual([{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' }]);
expect(index[2].map((item) => item.modelName)).toEqual(['gpt-4o-mini']);
expect(index[3]).toEqual([]);
});
it('returns empty entries when missing model map is empty', () => {
const index = buildRouteMissingTokenIndex([{ id: 1, modelPattern: 'claude-*' }], {}, matchesModelPattern);
expect(index[1]).toEqual([]);
});
it('normalizes missing-token map by trimming model name and deduplicating account', () => {
const merged = normalizeMissingTokenModels({
' claude-opus-4-6 ': [
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
],
});
expect(merged['claude-opus-4-6']).toEqual([
{
accountId: 1,
username: 'alice',
siteId: 11,
siteName: 'site-a',
},
]);
});
});
@@ -1,98 +0,0 @@
export type MissingTokenModelAccount = {
accountId: number;
username: string | null;
siteId: number;
siteName: string;
missingGroups?: string[];
requiredGroups?: string[];
availableGroups?: string[];
groupCoverageUncertain?: boolean;
};
export type MissingTokenModelsByName = Record<string, MissingTokenModelAccount[]>;
export type RouteMissingTokenHint = {
modelName: string;
accounts: MissingTokenModelAccount[];
};
type RoutePatternLike = {
id: number;
modelPattern: string;
};
export function normalizeMissingTokenModels(
withoutTokenByModel: MissingTokenModelsByName,
): MissingTokenModelsByName {
const normalized: MissingTokenModelsByName = {};
for (const modelName of Object.keys(withoutTokenByModel || {})) {
const normalizedModelName = String(modelName || '').trim();
if (!normalizedModelName) continue;
const accountMap = new Map<number, MissingTokenModelAccount>();
for (const account of withoutTokenByModel[modelName] || []) {
if (!account || !Number.isFinite(account.accountId)) continue;
const accountName = (account.username || '').trim();
const siteName = String(account.siteName || '').trim();
const normalizeLabels = (labels: unknown): string[] => Array.isArray(labels)
? Array.from(new Set(labels
.map((label) => String(label || '').trim())
.filter(Boolean)))
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
: [];
accountMap.set(account.accountId, {
accountId: account.accountId,
username: accountName || null,
siteId: account.siteId,
siteName,
...(normalizeLabels(account.missingGroups).length > 0 ? { missingGroups: normalizeLabels(account.missingGroups) } : {}),
...(normalizeLabels(account.requiredGroups).length > 0 ? { requiredGroups: normalizeLabels(account.requiredGroups) } : {}),
...(normalizeLabels(account.availableGroups).length > 0 ? { availableGroups: normalizeLabels(account.availableGroups) } : {}),
...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}),
});
}
if (accountMap.size > 0) {
normalized[normalizedModelName] = Array.from(accountMap.values()).sort((a, b) => a.accountId - b.accountId);
}
}
return normalized;
}
export function buildRouteMissingTokenIndex(
routes: RoutePatternLike[],
missingByModel: MissingTokenModelsByName,
matchesModelPattern: (model: string, pattern: string) => boolean,
): Record<number, RouteMissingTokenHint[]> {
const index: Record<number, RouteMissingTokenHint[]> = {};
for (const route of routes || []) {
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern) {
index[route.id] = [];
continue;
}
const matchedHints: RouteMissingTokenHint[] = [];
for (const [modelName, accounts] of Object.entries(missingByModel || {})) {
if (!matchesModelPattern(modelName, modelPattern)) continue;
const dedupedAccounts = new Map<number, MissingTokenModelAccount>();
for (const account of accounts || []) {
if (!account || !Number.isFinite(account.accountId)) continue;
dedupedAccounts.set(account.accountId, account);
}
matchedHints.push({
modelName,
accounts: Array.from(dedupedAccounts.values()).sort((a, b) => {
const siteCompare = String(a.siteName || '').localeCompare(String(b.siteName || ''), undefined, { sensitivity: 'base' });
if (siteCompare !== 0) return siteCompare;
return a.accountId - b.accountId;
}),
});
}
matchedHints.sort((a, b) => a.modelName.localeCompare(b.modelName, undefined, { sensitivity: 'base' }));
index[route.id] = matchedHints;
}
return index;
}
@@ -1,116 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildRouteModelCandidatesIndex,
type IndexedRouteModelCandidate,
type RouteModelCandidatesByModelName,
} from './routeModelCandidatesIndex.js';
function matchesModelPattern(model: string, pattern: string): boolean {
if (!pattern) return false;
if (pattern === '*') return true;
if (pattern.endsWith('*')) return model.startsWith(pattern.slice(0, -1));
return model === pattern;
}
describe('buildRouteModelCandidatesIndex', () => {
it('precomputes route candidates, account options and token options by account', () => {
const routes = [
{ id: 1, modelPattern: 'claude-*' },
{ id: 2, modelPattern: 'gpt-4o-mini' },
];
const modelCandidates: RouteModelCandidatesByModelName = {
'claude-opus-4-6': [
{
modelName: 'will-be-overwritten',
accountId: 11,
tokenId: 101,
tokenName: 'tk-opus',
isDefault: false,
username: 'alice',
siteId: 1,
siteName: 'site-a',
},
{
modelName: 'dup',
accountId: 11,
tokenId: 101,
tokenName: 'tk-opus',
isDefault: false,
username: 'alice',
siteId: 1,
siteName: 'site-a',
},
],
'claude-sonnet-4-6': [
{
modelName: 'will-be-overwritten',
accountId: 11,
tokenId: 102,
tokenName: 'tk-sonnet',
isDefault: true,
username: 'alice',
siteId: 1,
siteName: 'site-a',
},
],
'gpt-4o-mini': [
{
modelName: 'will-be-overwritten',
accountId: 22,
tokenId: 201,
tokenName: 'tk-gpt',
isDefault: true,
username: 'bob',
siteId: 2,
siteName: 'site-b',
},
],
};
const index = buildRouteModelCandidatesIndex(routes, modelCandidates, matchesModelPattern);
expect(index[1]).toBeTruthy();
expect(index[2]).toBeTruthy();
const route1Candidates = index[1].routeCandidates;
expect(route1Candidates).toHaveLength(2);
expect(route1Candidates.map((item) => item.modelName).sort()).toEqual([
'claude-opus-4-6',
'claude-sonnet-4-6',
]);
expect(index[1].accountOptions).toEqual([{ id: 11, label: 'alice @ site-a' }]);
const route1TokenOptions = index[1].tokenOptionsByAccountId[11];
expect(route1TokenOptions.map((item) => `${item.id}:${item.sourceModel}:${item.isDefault}`)).toEqual([
'102:claude-sonnet-4-6:true',
'101:claude-opus-4-6:false',
]);
expect(index[2].routeCandidates).toHaveLength(1);
expect(index[2].routeCandidates[0].modelName).toBe('gpt-4o-mini');
expect(index[2].accountOptions).toEqual([{ id: 22, label: 'bob @ site-b' }]);
});
it('returns empty structures when route model pattern is blank', () => {
const routes = [{ id: 1, modelPattern: ' ' }];
const modelCandidates: RouteModelCandidatesByModelName = {
'gpt-4o-mini': [{
modelName: 'ignored',
accountId: 1,
tokenId: 2,
tokenName: 'tk',
isDefault: false,
username: null,
siteId: 1,
siteName: 'site',
} satisfies IndexedRouteModelCandidate],
};
const index = buildRouteModelCandidatesIndex(routes, modelCandidates, matchesModelPattern);
expect(index[1].routeCandidates).toEqual([]);
expect(index[1].accountOptions).toEqual([]);
expect(index[1].tokenOptionsByAccountId).toEqual({});
});
});
@@ -1,118 +0,0 @@
export type IndexedRouteModelCandidate = {
modelName: string;
accountId: number;
tokenId: number;
tokenName: string;
isDefault: boolean;
username: string | null;
siteId: number;
siteName: string;
};
export type RouteModelCandidatesByModelName = Record<string, IndexedRouteModelCandidate[]>;
export type RouteAccountOption = {
id: number;
label: string;
};
export type RouteTokenOption = {
id: number;
name: string;
isDefault: boolean;
sourceModel?: string;
};
export type RouteCandidateView = {
routeCandidates: IndexedRouteModelCandidate[];
accountOptions: RouteAccountOption[];
tokenOptionsByAccountId: Record<number, RouteTokenOption[]>;
};
export type RouteModelPatternLike = {
id: number;
modelPattern: string;
routeMode?: string | null;
};
const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
routeCandidates: [],
accountOptions: [],
tokenOptionsByAccountId: {},
};
export function buildRouteModelCandidatesIndex(
routes: RouteModelPatternLike[],
modelCandidates: RouteModelCandidatesByModelName,
matchesModelPattern: (model: string, pattern: string) => boolean,
): Record<number, RouteCandidateView> {
const index: Record<number, RouteCandidateView> = {};
for (const route of routes || []) {
if (route.routeMode === 'explicit_group') {
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
continue;
}
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern) {
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
continue;
}
const deduped = new Map<string, IndexedRouteModelCandidate>();
for (const [modelName, candidates] of Object.entries(modelCandidates || {})) {
if (!matchesModelPattern(modelName, modelPattern)) continue;
for (const candidate of candidates || []) {
const key = `${candidate.tokenId}::${modelName}`;
if (!deduped.has(key)) {
deduped.set(key, {
...candidate,
modelName,
});
}
}
}
const routeCandidates = Array.from(deduped.values()).sort((a, b) => {
if (a.accountId === b.accountId) return a.tokenId - b.tokenId;
return a.accountId - b.accountId;
});
const accountMap = new Map<number, string>();
const tokenOptionsByAccountId: Record<number, RouteTokenOption[]> = {};
for (const candidate of routeCandidates) {
if (!accountMap.has(candidate.accountId)) {
accountMap.set(candidate.accountId, `${candidate.username || `account-${candidate.accountId}`} @ ${candidate.siteName}`);
}
if (!tokenOptionsByAccountId[candidate.accountId]) {
tokenOptionsByAccountId[candidate.accountId] = [];
}
tokenOptionsByAccountId[candidate.accountId].push({
id: candidate.tokenId,
name: candidate.tokenName,
isDefault: candidate.isDefault,
sourceModel: candidate.modelName,
});
}
for (const accountIdText of Object.keys(tokenOptionsByAccountId)) {
const accountId = Number.parseInt(accountIdText, 10);
tokenOptionsByAccountId[accountId].sort((a, b) => {
if (a.isDefault === b.isDefault) {
if (a.id === b.id) return (a.sourceModel || '').localeCompare(b.sourceModel || '', undefined, { sensitivity: 'base' });
return a.id - b.id;
}
return a.isDefault ? -1 : 1;
});
}
const accountOptions = Array.from(accountMap.entries()).map(([id, label]) => ({ id, label }));
index[route.id] = {
routeCandidates,
accountOptions,
tokenOptionsByAccountId,
};
}
return index;
}
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
ROUTING_PROFILE_PRESETS,
applyRoutingProfilePreset,
resolveRoutingProfilePreset,
} from './routingProfiles.js';
describe('routingProfiles', () => {
it('applies stable preset weights', () => {
const stable = applyRoutingProfilePreset('stable');
expect(stable).toEqual(ROUTING_PROFILE_PRESETS.stable);
});
it('resolves known profile by exact weights', () => {
const preset = resolveRoutingProfilePreset(ROUTING_PROFILE_PRESETS.cost);
expect(preset).toBe('cost');
});
it('returns custom when weights do not match presets', () => {
const preset = resolveRoutingProfilePreset({
baseWeightFactor: 0.61,
valueScoreFactor: 0.39,
costWeight: 0.22,
balanceWeight: 0.41,
usageWeight: 0.37,
});
expect(preset).toBe('custom');
});
});
-58
View File
@@ -1,58 +0,0 @@
export type RoutingWeights = {
baseWeightFactor: number;
valueScoreFactor: number;
costWeight: number;
balanceWeight: number;
usageWeight: number;
};
type RoutingProfilePresetId = 'balanced' | 'stable' | 'cost' | 'custom';
export const ROUTING_PROFILE_PRESETS: Record<Exclude<RoutingProfilePresetId, 'custom'>, RoutingWeights> = {
balanced: {
baseWeightFactor: 0.5,
valueScoreFactor: 0.5,
costWeight: 0.4,
balanceWeight: 0.3,
usageWeight: 0.3,
},
stable: {
baseWeightFactor: 0.7,
valueScoreFactor: 0.3,
costWeight: 0.2,
balanceWeight: 0.6,
usageWeight: 0.2,
},
cost: {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
costWeight: 0.75,
balanceWeight: 0.15,
usageWeight: 0.1,
},
};
const EPSILON = 1e-6;
export function applyRoutingProfilePreset(presetId: Exclude<RoutingProfilePresetId, 'custom'>): RoutingWeights {
return {
...ROUTING_PROFILE_PRESETS[presetId],
};
}
function weightsAlmostEqual(a: RoutingWeights, b: RoutingWeights): boolean {
return (
Math.abs(a.baseWeightFactor - b.baseWeightFactor) <= EPSILON
&& Math.abs(a.valueScoreFactor - b.valueScoreFactor) <= EPSILON
&& Math.abs(a.costWeight - b.costWeight) <= EPSILON
&& Math.abs(a.balanceWeight - b.balanceWeight) <= EPSILON
&& Math.abs(a.usageWeight - b.usageWeight) <= EPSILON
);
}
export function resolveRoutingProfilePreset(weights: RoutingWeights): RoutingProfilePresetId {
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.balanced)) return 'balanced';
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.stable)) return 'stable';
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.cost)) return 'cost';
return 'custom';
}
-4
View File
@@ -1,4 +0,0 @@
export function shouldIgnoreRowSelectionClick(target: EventTarget | null): boolean {
const element = target as { closest?: (selector: string) => unknown } | null;
return !!element?.closest?.('button, a, input, textarea, select, label, [role="button"]');
}
@@ -1,71 +0,0 @@
import { describe, expect, it } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import {
SiteAnnouncementContent,
formatSiteAnnouncementSeenAt,
readClientTimeZone,
resolveSiteAnnouncementTimeZone,
} from './siteAnnouncementPresentation.js';
const hasDomSanitizerSupport = typeof DOMParser === 'function' && typeof Node !== 'undefined';
const itWithDomSupport = hasDomSanitizerSupport ? it : it.skip;
describe('siteAnnouncementPresentation helpers', () => {
itWithDomSupport('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"');
});
itWithDomSupport('renders markdown notices as structured content', () => {
const markup = renderToStaticMarkup(
<SiteAnnouncementContent
content={[
'# README.md',
'',
'这是一个 [接入文档](https://example.com/setup)。',
'',
'```json',
'{',
' "model": "gpt-5.4"',
'}',
'```',
].join('\n')}
/>,
);
expect(markup).toContain('<h1>README.md</h1>');
expect(markup).toContain('href="https://example.com/setup"');
expect(markup).toContain('<pre><code class="language-json">');
expect(markup).toContain('&quot;model&quot;: &quot;gpt-5.4&quot;');
});
it('formats first-seen time in the requested local timezone', () => {
expect(formatSiteAnnouncementSeenAt('2026-03-20 04:23:27', 'Asia/Shanghai')).toBe('2026/03/20 12:23:27');
});
it('prefers client timezone over server timezone', () => {
expect(resolveSiteAnnouncementTimeZone('Asia/Shanghai', 'UTC')).toBe('Asia/Shanghai');
expect(resolveSiteAnnouncementTimeZone('', 'UTC')).toBe('UTC');
});
it('reads the browser timezone when available', () => {
expect(readClientTimeZone()).toBeTruthy();
});
});
@@ -1,384 +0,0 @@
import { marked } from 'marked';
import { formatDateTimeLocal } from './checkinLogTime.js';
const VOID_TAGS = new Set(['br', 'hr', 'img']);
const DROPPED_TAGS = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
const ALLOWED_TAGS = new Set([
'a',
'b',
'blockquote',
'br',
'center',
'code',
'del',
'div',
'em',
'font',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'img',
'li',
'ol',
'p',
'pre',
'span',
'strong',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'ul',
]);
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeLineEndings(value: string): string {
return String(value || '').replace(/\r\n?/g, '\n');
}
function isLikelyHtml(content: string): boolean {
return /<([a-z][a-z0-9-]*)(?:\s[^>]*)?>/i.test(content) || /<\/([a-z][a-z0-9-]*)>/i.test(content);
}
function isLikelyMarkdown(content: string): boolean {
return /(^|\n)(#{1,6}\s+|>\s+|[-*+]\s+|\d+\.\s+|```|~~~)/.test(content)
|| /!\[[^\]]*]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|~~[^~]+~~/.test(content);
}
function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
const value = String(raw || '').trim();
if (!value) return null;
if (value.startsWith('#') || value.startsWith('/')) return value;
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
if (/^https?:\/\//i.test(value)) return value;
if (/^mailto:/i.test(value)) return value;
if (/^tel:/i.test(value)) return value;
return null;
}
function extractVisibleTextFallback(html: string): string {
return normalizeLineEndings(html)
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function renderInlineMarkdown(text: string): string {
const tokens: string[] = [];
const pushToken = (html: string) => {
const key = `\u0000${tokens.length}\u0000`;
tokens.push(html);
return key;
};
let html = escapeHtml(text);
html = html.replace(/`([^`]+)`/g, (_, code) => pushToken(`<code>${escapeHtml(code)}</code>`));
html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, alt, src, title) => {
const safeSrc = sanitizeUrl(src, true);
if (!safeSrc) return escapeHtml(String(alt || ''));
const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
return pushToken(`<img src="${escapeHtml(safeSrc)}" alt="${escapeHtml(String(alt || ''))}"${titleAttr}>`);
});
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, label, href, title) => {
const safeHref = sanitizeUrl(href, false);
if (!safeHref) return escapeHtml(String(label || ''));
const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
return pushToken(`<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${escapeHtml(String(label || ''))}</a>`);
});
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
html = html.replace(/(^|[^\*])\*([^*\n]+)\*(?=[^\*]|$)/g, '$1<em>$2</em>');
return html.replace(/\u0000(\d+)\u0000/g, (_, index) => tokens[Number(index)] || '');
}
function isMarkdownListLine(line: string, ordered: boolean): boolean {
return ordered ? /^\s*\d+\.\s+/.test(line) : /^\s*[-*+]\s+/.test(line);
}
function consumeMarkdownList(lines: string[], startIndex: number, ordered: boolean) {
const items: string[] = [];
let index = startIndex;
while (index < lines.length && isMarkdownListLine(lines[index] || '', ordered)) {
const line = lines[index] || '';
const item = ordered
? line.replace(/^\s*\d+\.\s+/, '')
: line.replace(/^\s*[-*+]\s+/, '');
items.push(`<li>${renderInlineMarkdown(item.trim())}</li>`);
index += 1;
}
const tag = ordered ? 'ol' : 'ul';
return {
html: `<${tag}>${items.join('')}</${tag}>`,
nextIndex: index,
};
}
function consumeMarkdownQuote(lines: string[], startIndex: number) {
const parts: string[] = [];
let index = startIndex;
while (index < lines.length && /^\s*>\s?/.test(lines[index] || '')) {
parts.push((lines[index] || '').replace(/^\s*>\s?/, ''));
index += 1;
}
return {
html: `<blockquote>${renderMarkdown(parts.join('\n'))}</blockquote>`,
nextIndex: index,
};
}
function consumeMarkdownFence(lines: string[], startIndex: number) {
const firstLine = String(lines[startIndex] || '');
const marker = firstLine.startsWith('~~~') ? '~~~' : '```';
const language = firstLine.slice(marker.length).trim().toLowerCase();
const codeLines: string[] = [];
let index = startIndex + 1;
while (index < lines.length && !String(lines[index] || '').startsWith(marker)) {
codeLines.push(String(lines[index] || ''));
index += 1;
}
if (index < lines.length) index += 1;
const classAttr = language && /^[a-z0-9_-]+$/i.test(language) ? ` class="language-${escapeHtml(language)}"` : '';
return {
html: `<pre><code${classAttr}>${escapeHtml(codeLines.join('\n'))}</code></pre>`,
nextIndex: index,
};
}
function consumeMarkdownParagraph(lines: string[], startIndex: number) {
const parts: string[] = [];
let index = startIndex;
while (index < lines.length) {
const line = String(lines[index] || '');
const trimmed = line.trim();
if (!trimmed) break;
if (/^#{1,6}\s+/.test(trimmed)) break;
if (/^\s*>\s?/.test(line)) break;
if (isMarkdownListLine(line, false) || isMarkdownListLine(line, true)) break;
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) break;
if (/^(-{3,}|\*{3,})$/.test(trimmed)) break;
parts.push(line);
index += 1;
}
const content = parts.map((line) => renderInlineMarkdown(line.trim())).join('<br />');
return {
html: `<p>${content}</p>`,
nextIndex: index,
};
}
function renderMarkdown(content: string): string {
const lines = normalizeLineEndings(content).split('\n');
const blocks: string[] = [];
let index = 0;
while (index < lines.length) {
const rawLine = String(lines[index] || '');
const line = rawLine.trim();
if (!line) {
index += 1;
continue;
}
if (line.startsWith('```') || line.startsWith('~~~')) {
const fenced = consumeMarkdownFence(lines, index);
blocks.push(fenced.html);
index = fenced.nextIndex;
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
blocks.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
index += 1;
continue;
}
if (/^\s*>\s?/.test(rawLine)) {
const quoted = consumeMarkdownQuote(lines, index);
blocks.push(quoted.html);
index = quoted.nextIndex;
continue;
}
if (isMarkdownListLine(rawLine, false)) {
const list = consumeMarkdownList(lines, index, false);
blocks.push(list.html);
index = list.nextIndex;
continue;
}
if (isMarkdownListLine(rawLine, true)) {
const list = consumeMarkdownList(lines, index, true);
blocks.push(list.html);
index = list.nextIndex;
continue;
}
if (/^(-{3,}|\*{3,})$/.test(line)) {
blocks.push('<hr>');
index += 1;
continue;
}
const paragraph = consumeMarkdownParagraph(lines, index);
blocks.push(paragraph.html);
index = paragraph.nextIndex;
}
return blocks.join('');
}
function sanitizeNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return escapeHtml(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const element = node as Element;
const tag = element.tagName.toLowerCase();
if (DROPPED_TAGS.has(tag)) {
return '';
}
const childrenHtml = Array.from(element.childNodes).map((child) => sanitizeNode(child)).join('');
if (!ALLOWED_TAGS.has(tag)) {
return childrenHtml;
}
const attrs: string[] = [];
if (tag === 'a') {
const safeHref = sanitizeUrl(element.getAttribute('href'));
if (safeHref) {
attrs.push(` href="${escapeHtml(safeHref)}"`);
attrs.push(' target="_blank"');
attrs.push(' rel="noopener noreferrer"');
}
const title = element.getAttribute('title');
if (title) {
attrs.push(` title="${escapeHtml(title)}"`);
}
} else if (tag === 'img') {
const safeSrc = sanitizeUrl(element.getAttribute('src'), true);
if (!safeSrc) return '';
attrs.push(` src="${escapeHtml(safeSrc)}"`);
const alt = element.getAttribute('alt');
if (alt) attrs.push(` alt="${escapeHtml(alt)}"`);
const title = element.getAttribute('title');
if (title) attrs.push(` title="${escapeHtml(title)}"`);
} else if (tag === 'code') {
const className = String(element.getAttribute('class') || '').trim();
if (/^language-[a-z0-9_-]+$/i.test(className)) {
attrs.push(` class="${escapeHtml(className)}"`);
}
} else if (tag === 'td' || tag === 'th') {
const colspan = Number.parseInt(String(element.getAttribute('colspan') || ''), 10);
const rowspan = Number.parseInt(String(element.getAttribute('rowspan') || ''), 10);
if (Number.isFinite(colspan) && colspan > 1) attrs.push(` colspan="${colspan}"`);
if (Number.isFinite(rowspan) && rowspan > 1) attrs.push(` rowspan="${rowspan}"`);
}
if (VOID_TAGS.has(tag)) {
return `<${tag}${attrs.join('')}>`;
}
return `<${tag}${attrs.join('')}>${childrenHtml}</${tag}>`;
}
function sanitizeAnnouncementHtml(html: string): string {
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
return renderPlainText(extractVisibleTextFallback(html));
}
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
return Array.from(doc.body.childNodes).map((node) => sanitizeNode(node)).join('');
}
function renderPlainText(content: string): string {
const paragraphs = normalizeLineEndings(content)
.split(/\n{2,}/)
.map((part) => part.trim())
.filter(Boolean);
if (paragraphs.length === 0) {
return `<p>${escapeHtml(String(content || '').trim())}</p>`;
}
return paragraphs
.map((part) => `<p>${escapeHtml(part).replace(/\n/g, '<br />')}</p>`)
.join('');
}
export function readClientTimeZone(): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
} catch {
return undefined;
}
}
export function resolveSiteAnnouncementTimeZone(
clientTimeZone?: string | null,
serverTimeZone?: string | null,
): string | undefined {
const client = String(clientTimeZone || '').trim();
if (client) return client;
const server = String(serverTimeZone || '').trim();
return server || undefined;
}
export function formatSiteAnnouncementSeenAt(
value: string | null | undefined,
timeZone = readClientTimeZone(),
): string {
return formatDateTimeLocal(value, 'zh-CN', timeZone);
}
export function renderSiteAnnouncementHtml(content: string): string {
const raw = String(content || '').trim();
if (!raw) return '<p>-</p>';
if (isLikelyHtml(raw)) {
return sanitizeAnnouncementHtml(raw);
}
if (isLikelyMarkdown(raw)) {
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
return sanitizeAnnouncementHtml(rendered || renderMarkdown(raw));
}
return renderPlainText(raw);
}
export function SiteAnnouncementContent({ content }: { content: string }) {
return (
<div
className="announcement-rich-content"
dangerouslySetInnerHTML={{ __html: renderSiteAnnouncementHtml(content) }}
/>
);
}
-205
View File
@@ -1,205 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildSiteSaveAction,
emptySiteApiEndpoint,
emptySiteCustomHeader,
emptySiteForm,
serializeSiteApiEndpoints,
serializeSiteCustomHeaders,
siteFormFromSite,
} from './sitesEditor.js';
describe('buildSiteSaveAction', () => {
it('returns add action in add mode', () => {
const action = buildSiteSaveAction(
{ mode: 'add' },
{
name: 'site-a',
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
apiEndpoints: [
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
],
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
postRefreshProbeEnabled: true,
postRefreshProbeModel: 'gpt-4o',
postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 2500,
},
);
expect(action).toEqual({
kind: 'add',
payload: {
name: 'site-a',
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
apiEndpoints: [
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
],
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
postRefreshProbeEnabled: true,
postRefreshProbeModel: 'gpt-4o',
postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 2500,
},
});
});
it('returns update action in edit mode with site id', () => {
const action = buildSiteSaveAction(
{ mode: 'edit', editingSiteId: 12 },
{
name: 'site-b',
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
proxyUrl: '',
useSystemProxy: true,
apiEndpoints: [],
customHeaders: '',
globalWeight: 0.8,
},
);
expect(action).toEqual({
kind: 'update',
id: 12,
payload: {
name: 'site-b',
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
proxyUrl: '',
useSystemProxy: true,
apiEndpoints: [],
customHeaders: '',
globalWeight: 0.8,
},
});
});
it('throws when edit mode has no site id', () => {
expect(() =>
buildSiteSaveAction(
{ mode: 'edit' } as unknown as Parameters<typeof buildSiteSaveAction>[0],
{
name: 'site-c',
url: 'https://c.example.com',
externalCheckinUrl: '',
platform: '',
proxyUrl: '',
useSystemProxy: false,
apiEndpoints: [],
customHeaders: '',
globalWeight: 1,
},
),
).toThrow('editingSiteId is required in edit mode');
});
it('does not expose deprecated apiKey in site editor state', () => {
const legacySite = {
name: 'site-d',
url: 'https://d.example.com',
externalCheckinUrl: null,
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:8080',
apiEndpoints: [
{
url: 'https://api.example.com',
enabled: false,
cooldownUntil: '2026-04-01T00:05:00.000Z',
lastFailureReason: 'HTTP 502',
},
],
customHeaders: '{"x-site-token":"alpha"}',
globalWeight: 1,
apiKey: 'sk-legacy-site-key',
} as unknown as Parameters<typeof siteFormFromSite>[0];
expect(emptySiteForm()).not.toHaveProperty('apiKey');
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
expect(emptySiteForm().apiEndpoints).toEqual([emptySiteApiEndpoint()]);
expect(emptySiteForm().proxyUrl).toBe('');
expect(siteFormFromSite(legacySite)).not.toHaveProperty('apiKey');
expect(siteFormFromSite({
proxyUrl: 'http://127.0.0.1:8080',
}).proxyUrl).toBe('http://127.0.0.1:8080');
expect(siteFormFromSite(legacySite).apiEndpoints).toEqual([
{
url: 'https://api.example.com',
enabled: false,
cooldownUntil: '2026-04-01T00:05:00.000Z',
lastFailureReason: 'HTTP 502',
},
]);
});
it('parses custom headers json into key value rows', () => {
expect(siteFormFromSite({
name: 'site-e',
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
}).customHeaders).toEqual([
{ key: 'x-site-token', value: 'alpha' },
{ key: 'cf-access-client-id', value: 'beta' },
]);
});
it('serializes key value rows into json', () => {
expect(serializeSiteCustomHeaders([
{ key: 'x-site-token', value: 'alpha' },
{ key: 'cf-access-client-id', value: 'beta' },
emptySiteCustomHeader(),
])).toEqual({
valid: true,
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
});
});
it('rejects duplicate custom header names case-insensitively', () => {
expect(serializeSiteCustomHeaders([
{ key: 'Authorization', value: 'Bearer a' },
{ key: 'authorization', value: 'Bearer b' },
])).toEqual({
valid: false,
customHeaders: '',
error: '请求头 "authorization" 重复了',
});
});
it('serializes api endpoint rows into ordered payloads', () => {
expect(serializeSiteApiEndpoints([
{ url: 'https://api-a.example.com/', enabled: true },
{ url: 'https://api-b.example.com', enabled: false },
emptySiteApiEndpoint(),
])).toEqual({
valid: true,
apiEndpoints: [
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
],
});
});
it('rejects duplicate api endpoints after normalization', () => {
expect(serializeSiteApiEndpoints([
{ url: 'https://api.example.com/', enabled: true },
{ url: 'https://api.example.com', enabled: true },
])).toEqual({
valid: false,
apiEndpoints: [],
error: 'API 请求地址 "https://api.example.com" 重复了',
});
});
});
-257
View File
@@ -1,257 +0,0 @@
export type SiteCustomHeaderField = {
key: string;
value: string;
};
export type SiteApiEndpointField = {
draftId?: string;
url: string;
enabled: boolean;
cooldownUntil?: string | null;
lastFailureReason?: string | null;
};
export type SiteForm = {
name: string;
url: string;
externalCheckinUrl: string;
platform: string;
proxyUrl: string;
useSystemProxy: boolean;
apiEndpoints: SiteApiEndpointField[];
customHeaders: SiteCustomHeaderField[];
globalWeight: string;
};
export type SiteEditorState =
| { mode: 'add' }
| { mode: 'edit'; editingSiteId: number };
export type SiteSavePayload = {
name: string;
url: string;
externalCheckinUrl: string;
platform: string;
initializationPresetId?: string | null;
proxyUrl: string;
useSystemProxy: boolean;
apiEndpoints: Array<{
url: string;
enabled: boolean;
sortOrder: number;
}>;
customHeaders: string;
globalWeight: number;
postRefreshProbeEnabled?: boolean;
postRefreshProbeModel?: string;
postRefreshProbeScope?: 'single' | 'all';
postRefreshProbeLatencyThresholdMs?: number;
};
type SiteSaveAction =
| { kind: 'add'; payload: SiteSavePayload }
| { kind: 'update'; id: number; payload: SiteSavePayload };
export function emptySiteCustomHeader(): SiteCustomHeaderField {
return { key: '', value: '' };
}
export function emptySiteApiEndpoint(): SiteApiEndpointField {
return {
url: '',
enabled: true,
cooldownUntil: null,
lastFailureReason: null,
};
}
function ensureSiteCustomHeaderRows(rows: SiteCustomHeaderField[]): SiteCustomHeaderField[] {
return rows.length > 0 ? rows : [emptySiteCustomHeader()];
}
export function emptySiteForm(): SiteForm {
return {
name: '',
url: '',
externalCheckinUrl: '',
platform: '',
proxyUrl: '',
useSystemProxy: false,
apiEndpoints: [emptySiteApiEndpoint()],
customHeaders: [emptySiteCustomHeader()],
globalWeight: '1',
};
}
function ensureSiteApiEndpointRows(rows: SiteApiEndpointField[]): SiteApiEndpointField[] {
return rows.length > 0 ? rows : [emptySiteApiEndpoint()];
}
function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
if (typeof raw !== 'string') {
return ensureSiteCustomHeaderRows([]);
}
const trimmed = raw.trim();
if (!trimmed) {
return ensureSiteCustomHeaderRows([]);
}
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return ensureSiteCustomHeaderRows([]);
}
return ensureSiteCustomHeaderRows(
Object.entries(parsed as Record<string, unknown>).map(([key, value]) => ({
key,
value: typeof value === 'string' ? value : String(value ?? ''),
})),
);
} catch {
return ensureSiteCustomHeaderRows([]);
}
}
function parseApiEndpointsForEditor(raw: unknown): SiteApiEndpointField[] {
if (!Array.isArray(raw)) {
return ensureSiteApiEndpointRows([]);
}
const rows: SiteApiEndpointField[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
rows.push({
url: typeof row.url === 'string' ? row.url : '',
enabled: row.enabled !== false,
cooldownUntil: typeof row.cooldownUntil === 'string' ? row.cooldownUntil : null,
lastFailureReason: typeof row.lastFailureReason === 'string' ? row.lastFailureReason : null,
});
}
return ensureSiteApiEndpointRows(rows);
}
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'apiEndpoints' | 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
externalCheckinUrl?: string | null;
proxyUrl?: string | null;
useSystemProxy?: boolean | null;
apiEndpoints?: Array<{
url?: string | null;
enabled?: boolean | null;
cooldownUntil?: string | null;
lastFailureReason?: string | null;
}> | null;
customHeaders?: string | null;
globalWeight?: number | string | null;
}): SiteForm {
const globalWeightRaw = Number(site.globalWeight);
const globalWeight = Number.isFinite(globalWeightRaw) && globalWeightRaw > 0 ? String(globalWeightRaw) : '1';
return {
name: site.name ?? '',
url: site.url ?? '',
externalCheckinUrl: site.externalCheckinUrl ?? '',
platform: site.platform ?? '',
proxyUrl: site.proxyUrl ?? '',
useSystemProxy: !!site.useSystemProxy,
apiEndpoints: parseApiEndpointsForEditor(site.apiEndpoints),
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
globalWeight,
};
}
// Keep this in sync with normalizeSiteApiEndpointBaseUrl in
// src/server/services/siteApiEndpointService.ts.
function normalizeSiteApiEndpointUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
try {
const parsed = new URL(trimmed);
parsed.search = '';
parsed.hash = '';
return parsed.toString().replace(/\/+$/, '');
} catch {
return trimmed.replace(/\/+$/, '');
}
}
export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
valid: boolean;
customHeaders: string;
error?: string;
} {
const headers: Record<string, string> = {};
const seen = new Set<string>();
for (const field of fields) {
const key = field.key.trim();
const value = field.value;
const hasAnyInput = key.length > 0 || value.trim().length > 0;
if (!hasAnyInput) continue;
if (!key) {
return { valid: false, customHeaders: '', error: '请求头名称不能为空' };
}
const normalizedKey = key.toLowerCase();
if (seen.has(normalizedKey)) {
return { valid: false, customHeaders: '', error: `请求头 "${key}" 重复了` };
}
seen.add(normalizedKey);
headers[key] = value;
}
return {
valid: true,
customHeaders: Object.keys(headers).length > 0 ? JSON.stringify(headers) : '',
};
}
export function serializeSiteApiEndpoints(fields: SiteApiEndpointField[]): {
valid: boolean;
apiEndpoints: Array<{
url: string;
enabled: boolean;
sortOrder: number;
}>;
error?: string;
} {
const apiEndpoints: Array<{
url: string;
enabled: boolean;
sortOrder: number;
}> = [];
const seen = new Set<string>();
for (const field of fields) {
const rawUrl = field.url.trim();
if (!rawUrl) continue;
const normalizedUrl = normalizeSiteApiEndpointUrl(rawUrl);
if (!normalizedUrl) continue;
if (seen.has(normalizedUrl)) {
return {
valid: false,
apiEndpoints: [],
error: `API 请求地址 "${normalizedUrl}" 重复了`,
};
}
seen.add(normalizedUrl);
apiEndpoints.push({
url: normalizedUrl || rawUrl,
enabled: field.enabled !== false,
sortOrder: apiEndpoints.length,
});
}
return {
valid: true,
apiEndpoints,
};
}
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteSavePayload): SiteSaveAction {
if (editor.mode === 'edit') {
if (!Number.isFinite(editor.editingSiteId)) {
throw new Error('editingSiteId is required in edit mode');
}
return { kind: 'update', id: editor.editingSiteId, payload: form };
}
return { kind: 'add', payload: form };
}
@@ -1,104 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildUpdateReminder,
describeDockerDeployState,
} from './updateCenterPresentation.js';
describe('updateCenterPresentation', () => {
it('returns an unknown reminder when no candidate source data is available', () => {
expect(buildUpdateReminder({
currentVersion: '1.2.3',
helper: null,
githubRelease: null,
dockerHubTag: null,
})).toEqual({
label: '无法检查更新',
badgeClassName: 'badge badge-muted',
detail: '暂未获取到可比较的版本信息。',
highlight: false,
});
});
it('treats a same-version Docker target with a different digest as a real new-digest deploy', () => {
const state = describeDockerDeployState({
enabled: true,
helperHealthy: true,
currentVersion: '1.2.3',
helper: {
imageTag: '1.2.3',
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
candidate: {
normalizedVersion: '1.2.3',
tagName: '1.2.3',
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
});
expect(state.kind).toBe('new-digest');
expect(state.canDeploy).toBe(true);
expect(state.badgeLabel).toBe('发现新 digest');
});
it('treats semver tags with and without a v prefix as the same Docker digest target', () => {
const state = describeDockerDeployState({
enabled: true,
helperHealthy: true,
currentVersion: '1.2.3',
helper: {
imageTag: 'v1.2.3',
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
candidate: {
normalizedVersion: '1.2.3',
tagName: '1.2.3',
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
});
expect(state.kind).toBe('new-digest');
expect(state.canDeploy).toBe(true);
});
it('treats alias tags like latest as a new digest deploy target when the digest changes', () => {
const state = describeDockerDeployState({
enabled: true,
helperHealthy: true,
currentVersion: '1.2.3',
helper: {
imageTag: 'latest',
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
candidate: {
normalizedVersion: 'latest',
tagName: 'latest',
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
});
expect(state.kind).toBe('new-digest');
expect(state.canDeploy).toBe(true);
expect(state.badgeLabel).toBe('发现新 digest');
});
it('does not advertise an older GitHub reminder when the helper is already ahead', () => {
expect(buildUpdateReminder({
currentVersion: '1.2.3',
helper: {
imageTag: '1.4.0',
},
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
tagName: 'v1.3.0',
},
dockerHubTag: null,
})).toEqual({
label: '已是最新',
badgeClassName: 'badge badge-muted',
detail: '当前运行版本与已发现的部署目标没有明显差异。',
highlight: false,
});
});
});
@@ -1,277 +0,0 @@
import {
compareStableVersions,
isSameImageTarget,
resolveUpdateReminderCandidate,
type UpdateHelperRuntimeLike,
type UpdateVersionCandidateLike,
} from '../../../shared/updateCenterReminder.js';
export type UpdateDeployState = {
kind: 'disabled' | 'missing' | 'helper-unhealthy' | 'same-version' | 'same-image' | 'new-version' | 'new-digest' | 'available';
badgeClassName: string;
badgeLabel: string;
reason: string;
canDeploy: boolean;
highlight: boolean;
};
export type UpdateReminder = {
label: string;
badgeClassName: string;
detail: string;
highlight: boolean;
};
function normalizeString(value?: string | null): string {
return String(value || '').trim();
}
function normalizeDigest(value?: string | null): string {
const digest = normalizeString(value);
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
}
export function describeGitHubDeployState(input: {
enabled: boolean;
helperHealthy: boolean;
helperError?: string | null;
currentVersion?: string | null;
helperImageTag?: string | null;
candidate: UpdateVersionCandidateLike | null | undefined;
}): UpdateDeployState {
if (!input.enabled) {
return {
kind: 'disabled',
badgeClassName: 'badge badge-muted',
badgeLabel: '已停用',
reason: '当前来源已停用,开启后才会参与检查和部署。',
canDeploy: false,
highlight: false,
};
}
const candidateVersion = normalizeString(input.candidate?.normalizedVersion);
const candidateTag = normalizeString(input.candidate?.tagName || candidateVersion);
if (!candidateVersion && !candidateTag) {
return {
kind: 'missing',
badgeClassName: 'badge badge-warning',
badgeLabel: '未发现版本',
reason: '当前来源还没有可部署版本。',
canDeploy: false,
highlight: false,
};
}
if (!input.helperHealthy) {
return {
kind: 'helper-unhealthy',
badgeClassName: 'badge badge-warning',
badgeLabel: '等待 helper',
reason: input.helperError || 'Deploy Helper 未健康,先修复 helper 再部署。',
canDeploy: false,
highlight: false,
};
}
const candidateTargetVersion = candidateVersion || candidateTag;
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTargetVersion);
if (versionCompare === 0 || helperVersionCompare === 0 || helperVersionCompare === 1) {
return {
kind: 'same-version',
badgeClassName: 'badge badge-muted',
badgeLabel: '当前运行',
reason: helperVersionCompare === 1
? 'Deploy Helper 已指向更高版本,无需回退到较旧的 GitHub 稳定版。'
: '当前已运行该版本,无需重复部署。',
canDeploy: false,
highlight: false,
};
}
if (versionCompare === -1) {
return {
kind: 'new-version',
badgeClassName: 'badge badge-success',
badgeLabel: '发现新版本',
reason: '检测到比当前运行版本更新的稳定版,可直接发起部署。',
canDeploy: true,
highlight: true,
};
}
return {
kind: 'available',
badgeClassName: 'badge badge-info',
badgeLabel: '可部署',
reason: '版本可用,点击按钮即可通过 helper 发起滚动更新。',
canDeploy: true,
highlight: false,
};
}
export function describeDockerDeployState(input: {
enabled: boolean;
helperHealthy: boolean;
helperError?: string | null;
currentVersion?: string | null;
helper: UpdateHelperRuntimeLike | null | undefined;
candidate: UpdateVersionCandidateLike | null | undefined;
}): UpdateDeployState {
if (!input.enabled) {
return {
kind: 'disabled',
badgeClassName: 'badge badge-muted',
badgeLabel: '已停用',
reason: '当前来源已停用,开启后才会参与检查和部署。',
canDeploy: false,
highlight: false,
};
}
const candidateVersion = normalizeString(input.candidate?.normalizedVersion);
const candidateTag = normalizeString(input.candidate?.tagName || candidateVersion);
const candidateDigest = normalizeDigest(input.candidate?.digest);
if (!candidateVersion && !candidateTag) {
return {
kind: 'missing',
badgeClassName: 'badge badge-warning',
badgeLabel: '未发现版本',
reason: '当前来源还没有可部署版本。',
canDeploy: false,
highlight: false,
};
}
if (!input.helperHealthy) {
return {
kind: 'helper-unhealthy',
badgeClassName: 'badge badge-warning',
badgeLabel: '等待 helper',
reason: input.helperError || 'Deploy Helper 未健康,先修复 helper 再部署。',
canDeploy: false,
highlight: false,
};
}
if (isSameImageTarget(input.helper, { tag: candidateTag, digest: candidateDigest })) {
return {
kind: 'same-image',
badgeClassName: 'badge badge-muted',
badgeLabel: '当前运行',
reason: '当前已运行该镜像,无需重复部署。',
canDeploy: false,
highlight: false,
};
}
const candidateTargetVersion = candidateVersion || candidateTag;
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, candidateTargetVersion);
if (helperVersionCompare === 1) {
return {
kind: 'same-version',
badgeClassName: 'badge badge-muted',
badgeLabel: '当前运行',
reason: 'Deploy Helper 已指向更高版本,无需回退到较旧镜像。',
canDeploy: false,
highlight: false,
};
}
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
return {
kind: 'new-version',
badgeClassName: 'badge badge-success',
badgeLabel: '发现新版本',
reason: 'Docker Hub 已出现更高版本,可直接发起部署。',
canDeploy: true,
highlight: true,
};
}
const helperDigest = normalizeDigest(input.helper?.imageDigest);
const hasSameStableTag = isSameImageTarget(
{
imageTag: input.helper?.imageTag,
imageDigest: null,
},
{
tag: candidateTag,
digest: null,
},
);
if (candidateDigest && helperDigest && hasSameStableTag && helperDigest !== candidateDigest) {
return {
kind: 'new-digest',
badgeClassName: 'badge badge-success',
badgeLabel: '发现新 digest',
reason: '标签未变,但镜像 digest 已更新,适合按镜像级别滚动更新。',
canDeploy: true,
highlight: true,
};
}
return {
kind: 'available',
badgeClassName: 'badge badge-info',
badgeLabel: '可部署',
reason: '版本可用,点击按钮即可通过 helper 发起滚动更新。',
canDeploy: true,
highlight: false,
};
}
export function buildUpdateReminder(input: {
currentVersion?: string | null;
helper: UpdateHelperRuntimeLike | null | undefined;
githubRelease: UpdateVersionCandidateLike | null | undefined;
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
}): UpdateReminder {
const hasGitHubCandidate = Boolean(normalizeString(
input.githubRelease?.displayVersion
|| input.githubRelease?.normalizedVersion
|| input.githubRelease?.tagName,
));
const hasDockerCandidate = Boolean(normalizeString(
input.dockerHubTag?.displayVersion
|| input.dockerHubTag?.normalizedVersion
|| input.dockerHubTag?.tagName
|| input.dockerHubTag?.digest,
));
if (!hasGitHubCandidate && !hasDockerCandidate) {
return {
label: '无法检查更新',
badgeClassName: 'badge badge-muted',
detail: '暂未获取到可比较的版本信息。',
highlight: false,
};
}
const candidate = resolveUpdateReminderCandidate({
currentVersion: input.currentVersion,
helper: input.helper,
githubRelease: input.githubRelease,
dockerHubTag: input.dockerHubTag,
});
if (candidate) {
return {
label: candidate.kind === 'new-digest' ? '发现新 digest' : '发现新版本',
badgeClassName: 'badge badge-success',
detail: candidate.kind === 'new-digest'
? 'Docker Hub 的 alias tag 已指向新 digest,可按需部署。'
: candidate.source === 'github-release'
? `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。`
: `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`,
highlight: true,
};
}
return {
label: '已是最新',
badgeClassName: 'badge badge-muted',
detail: '当前运行版本与已发现的部署目标没有明显差异。',
highlight: false,
};
}

Some files were not shown because too many files have changed in this diff Show More