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:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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('"model": "gpt-5.4"');
|
||||
});
|
||||
|
||||
it('formats first-seen time in the requested local timezone', () => {
|
||||
expect(formatSiteAnnouncementSeenAt('2026-03-20 04:23:27', 'Asia/Shanghai')).toBe('2026/03/20 12:23:27');
|
||||
});
|
||||
|
||||
it('prefers client timezone over server timezone', () => {
|
||||
expect(resolveSiteAnnouncementTimeZone('Asia/Shanghai', 'UTC')).toBe('Asia/Shanghai');
|
||||
expect(resolveSiteAnnouncementTimeZone('', 'UTC')).toBe('UTC');
|
||||
});
|
||||
|
||||
it('reads the browser timezone when available', () => {
|
||||
expect(readClientTimeZone()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalizeLineEndings(value: string): string {
|
||||
return String(value || '').replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
function isLikelyHtml(content: string): boolean {
|
||||
return /<([a-z][a-z0-9-]*)(?:\s[^>]*)?>/i.test(content) || /<\/([a-z][a-z0-9-]*)>/i.test(content);
|
||||
}
|
||||
|
||||
function isLikelyMarkdown(content: string): boolean {
|
||||
return /(^|\n)(#{1,6}\s+|>\s+|[-*+]\s+|\d+\.\s+|```|~~~)/.test(content)
|
||||
|| /!\[[^\]]*]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|~~[^~]+~~/.test(content);
|
||||
}
|
||||
|
||||
function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
|
||||
const value = String(raw || '').trim();
|
||||
if (!value) return null;
|
||||
if (value.startsWith('#') || value.startsWith('/')) return value;
|
||||
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
if (/^mailto:/i.test(value)) return value;
|
||||
if (/^tel:/i.test(value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractVisibleTextFallback(html: string): string {
|
||||
return normalizeLineEndings(html)
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string): string {
|
||||
const tokens: string[] = [];
|
||||
const pushToken = (html: string) => {
|
||||
const key = `\u0000${tokens.length}\u0000`;
|
||||
tokens.push(html);
|
||||
return key;
|
||||
};
|
||||
|
||||
let html = escapeHtml(text);
|
||||
|
||||
html = html.replace(/`([^`]+)`/g, (_, code) => pushToken(`<code>${escapeHtml(code)}</code>`));
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, alt, src, title) => {
|
||||
const safeSrc = sanitizeUrl(src, true);
|
||||
if (!safeSrc) return escapeHtml(String(alt || ''));
|
||||
const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
|
||||
return pushToken(`<img src="${escapeHtml(safeSrc)}" alt="${escapeHtml(String(alt || ''))}"${titleAttr}>`);
|
||||
});
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, label, href, title) => {
|
||||
const safeHref = sanitizeUrl(href, false);
|
||||
if (!safeHref) return escapeHtml(String(label || ''));
|
||||
const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
|
||||
return pushToken(`<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${escapeHtml(String(label || ''))}</a>`);
|
||||
});
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||||
html = html.replace(/(^|[^\*])\*([^*\n]+)\*(?=[^\*]|$)/g, '$1<em>$2</em>');
|
||||
|
||||
return html.replace(/\u0000(\d+)\u0000/g, (_, index) => tokens[Number(index)] || '');
|
||||
}
|
||||
|
||||
function isMarkdownListLine(line: string, ordered: boolean): boolean {
|
||||
return ordered ? /^\s*\d+\.\s+/.test(line) : /^\s*[-*+]\s+/.test(line);
|
||||
}
|
||||
|
||||
function consumeMarkdownList(lines: string[], startIndex: number, ordered: boolean) {
|
||||
const items: string[] = [];
|
||||
let index = startIndex;
|
||||
while (index < lines.length && isMarkdownListLine(lines[index] || '', ordered)) {
|
||||
const line = lines[index] || '';
|
||||
const item = ordered
|
||||
? line.replace(/^\s*\d+\.\s+/, '')
|
||||
: line.replace(/^\s*[-*+]\s+/, '');
|
||||
items.push(`<li>${renderInlineMarkdown(item.trim())}</li>`);
|
||||
index += 1;
|
||||
}
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
return {
|
||||
html: `<${tag}>${items.join('')}</${tag}>`,
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeMarkdownQuote(lines: string[], startIndex: number) {
|
||||
const parts: string[] = [];
|
||||
let index = startIndex;
|
||||
while (index < lines.length && /^\s*>\s?/.test(lines[index] || '')) {
|
||||
parts.push((lines[index] || '').replace(/^\s*>\s?/, ''));
|
||||
index += 1;
|
||||
}
|
||||
return {
|
||||
html: `<blockquote>${renderMarkdown(parts.join('\n'))}</blockquote>`,
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeMarkdownFence(lines: string[], startIndex: number) {
|
||||
const firstLine = String(lines[startIndex] || '');
|
||||
const marker = firstLine.startsWith('~~~') ? '~~~' : '```';
|
||||
const language = firstLine.slice(marker.length).trim().toLowerCase();
|
||||
const codeLines: string[] = [];
|
||||
let index = startIndex + 1;
|
||||
while (index < lines.length && !String(lines[index] || '').startsWith(marker)) {
|
||||
codeLines.push(String(lines[index] || ''));
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
const classAttr = language && /^[a-z0-9_-]+$/i.test(language) ? ` class="language-${escapeHtml(language)}"` : '';
|
||||
return {
|
||||
html: `<pre><code${classAttr}>${escapeHtml(codeLines.join('\n'))}</code></pre>`,
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeMarkdownParagraph(lines: string[], startIndex: number) {
|
||||
const parts: string[] = [];
|
||||
let index = startIndex;
|
||||
while (index < lines.length) {
|
||||
const line = String(lines[index] || '');
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) break;
|
||||
if (/^#{1,6}\s+/.test(trimmed)) break;
|
||||
if (/^\s*>\s?/.test(line)) break;
|
||||
if (isMarkdownListLine(line, false) || isMarkdownListLine(line, true)) break;
|
||||
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) break;
|
||||
if (/^(-{3,}|\*{3,})$/.test(trimmed)) break;
|
||||
parts.push(line);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const content = parts.map((line) => renderInlineMarkdown(line.trim())).join('<br />');
|
||||
return {
|
||||
html: `<p>${content}</p>`,
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdown(content: string): string {
|
||||
const lines = normalizeLineEndings(content).split('\n');
|
||||
const blocks: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const rawLine = String(lines[index] || '');
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('```') || line.startsWith('~~~')) {
|
||||
const fenced = consumeMarkdownFence(lines, index);
|
||||
blocks.push(fenced.html);
|
||||
index = fenced.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
blocks.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*>\s?/.test(rawLine)) {
|
||||
const quoted = consumeMarkdownQuote(lines, index);
|
||||
blocks.push(quoted.html);
|
||||
index = quoted.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMarkdownListLine(rawLine, false)) {
|
||||
const list = consumeMarkdownList(lines, index, false);
|
||||
blocks.push(list.html);
|
||||
index = list.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMarkdownListLine(rawLine, true)) {
|
||||
const list = consumeMarkdownList(lines, index, true);
|
||||
blocks.push(list.html);
|
||||
index = list.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(-{3,}|\*{3,})$/.test(line)) {
|
||||
blocks.push('<hr>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraph = consumeMarkdownParagraph(lines, index);
|
||||
blocks.push(paragraph.html);
|
||||
index = paragraph.nextIndex;
|
||||
}
|
||||
|
||||
return blocks.join('');
|
||||
}
|
||||
|
||||
function sanitizeNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return escapeHtml(node.textContent || '');
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const element = node as Element;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (DROPPED_TAGS.has(tag)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const childrenHtml = Array.from(element.childNodes).map((child) => sanitizeNode(child)).join('');
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
return childrenHtml;
|
||||
}
|
||||
|
||||
const attrs: string[] = [];
|
||||
if (tag === 'a') {
|
||||
const safeHref = sanitizeUrl(element.getAttribute('href'));
|
||||
if (safeHref) {
|
||||
attrs.push(` href="${escapeHtml(safeHref)}"`);
|
||||
attrs.push(' target="_blank"');
|
||||
attrs.push(' rel="noopener noreferrer"');
|
||||
}
|
||||
const title = element.getAttribute('title');
|
||||
if (title) {
|
||||
attrs.push(` title="${escapeHtml(title)}"`);
|
||||
}
|
||||
} else if (tag === 'img') {
|
||||
const safeSrc = sanitizeUrl(element.getAttribute('src'), true);
|
||||
if (!safeSrc) return '';
|
||||
attrs.push(` src="${escapeHtml(safeSrc)}"`);
|
||||
const alt = element.getAttribute('alt');
|
||||
if (alt) attrs.push(` alt="${escapeHtml(alt)}"`);
|
||||
const title = element.getAttribute('title');
|
||||
if (title) attrs.push(` title="${escapeHtml(title)}"`);
|
||||
} else if (tag === 'code') {
|
||||
const className = String(element.getAttribute('class') || '').trim();
|
||||
if (/^language-[a-z0-9_-]+$/i.test(className)) {
|
||||
attrs.push(` class="${escapeHtml(className)}"`);
|
||||
}
|
||||
} else if (tag === 'td' || tag === 'th') {
|
||||
const colspan = Number.parseInt(String(element.getAttribute('colspan') || ''), 10);
|
||||
const rowspan = Number.parseInt(String(element.getAttribute('rowspan') || ''), 10);
|
||||
if (Number.isFinite(colspan) && colspan > 1) attrs.push(` colspan="${colspan}"`);
|
||||
if (Number.isFinite(rowspan) && rowspan > 1) attrs.push(` rowspan="${rowspan}"`);
|
||||
}
|
||||
|
||||
if (VOID_TAGS.has(tag)) {
|
||||
return `<${tag}${attrs.join('')}>`;
|
||||
}
|
||||
return `<${tag}${attrs.join('')}>${childrenHtml}</${tag}>`;
|
||||
}
|
||||
|
||||
function sanitizeAnnouncementHtml(html: string): string {
|
||||
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
|
||||
return renderPlainText(extractVisibleTextFallback(html));
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
|
||||
return Array.from(doc.body.childNodes).map((node) => sanitizeNode(node)).join('');
|
||||
}
|
||||
|
||||
function renderPlainText(content: string): string {
|
||||
const paragraphs = normalizeLineEndings(content)
|
||||
.split(/\n{2,}/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (paragraphs.length === 0) {
|
||||
return `<p>${escapeHtml(String(content || '').trim())}</p>`;
|
||||
}
|
||||
|
||||
return paragraphs
|
||||
.map((part) => `<p>${escapeHtml(part).replace(/\n/g, '<br />')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function readClientTimeZone(): string | undefined {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSiteAnnouncementTimeZone(
|
||||
clientTimeZone?: string | null,
|
||||
serverTimeZone?: string | null,
|
||||
): string | undefined {
|
||||
const client = String(clientTimeZone || '').trim();
|
||||
if (client) return client;
|
||||
|
||||
const server = String(serverTimeZone || '').trim();
|
||||
return server || undefined;
|
||||
}
|
||||
|
||||
export function formatSiteAnnouncementSeenAt(
|
||||
value: string | null | undefined,
|
||||
timeZone = readClientTimeZone(),
|
||||
): string {
|
||||
return formatDateTimeLocal(value, 'zh-CN', timeZone);
|
||||
}
|
||||
|
||||
export function renderSiteAnnouncementHtml(content: string): string {
|
||||
const raw = String(content || '').trim();
|
||||
if (!raw) return '<p>-</p>';
|
||||
|
||||
if (isLikelyHtml(raw)) {
|
||||
return sanitizeAnnouncementHtml(raw);
|
||||
}
|
||||
|
||||
if (isLikelyMarkdown(raw)) {
|
||||
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
|
||||
return sanitizeAnnouncementHtml(rendered || renderMarkdown(raw));
|
||||
}
|
||||
|
||||
return renderPlainText(raw);
|
||||
}
|
||||
|
||||
export function SiteAnnouncementContent({ content }: { content: string }) {
|
||||
return (
|
||||
<div
|
||||
className="announcement-rich-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderSiteAnnouncementHtml(content) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" 重复了',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user