feat: Codex 工作区改动迁移(支付管理/用户余额/创作页等)
将 Codex 在工作区累积的改动整体落到 refactor/rust-backend: 支付管理与用户余额页、各创作 surface、website 等。排除 boosapi.db/config.toml 等敏感文件。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -250,10 +250,9 @@ describe('Login surface', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects admin API Key login on the user login page', async () => {
|
||||
it('promotes admin-role API Key login into both audiences and navigates to /dashboard', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
commitSession('user', 'stale-user-jwt', undefined, storage);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -298,17 +297,16 @@ describe('Login surface', () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiKey: 'sk-admin-user-key' }),
|
||||
}));
|
||||
expect(getSessionToken('admin', storage)).toBeNull();
|
||||
expect(getSessionToken('user', storage)).toBe('stale-user-jwt');
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
|
||||
expect(paths.at(-1)).toBe('/user/login');
|
||||
expect(getSessionToken('admin', storage)).toBe('jwt-admin-from-key');
|
||||
expect(getSessionToken('user', storage)).toBe('jwt-admin-from-key');
|
||||
expect(onLogin).toHaveBeenCalledWith('admin');
|
||||
expect(paths.at(-1)).toBe('/dashboard');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects API Key login when JWT role falls back to admin', async () => {
|
||||
it('promotes admin role from JWT fallback when API Key response omits user.role', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
const adminJwt = unsignedJwt({ sub: '5', role: 'admin', exp: 9_999_999_999 });
|
||||
@@ -355,11 +353,10 @@ describe('Login surface', () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(getSessionToken('admin', storage)).toBeNull();
|
||||
expect(getSessionToken('user', storage)).toBeNull();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
|
||||
expect(paths.at(-1)).toBe('/user/login');
|
||||
expect(getSessionToken('admin', storage)).toBe(adminJwt);
|
||||
expect(getSessionToken('user', storage)).toBe(adminJwt);
|
||||
expect(onLogin).toHaveBeenCalledWith('admin');
|
||||
expect(paths.at(-1)).toBe('/dashboard');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -200,4 +200,19 @@ describe('App sidebar config', () => {
|
||||
expect(appSource).toContain("{ to: '/user/recharge', label: '账户充值'");
|
||||
expect(appSource).toContain('<Route path="/user/recharge" element={<UserRecharge />} />');
|
||||
});
|
||||
|
||||
it('adds admin payment management navigation and route without fallback static checkout links', () => {
|
||||
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
const paymentSource = readFileSync(resolve(process.cwd(), 'src/web/pages/PaymentManagement.tsx'), 'utf8');
|
||||
const opsGroupSource = appSource.slice(
|
||||
appSource.indexOf("label: '运营管理'"),
|
||||
appSource.indexOf("label: '接入管理'"),
|
||||
);
|
||||
|
||||
expect(appSource).toContain("const PaymentManagement = lazy(() => import('./pages/PaymentManagement.js'));");
|
||||
expect(appSource).toContain('payment: <svg className="sidebar-item-icon"');
|
||||
expect(opsGroupSource).toContain("{ to: '/payment', label: '支付管理', icon: navIcons.payment }");
|
||||
expect(appSource).toContain('<Route path="/payment" element={<PaymentManagement />} />');
|
||||
expect(paymentSource).not.toContain('fallbackStaticUrlsSupported');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,8 @@ const OpsMonitor = lazy(() => import('./pages/OpsMonitor.js'));
|
||||
const IpManagement = lazy(() => import('./pages/IpManagement.js'));
|
||||
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
||||
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
|
||||
const PaymentManagement = lazy(() => import('./pages/PaymentManagement.js'));
|
||||
const UserBalances = lazy(() => import('./pages/UserBalances.js'));
|
||||
const UserRegister = lazy(() => import('./pages/UserRegister.js'));
|
||||
const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
|
||||
const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));
|
||||
@@ -565,6 +567,7 @@ const navIcons = {
|
||||
ops: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 19V9m4 10V5m4 14v-7m4 7V8m4 11V4M3 19h18" /></svg>,
|
||||
users: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>,
|
||||
announcement: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg>,
|
||||
payment: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M3 8h18M5 5h14a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2zm3 9h4" /></svg>,
|
||||
channels: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 7h16M4 12h10m-10 5h16M17 9l3 3-3 3" /></svg>,
|
||||
sites: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>,
|
||||
accounts: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
|
||||
@@ -599,6 +602,8 @@ export const sidebarGroups: SidebarGroup[] = [
|
||||
items: [
|
||||
{ to: '/users', label: '用户管理', icon: navIcons.users },
|
||||
{ to: '/site-announcements', label: '用户公告', icon: navIcons.announcement },
|
||||
{ to: '/payment', label: '支付管理', icon: navIcons.payment },
|
||||
{ to: '/user-balances', label: '用户余额', icon: navIcons.payment },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1667,6 +1672,8 @@ function AppShell() {
|
||||
<Route path="/workspace" element={<Workspace />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/users" element={<UserManagement />} />
|
||||
<Route path="/payment" element={<PaymentManagement />} />
|
||||
<Route path="/user-balances" element={<UserBalances />} />
|
||||
|
||||
{/* User specific pages */}
|
||||
<Route
|
||||
|
||||
+65
-4
@@ -52,7 +52,7 @@ function sanitizeErrorText(status: number, text: string): string {
|
||||
|| trimmed.includes("</html>")
|
||||
|| trimmed.includes("<!--[if");
|
||||
if (looksLikeHtml) {
|
||||
return `HTTP ${status}: 上游返回 HTML 错误页,请检查支付/上游 Token 配置`;
|
||||
return `HTTP ${status}: 上游返回 HTML 错误页,请检查对应上游服务配置`;
|
||||
}
|
||||
return `HTTP ${status}: ${trimmed.slice(0, 120)}`;
|
||||
}
|
||||
@@ -1476,6 +1476,7 @@ export type DigitalHumanJob = {
|
||||
progress: number;
|
||||
progressTotal: number;
|
||||
result?: any;
|
||||
reusePayload?: any;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
startedAt?: string | null;
|
||||
@@ -2017,13 +2018,45 @@ export const api = {
|
||||
"/api/digital-human/pixelle/bgm",
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
uploadAsset: (file: File, purpose = "reference_audio") => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
formData.set("purpose", purpose);
|
||||
return request<{
|
||||
success: boolean;
|
||||
asset: {
|
||||
publicId: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
fileUrl: string;
|
||||
pixellePath?: string;
|
||||
workspaceFileUrl?: string;
|
||||
};
|
||||
}>(
|
||||
"/api/digital-human/upload",
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
preserveAuthErrors: true,
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
getTemplateParams: (template: string) =>
|
||||
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
|
||||
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
previewImage: (data: { prompt: string; width?: number; height?: number; workflow?: string }) =>
|
||||
request<{ image_path: string }>(
|
||||
previewImage: (data: {
|
||||
prompt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
workflow?: string;
|
||||
workflow_key?: string;
|
||||
media_workflow?: string;
|
||||
backend_provider?: string;
|
||||
}) =>
|
||||
request<any>(
|
||||
"/api/digital-human/pixelle/preview-image",
|
||||
{
|
||||
method: "POST",
|
||||
@@ -2033,7 +2066,7 @@ export const api = {
|
||||
},
|
||||
),
|
||||
previewTts: (data: { text: string; workflow?: string; ref_audio?: string }) =>
|
||||
request<{ audio_path: string; duration?: number }>(
|
||||
request<any>(
|
||||
"/api/digital-human/pixelle/preview-tts",
|
||||
{
|
||||
method: "POST",
|
||||
@@ -2069,6 +2102,7 @@ export const api = {
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
fileUrl: (path: string) => {
|
||||
if (/^(https?:|blob:|data:)/i.test(path) || path.startsWith("/api/")) return path;
|
||||
const trimmed = String(path || "").replace(/^\/+/, "");
|
||||
const segments = trimmed.split("/").map((segment) => encodeURIComponent(segment));
|
||||
return `/api/digital-human/pixelle/files/${segments.join("/")}`;
|
||||
@@ -2858,6 +2892,8 @@ export const api = {
|
||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
||||
cancelDigitalHumanJob: (id: string) =>
|
||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||
retryDigitalHumanJob: (id: string) =>
|
||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/retry`, { method: "POST" }),
|
||||
clearDigitalHumanJobs: () =>
|
||||
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
||||
getMoneyPrinterStatus: () =>
|
||||
@@ -2977,6 +3013,29 @@ export const userApi = {
|
||||
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
getDigitalHumanStatus: () =>
|
||||
userRequest<DigitalHumanStatusResponse>("/api/digital-human/status"),
|
||||
uploadDigitalHumanAsset: (file: File, purpose = "reference_audio") => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
formData.set("purpose", purpose);
|
||||
return userRequest<{
|
||||
success: boolean;
|
||||
asset: {
|
||||
publicId: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
fileUrl: string;
|
||||
pixellePath?: string;
|
||||
workspaceFileUrl?: string;
|
||||
};
|
||||
}>(
|
||||
"/api/digital-human/upload",
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
createDigitalHumanJob: (data: any) =>
|
||||
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
||||
method: "POST",
|
||||
@@ -2989,6 +3048,8 @@ export const userApi = {
|
||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
||||
cancelDigitalHumanJob: (id: string) =>
|
||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||
retryDigitalHumanJob: (id: string) =>
|
||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/retry`, { method: "POST" }),
|
||||
clearDigitalHumanJobs: () =>
|
||||
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
||||
getMoneyPrinterStatus: () =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
USER_RECHARGE_CONFIG,
|
||||
formatRechargePoints,
|
||||
isUserRechargeOptionPayable,
|
||||
type UserRechargeOption,
|
||||
type UserRechargeCheckoutOption,
|
||||
@@ -47,8 +48,10 @@ export default function UserRechargeOptions({
|
||||
title={option.unavailableReason || '该额度支付链接未配置'}
|
||||
disabled
|
||||
>
|
||||
{option.recommended ? <small>推荐</small> : null}
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
|
||||
<em>待配置</em>
|
||||
</button>
|
||||
);
|
||||
@@ -62,8 +65,10 @@ export default function UserRechargeOptions({
|
||||
className={className}
|
||||
aria-label={`前往系统内充值页确认 ${option.label}`}
|
||||
>
|
||||
{option.recommended ? <small>推荐</small> : null}
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -79,8 +84,10 @@ export default function UserRechargeOptions({
|
||||
disabled={pending}
|
||||
data-checkout-option-id={option.id}
|
||||
>
|
||||
{option.recommended ? <small>推荐</small> : null}
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
|
||||
{pending ? <em>创建记录中</em> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
+310
-48
@@ -2439,7 +2439,7 @@ body {
|
||||
|
||||
.program-logs-table {
|
||||
table-layout: fixed;
|
||||
min-width: 1120px;
|
||||
min-width: 1420px;
|
||||
}
|
||||
|
||||
.program-logs-filter-bar {
|
||||
@@ -2474,8 +2474,21 @@ body {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.program-logs-table td:nth-child(6),
|
||||
.program-logs-table td:nth-child(7) {
|
||||
.program-logs-table .program-logs-account-cell,
|
||||
.program-logs-table .program-logs-ip-cell {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.program-logs-table .program-logs-ip-cell {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.program-logs-table td:nth-child(8),
|
||||
.program-logs-table td:nth-child(9) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -4418,6 +4431,11 @@ textarea {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.picture-agent-tabs + [role="tabpanel"] {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.picture-agent-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4482,22 +4500,24 @@ textarea {
|
||||
.picture-agent-grid-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-size-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr minmax(92px, 0.8fr);
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.picture-agent-size-preview {
|
||||
min-height: 38px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
grid-column: 1 / -1;
|
||||
min-height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-info) 7%, var(--color-bg-card));
|
||||
@@ -4511,21 +4531,21 @@ textarea {
|
||||
|
||||
.picture-agent-size-preview strong {
|
||||
color: var(--color-info);
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picture-agent-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picture-agent-presets button {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
@@ -4590,9 +4610,9 @@ textarea {
|
||||
}
|
||||
|
||||
.picture-agent-submit {
|
||||
min-height: 40px;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.picture-agent-submit-hint {
|
||||
@@ -5773,6 +5793,11 @@ textarea {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.picture-agent-presets {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picture-agent-size-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
@@ -12959,6 +12984,63 @@ textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.digital-human-upload-note {
|
||||
margin-top: -2px;
|
||||
color: var(--color-success);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.digital-human-file-picker {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-bg-card) 76%, var(--color-bg));
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.digital-human-file-picker input {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.digital-human-file-picker label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-contrast);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.digital-human-file-picker input:disabled + label {
|
||||
cursor: wait;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.digital-human-file-picker span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.digital-human-panel h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
@@ -13069,6 +13151,35 @@ textarea {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.digital-human-backend-switch {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
width: min(360px, 100%);
|
||||
padding: 3px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 16%, var(--color-border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-bg) 86%, var(--color-primary));
|
||||
}
|
||||
|
||||
.digital-human-backend-switch button {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius-sm) - 3px);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.digital-human-backend-switch button.is-active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-contrast);
|
||||
box-shadow: 0 8px 18px color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
}
|
||||
|
||||
.digital-human-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -13418,7 +13529,8 @@ textarea {
|
||||
}
|
||||
|
||||
.money-printer-summary,
|
||||
.digital-human-summary {
|
||||
.digital-human-summary,
|
||||
.picture-agent-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
@@ -13426,7 +13538,8 @@ textarea {
|
||||
}
|
||||
|
||||
.money-printer-summary-card,
|
||||
.digital-human-summary-card {
|
||||
.digital-human-summary-card,
|
||||
.picture-agent-summary-card {
|
||||
min-height: 92px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
@@ -13443,14 +13556,17 @@ textarea {
|
||||
.money-printer-summary-card span,
|
||||
.money-printer-summary-card em,
|
||||
.digital-human-summary-card span,
|
||||
.digital-human-summary-card em {
|
||||
.digital-human-summary-card em,
|
||||
.picture-agent-summary-card span,
|
||||
.picture-agent-summary-card em {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.money-printer-summary-card strong,
|
||||
.digital-human-summary-card strong {
|
||||
.digital-human-summary-card strong,
|
||||
.picture-agent-summary-card strong {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 18px;
|
||||
@@ -13460,7 +13576,8 @@ textarea {
|
||||
}
|
||||
|
||||
.money-printer-summary-card em,
|
||||
.digital-human-summary-card em {
|
||||
.digital-human-summary-card em,
|
||||
.picture-agent-summary-card em {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@ -13937,6 +14054,7 @@ textarea {
|
||||
@media (max-width: 1180px) {
|
||||
.money-printer-summary,
|
||||
.digital-human-summary,
|
||||
.picture-agent-summary,
|
||||
.money-printer-presets {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -13949,6 +14067,7 @@ textarea {
|
||||
@media (max-width: 720px) {
|
||||
.money-printer-summary,
|
||||
.digital-human-summary,
|
||||
.picture-agent-summary,
|
||||
.money-printer-presets {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -14769,22 +14888,38 @@ textarea {
|
||||
}
|
||||
|
||||
.user-dashboard-recharge-panel {
|
||||
display: flex;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(440px, 0.78fr);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
gap: 22px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 18px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 24%, var(--console-line));
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(circle at 92% 12%, color-mix(in srgb, var(--color-primary) 18%, transparent), transparent 34%),
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg)));
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.user-dashboard-recharge-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--color-bg-card) 54%, transparent), transparent 58%),
|
||||
repeating-linear-gradient(90deg, color-mix(in srgb, var(--color-primary) 7%, transparent) 0 1px, transparent 1px 44px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.user-dashboard-recharge-panel>div {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -14796,9 +14931,9 @@ textarea {
|
||||
|
||||
.user-dashboard-recharge-panel strong {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 820;
|
||||
line-height: 1.35;
|
||||
font-size: clamp(18px, 2.1vw, 24px);
|
||||
font-weight: 860;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.user-dashboard-recharge-panel p {
|
||||
@@ -14897,10 +15032,12 @@ textarea {
|
||||
|
||||
.user-recharge-options {
|
||||
display: grid;
|
||||
flex: 0 1 520px;
|
||||
grid-template-columns: repeat(4, minmax(92px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: min(520px, 100%);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 0 1 560px;
|
||||
grid-template-columns: repeat(4, minmax(98px, 1fr));
|
||||
gap: 10px;
|
||||
min-width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.user-recharge-options.is-compact {
|
||||
@@ -14910,18 +15047,37 @@ textarea {
|
||||
}
|
||||
|
||||
.user-recharge-option {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
min-height: 64px;
|
||||
padding: 10px 12px;
|
||||
min-height: 76px;
|
||||
overflow: hidden;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--console-line));
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--color-bg-card) 92%, var(--color-primary));
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 94%, var(--color-primary)), color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg)));
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, white 46%, transparent),
|
||||
0 12px 28px color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.user-recharge-option::after {
|
||||
content: "→";
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 10px;
|
||||
color: color-mix(in srgb, var(--color-primary) 62%, var(--color-text-muted));
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
button.user-recharge-option {
|
||||
@@ -14934,9 +15090,18 @@ button.user-recharge-option {
|
||||
.user-recharge-option:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 54%, var(--console-line));
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-card));
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, white 50%, transparent),
|
||||
0 16px 34px color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.user-recharge-option:hover::after,
|
||||
.user-recharge-option:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.user-recharge-option.is-disabled,
|
||||
.user-recharge-option.is-disabled:hover,
|
||||
.user-recharge-option.is-disabled:focus-visible {
|
||||
@@ -14948,14 +15113,35 @@ button.user-recharge-option {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.user-recharge-option.is-disabled::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-recharge-option.is-recommended {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 64%, var(--console-line));
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 22%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 84%, var(--color-bg)));
|
||||
background:
|
||||
radial-gradient(circle at 86% 18%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 38%),
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 20%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 84%, var(--color-bg)));
|
||||
}
|
||||
|
||||
.user-recharge-option small {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 9px;
|
||||
max-width: calc(100% - 18px);
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-card));
|
||||
color: var(--color-primary);
|
||||
font-size: 10px;
|
||||
font-weight: 820;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-recharge-option strong {
|
||||
overflow: hidden;
|
||||
font-size: 17px;
|
||||
font-size: 20px;
|
||||
font-weight: 860;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
@@ -14965,13 +15151,23 @@ button.user-recharge-option {
|
||||
.user-recharge-option span {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
font-size: 12px;
|
||||
font-weight: 680;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-recharge-option b {
|
||||
overflow: hidden;
|
||||
color: var(--color-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 820;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-recharge-option em {
|
||||
align-self: end;
|
||||
color: var(--color-text-muted);
|
||||
@@ -15083,6 +15279,68 @@ button.user-recharge-option {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-recharge-custom {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(170px, 0.28fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 22%, var(--console-line));
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, transparent), transparent 58%),
|
||||
var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.user-recharge-custom>div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-recharge-custom span {
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.user-recharge-custom strong {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 860;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-recharge-custom p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.user-recharge-custom label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-recharge-custom input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--console-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
font-weight: 760;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.user-recharge-custom .btn {
|
||||
min-height: 42px;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-recharge-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -15574,7 +15832,7 @@ button.user-recharge-option {
|
||||
|
||||
.user-dashboard-recharge-panel {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-dashboard-recharge-panel .btn,
|
||||
@@ -15603,6 +15861,10 @@ button.user-recharge-option {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.user-recharge-custom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-recharge-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -576,6 +576,16 @@ function angleLabel(angle?: string) {
|
||||
return angle || '图像';
|
||||
}
|
||||
|
||||
function toolLabel(tool?: string) {
|
||||
switch (tool) {
|
||||
case 'generate_character_images': return '生成角色四视图';
|
||||
case 'generate_scene_image': return '生成场景图';
|
||||
case 'generate_tts': return '生成语音';
|
||||
case 'build_comfyui_workflow': return '生成工作流';
|
||||
default: return tool || '工具';
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(current: string, line: string) {
|
||||
if (!line) return current;
|
||||
return current ? `${current}\n${line}` : line;
|
||||
@@ -1027,11 +1037,11 @@ export default function ComfyUIAgent() {
|
||||
next.images.push({
|
||||
url: String(event.url || ''),
|
||||
character: event.character,
|
||||
angle: event.angle,
|
||||
angle: event.angle || event.view,
|
||||
});
|
||||
next.content = appendLine(
|
||||
next.content,
|
||||
`已生成 ${event.character || '角色'} ${angleLabel(event.angle)}。`,
|
||||
`已生成 ${event.character || '角色'} ${angleLabel(event.angle || event.view)}。`,
|
||||
);
|
||||
} else if (event.type === 'scene') {
|
||||
next.scenes.push({
|
||||
@@ -1055,8 +1065,27 @@ export default function ComfyUIAgent() {
|
||||
`已生成 ${event.character || '角色'} 的语音。`,
|
||||
);
|
||||
} else if (event.type === 'comfyui_workflow') {
|
||||
next.workflowJson = parseWorkflow(String(event.content || ''));
|
||||
// New agent path sends a parsed object in `workflow`; legacy path sends a
|
||||
// JSON string in `content`.
|
||||
if (event.workflow !== undefined && event.workflow !== null) {
|
||||
next.workflowJson = event.workflow;
|
||||
} else {
|
||||
next.workflowJson = parseWorkflow(String(event.content || ''));
|
||||
}
|
||||
next.content = appendLine(next.content, 'ComfyUI 工作流 JSON 已就绪。');
|
||||
} else if (event.type === 'tool_call') {
|
||||
next.content = appendLine(
|
||||
next.content,
|
||||
`🛠 调用工具:${toolLabel(event.tool)}`,
|
||||
);
|
||||
} else if (event.type === 'tool_progress') {
|
||||
if (event.detail) {
|
||||
next.content = appendLine(next.content, `· ${event.detail}`);
|
||||
}
|
||||
} else if (event.type === 'tool_result') {
|
||||
if (event.result) {
|
||||
next.content = appendLine(next.content, `✓ ${String(event.result)}`);
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
next.content = appendLine(next.content, `错误:${event.message || '生成失败'}`);
|
||||
}
|
||||
|
||||
@@ -1,964 +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 { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import DigitalHuman from './DigitalHuman.js';
|
||||
|
||||
const { apiMock, userApiMock } = vi.hoisted(() => {
|
||||
const createBase = () => ({
|
||||
getWorkflows: vi.fn(),
|
||||
getWorkflowModelFolder: vi.fn(),
|
||||
getDigitalHumanStatus: vi.fn(),
|
||||
getDigitalHumanJobs: vi.fn(),
|
||||
getWorkspaceAssets: vi.fn(),
|
||||
createDigitalHumanJob: vi.fn(),
|
||||
cancelDigitalHumanJob: vi.fn(),
|
||||
clearDigitalHumanJobs: vi.fn(),
|
||||
});
|
||||
return {
|
||||
apiMock: createBase(),
|
||||
userApiMock: createBase(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
userApi: userApiMock,
|
||||
fetchSessionResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
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 findButton(root: ReactTestRenderer, label: string) {
|
||||
return root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes(label)
|
||||
))[0];
|
||||
}
|
||||
|
||||
function findComfyModelInput(root: ReactTestRenderer) {
|
||||
return root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.list === 'digital-human-comfy-api-models'
|
||||
));
|
||||
}
|
||||
|
||||
function findDatalistOptionValues(root: ReactTestRenderer, id: string) {
|
||||
const datalist = root.root.find((node) => node.type === 'datalist' && node.props.id === id);
|
||||
return datalist.findAll((node) => node.type === 'option').map((node) => node.props.value);
|
||||
}
|
||||
|
||||
function linkTarget(node: ReactTestInstance): string {
|
||||
return String(node.props.href || node.props.to || '');
|
||||
}
|
||||
|
||||
async function renderDigitalHuman(initialPath = '/digital-human') {
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<DigitalHuman />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
return root;
|
||||
}
|
||||
|
||||
describe('DigitalHuman page', () => {
|
||||
let documentListeners!: Map<string, EventListener[]>;
|
||||
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
documentListeners = new Map();
|
||||
class MockHTMLElement {
|
||||
isConnected = true;
|
||||
focus = vi.fn();
|
||||
}
|
||||
activeElementMock = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
vi.stubGlobal('HTMLElement', MockHTMLElement);
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: activeElementMock,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
},
|
||||
},
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
documentListeners.set(type, [...(documentListeners.get(type) || []), listener]);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
documentListeners.set(type, (documentListeners.get(type) || []).filter((item) => item !== listener));
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
setInterval,
|
||||
clearInterval,
|
||||
});
|
||||
|
||||
apiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
|
||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [{ name: 'wan2.1-digital-human' }, { name: 'seedance-1-0-pro-t2v-250528' }],
|
||||
names: ['wan2.1-digital-human', 'seedance-1-0-pro-t2v-250528'],
|
||||
});
|
||||
apiMock.getDigitalHumanStatus.mockResolvedValue({
|
||||
success: true,
|
||||
mode: 'comfy-native',
|
||||
keyName: 'comfyui-agent',
|
||||
capabilities: ['product_explainer_video', 'workspace_archive'],
|
||||
comfy: {
|
||||
configured: true,
|
||||
backend: 'cloud',
|
||||
baseUrl: 'https://comfy.example.com',
|
||||
},
|
||||
});
|
||||
apiMock.getDigitalHumanJobs.mockResolvedValue({ success: true, jobs: [] });
|
||||
apiMock.getWorkspaceAssets.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
publicId: 'asset-audio-1',
|
||||
title: '参考音频',
|
||||
kind: 'audio',
|
||||
source: 'workspace',
|
||||
hasLocalFile: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
userApiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
|
||||
userApiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [{ name: 'wan2.1-digital-human' }],
|
||||
names: ['wan2.1-digital-human'],
|
||||
});
|
||||
userApiMock.getDigitalHumanStatus.mockResolvedValue({
|
||||
success: true,
|
||||
mode: 'boosapi-video-route',
|
||||
keyName: 'user-default',
|
||||
capabilities: [],
|
||||
comfy: {
|
||||
configured: false,
|
||||
},
|
||||
});
|
||||
userApiMock.getDigitalHumanJobs.mockResolvedValue({ success: true, jobs: [] });
|
||||
userApiMock.getWorkspaceAssets.mockResolvedValue({ success: true, items: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the workspace asset picker as a labelled dialog and dismisses it with Escape', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
|
||||
await act(async () => {
|
||||
findButton(root, '工作空间').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(dialog.props['aria-labelledby']).toBeTruthy();
|
||||
expect(dialog.props.tabIndex).toBe(-1);
|
||||
expect(collectText(dialog)).toContain('选择人物素材');
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
expect(apiMock.getWorkspaceAssets).toHaveBeenCalledWith({ kind: undefined, pageSize: 60 });
|
||||
|
||||
const keydownListeners = documentListeners.get('keydown') || [];
|
||||
expect(keydownListeners.length).toBeGreaterThan(0);
|
||||
|
||||
const tabEvent = {
|
||||
key: 'Tab',
|
||||
preventDefault: vi.fn(),
|
||||
shiftKey: false,
|
||||
};
|
||||
await act(async () => {
|
||||
keydownListeners.at(-1)?.(tabEvent as unknown as KeyboardEvent);
|
||||
});
|
||||
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
keydownListeners.at(-1)?.({ key: 'Escape' } as KeyboardEvent);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
expect(documentListeners.get('keydown') || []).toHaveLength(0);
|
||||
expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the active digital human backend, key, and Comfy status', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
const text = collectText(root.root);
|
||||
expect(apiMock.getDigitalHumanStatus).toHaveBeenCalled();
|
||||
expect(text).toContain('当前后端');
|
||||
expect(text).toContain('Comfy API');
|
||||
expect(text).toContain('使用 comfyui-agent');
|
||||
expect(text).toContain('Comfy 状态');
|
||||
expect(text).toContain('cloud');
|
||||
expect(text).toContain('https://comfy.example.com');
|
||||
expect(text).toContain('能力');
|
||||
expect(text).toContain('2 项');
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('allows generation when only optional output mapping is missing', async () => {
|
||||
apiMock.getWorkflows.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
publicId: 'tpl_digital_human',
|
||||
name: '数字人模板',
|
||||
visibility: 'template',
|
||||
isTemplate: true,
|
||||
graph: {
|
||||
metadata: {
|
||||
digitalHuman: {
|
||||
nodeMapping: {
|
||||
prompt: { nodeId: '1', field: 'prompt' },
|
||||
script: { nodeId: '2', field: 'text' },
|
||||
model: { nodeId: '5', field: 'model' },
|
||||
avatar: { nodeId: '3', field: 'image' },
|
||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
||||
output: { nodeId: '', field: 'video' },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
'5': {
|
||||
class_type: 'ByteDanceDigitalHumanNode',
|
||||
inputs: { model: 'seedance-1-0-lite-t2v-250428' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.createDigitalHumanJob.mockResolvedValue({
|
||||
success: true,
|
||||
job: {
|
||||
id: 'dh_done',
|
||||
type: 'digital-human-generation',
|
||||
title: '数字人模板',
|
||||
status: 'running',
|
||||
progress: 1,
|
||||
progressTotal: 5,
|
||||
result: {},
|
||||
},
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('必需节点映射已准备好');
|
||||
expect(text).not.toContain('缺少映射:输出节点');
|
||||
|
||||
const avatarInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
||||
));
|
||||
const audioInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴音频 URL'
|
||||
));
|
||||
await act(async () => {
|
||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
||||
});
|
||||
const modelInput = findComfyModelInput(root);
|
||||
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
||||
expect(modelInput.props.list).toBe('digital-human-comfy-api-models');
|
||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
||||
expect(modelOptions).toContain('wan2.1-digital-human');
|
||||
expect(modelOptions).toContain('seedance-1-0-lite-t2v-250428');
|
||||
await act(async () => {
|
||||
modelInput.props.onChange({ target: { value: 'seedance-1-0-pro-t2v-250528' } });
|
||||
});
|
||||
|
||||
const generateButton = findButton(root, '生成讲解视频');
|
||||
expect(generateButton.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
generateButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.createDigitalHumanJob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
templateId: 'tpl_digital_human',
|
||||
avatarUrl: 'https://cdn.example.com/avatar.png',
|
||||
referenceAudioUrl: 'https://cdn.example.com/voice.wav',
|
||||
comfyApiModel: 'seedance-1-0-pro-t2v-250528',
|
||||
nodeMapping: expect.objectContaining({
|
||||
model: { nodeId: '5', field: 'model' },
|
||||
output: { nodeId: '', field: 'video' },
|
||||
}),
|
||||
}));
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('shows internal recharge guidance when a user digital human task hits quota', async () => {
|
||||
userApiMock.getWorkflows.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
publicId: 'tpl_digital_human_quota',
|
||||
name: '数字人模板',
|
||||
visibility: 'template',
|
||||
isTemplate: true,
|
||||
graph: {
|
||||
metadata: {
|
||||
digitalHuman: {
|
||||
nodeMapping: {
|
||||
prompt: { nodeId: '1', field: 'prompt' },
|
||||
script: { nodeId: '2', field: 'text' },
|
||||
avatar: { nodeId: '3', field: 'image' },
|
||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const quotaError = new Error('下游密钥成本额度已用完') as any;
|
||||
quotaError.status = 429;
|
||||
quotaError.billing = {
|
||||
rechargePath: '/user/recharge',
|
||||
usagePath: '/user/usage',
|
||||
};
|
||||
userApiMock.createDigitalHumanJob.mockRejectedValue(quotaError);
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman('/user/digital-human');
|
||||
const avatarInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
||||
));
|
||||
const audioInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴音频 URL'
|
||||
));
|
||||
await act(async () => {
|
||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(root, '生成讲解视频').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
const links = root.root.findAll((node) => node.type === 'a');
|
||||
expect(text).toContain('账户额度不足');
|
||||
expect(text).toContain('去充值');
|
||||
expect(links.some((link) => linkTarget(link) === '/user/recharge')).toBe(true);
|
||||
expect(links.some((link) => linkTarget(link) === '/user/usage')).toBe(true);
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the template Comfy API model when video_models is empty', async () => {
|
||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [],
|
||||
names: [],
|
||||
});
|
||||
apiMock.getWorkflows.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
publicId: 'tpl_digital_human_template_model',
|
||||
name: '模板内模型数字人',
|
||||
visibility: 'template',
|
||||
isTemplate: true,
|
||||
graph: {
|
||||
metadata: {
|
||||
digitalHuman: {
|
||||
nodeMapping: {
|
||||
prompt: { nodeId: '1', field: 'prompt' },
|
||||
script: { nodeId: '2', field: 'text' },
|
||||
model: { nodeId: '5', field: 'model' },
|
||||
avatar: { nodeId: '3', field: 'image' },
|
||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
|
||||
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
|
||||
'3': { class_type: 'LoadImage', inputs: { image: '' } },
|
||||
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
|
||||
'5': {
|
||||
class_type: 'ByteDanceDigitalHumanNode',
|
||||
inputs: { model: 'seedance-1-0-lite-t2v' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.createDigitalHumanJob.mockResolvedValue({
|
||||
success: true,
|
||||
job: {
|
||||
id: 'dh_template_model',
|
||||
type: 'digital-human-generation',
|
||||
title: '数字人讲解任务',
|
||||
status: 'running',
|
||||
progress: 1,
|
||||
progressTotal: 5,
|
||||
result: {},
|
||||
},
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
const modelInput = findComfyModelInput(root);
|
||||
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
||||
expect(collectText(root.root)).toContain('已使用模板内 Comfy API 模型 seedance-1-0-lite-t2v-250428');
|
||||
|
||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
||||
expect(modelOptions).toEqual(['seedance-1-0-lite-t2v-250428']);
|
||||
|
||||
const avatarInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
||||
));
|
||||
const audioInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴音频 URL'
|
||||
));
|
||||
await act(async () => {
|
||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
||||
});
|
||||
|
||||
const generateButton = findButton(root, '生成讲解视频');
|
||||
expect(generateButton.props.disabled).toBe(false);
|
||||
await act(async () => {
|
||||
generateButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.createDigitalHumanJob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
templateId: 'tpl_digital_human_template_model',
|
||||
comfyApiModel: 'seedance-1-0-lite-t2v-250428',
|
||||
}));
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a Comfy API video model when neither video_models nor the template provide one', async () => {
|
||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [],
|
||||
names: [],
|
||||
});
|
||||
apiMock.getWorkflows.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
publicId: 'tpl_digital_human_no_model',
|
||||
name: '无模型字段数字人模板',
|
||||
visibility: 'template',
|
||||
isTemplate: true,
|
||||
graph: {
|
||||
metadata: {
|
||||
digitalHuman: {
|
||||
nodeMapping: {
|
||||
prompt: { nodeId: '1', field: 'prompt' },
|
||||
script: { nodeId: '2', field: 'text' },
|
||||
avatar: { nodeId: '3', field: 'image' },
|
||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
|
||||
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
|
||||
'3': { class_type: 'LoadImage', inputs: { image: '' } },
|
||||
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.createDigitalHumanJob.mockResolvedValue({
|
||||
success: true,
|
||||
job: {
|
||||
id: 'dh_default_model',
|
||||
type: 'digital-human-generation',
|
||||
title: '数字人讲解任务',
|
||||
status: 'running',
|
||||
progress: 1,
|
||||
progressTotal: 5,
|
||||
result: {},
|
||||
},
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
const modelInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.list === 'digital-human-comfy-api-models'
|
||||
));
|
||||
expect(modelInput.props.value).toBe('');
|
||||
expect(collectText(root.root)).toContain('未读取到 Comfy API 视频模型,请先在 Comfy 模板或 video_models 中配置模型');
|
||||
|
||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
||||
expect(modelOptions).toEqual([]);
|
||||
|
||||
const avatarInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
||||
));
|
||||
const audioInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴音频 URL'
|
||||
));
|
||||
await act(async () => {
|
||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
||||
});
|
||||
|
||||
const generateButton = findButton(root, '生成讲解视频');
|
||||
expect(generateButton.props.disabled).toBe(true);
|
||||
await act(async () => {
|
||||
generateButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.createDigitalHumanJob).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('filters non-video route aliases out of the Comfy API model picker', async () => {
|
||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{ name: 'gpt-4o' },
|
||||
{ name: 'seedance-1-0-pro-t2v-250528' },
|
||||
{ name: 'gpt-image-2' },
|
||||
],
|
||||
names: ['gpt-4o', 'seedance-1-0-pro-t2v-250528', 'gpt-image-2'],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
||||
expect(modelOptions).toContain('seedance-1-0-pro-t2v-250528');
|
||||
expect(modelOptions).not.toContain('seedance-1-0-lite-t2v-250428');
|
||||
expect(modelOptions).not.toContain('gpt-4o');
|
||||
expect(modelOptions).not.toContain('gpt-image-2');
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reads Comfy API video models from item payloads and normalizes old Seedance aliases', async () => {
|
||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{ value: 'seedance-1-0-lite-t2v' },
|
||||
{ filename: 'wan2.2-digital-human' },
|
||||
{ model: 'gpt-image-2' },
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
const modelInput = findComfyModelInput(root);
|
||||
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
||||
expect(modelOptions).toContain('seedance-1-0-lite-t2v-250428');
|
||||
expect(modelOptions).toContain('wan2.2-digital-human');
|
||||
expect(modelOptions).not.toContain('seedance-1-0-lite-t2v');
|
||||
expect(modelOptions).not.toContain('gpt-image-2');
|
||||
expect(collectText(root.root)).toContain('优先使用 Comfy API video_models;已读取 2 个视频模型');
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a manually selected Comfy API video model after data refresh', async () => {
|
||||
apiMock.getWorkflows.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
publicId: 'tpl_digital_human',
|
||||
name: '数字人模板',
|
||||
visibility: 'template',
|
||||
isTemplate: true,
|
||||
graph: {
|
||||
metadata: {
|
||||
digitalHuman: {
|
||||
nodeMapping: {
|
||||
prompt: { nodeId: '1', field: 'prompt' },
|
||||
script: { nodeId: '2', field: 'text' },
|
||||
avatar: { nodeId: '3', field: 'image' },
|
||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
'5': {
|
||||
class_type: 'ByteDanceDigitalHumanNode',
|
||||
inputs: { model: 'seedance-1-0-lite-t2v-250428' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
const modelInput = findComfyModelInput(root);
|
||||
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
||||
|
||||
await act(async () => {
|
||||
modelInput.props.onChange({ target: { value: 'seedance-1-0-pro-t2v-250528' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(root, '刷新').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const refreshedModelInput = findComfyModelInput(root);
|
||||
expect(refreshedModelInput.props.value).toBe('seedance-1-0-pro-t2v-250528');
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to digital-human capable templates instead of generic workflows', async () => {
|
||||
apiMock.getWorkflows.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
publicId: 'tpl_generic_image',
|
||||
name: '普通生图模板',
|
||||
visibility: 'template',
|
||||
isTemplate: true,
|
||||
graph: {
|
||||
prompt: {
|
||||
'1': { class_type: 'CLIPTextEncode', inputs: { text: '' } },
|
||||
'2': { class_type: 'KSampler', inputs: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
publicId: 'tpl_digital_human_ready',
|
||||
name: '数字人可用模板',
|
||||
visibility: 'public',
|
||||
isTemplate: false,
|
||||
graph: {
|
||||
prompt: {
|
||||
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
|
||||
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
|
||||
'3': { class_type: 'LoadImage', inputs: { image: '' } },
|
||||
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
|
||||
'5': { class_type: 'VHS_VideoCombine', inputs: { filename_prefix: 'dh' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('数字人可用模板');
|
||||
expect(text).not.toContain('普通生图模板');
|
||||
|
||||
const templateSelect = root.root.find((node) => (
|
||||
node.type === 'select'
|
||||
&& node.props.value === 'tpl_digital_human_ready'
|
||||
));
|
||||
expect(templateSelect).toBeTruthy();
|
||||
expect(collectText(root.root)).toContain('必需节点映射已准备好');
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('auto-infers required mappings from a plain Comfy workflow graph', async () => {
|
||||
apiMock.getWorkflows.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
id: 2,
|
||||
publicId: 'tpl_plain_comfy',
|
||||
name: '普通 Comfy 数字人',
|
||||
visibility: 'public',
|
||||
isTemplate: false,
|
||||
graph: {
|
||||
prompt: {
|
||||
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
|
||||
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
|
||||
'3': { class_type: 'LoadImage', inputs: { image: '' } },
|
||||
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
|
||||
'5': { class_type: 'VHS_VideoCombine', inputs: { filename_prefix: 'dh' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
expect(collectText(root.root)).toContain('必需节点映射已准备好');
|
||||
expect(collectText(root.root)).not.toContain('缺少映射');
|
||||
|
||||
const avatarInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
||||
));
|
||||
const audioInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '或粘贴音频 URL'
|
||||
));
|
||||
await act(async () => {
|
||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
||||
});
|
||||
|
||||
const generateButton = findButton(root, '生成讲解视频');
|
||||
expect(generateButton.props.disabled).toBe(false);
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces remote Comfy outputs and workspace assets on completed jobs', async () => {
|
||||
apiMock.getDigitalHumanJobs.mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
id: 'dh_remote_result',
|
||||
type: 'digital-human-generation',
|
||||
title: '数字人讲解任务',
|
||||
status: 'succeeded',
|
||||
progress: 5,
|
||||
progressTotal: 5,
|
||||
createdAt: '2026-06-08T05:00:00Z',
|
||||
result: {
|
||||
promptId: 'prompt-remote-123456',
|
||||
keyId: 88,
|
||||
keyName: 'comfyui-agent',
|
||||
templateName: '数字人模板',
|
||||
script: 'BossAI 数字人讲解脚本',
|
||||
request: {
|
||||
comfyApiModel: 'seedance-1-0-pro-t2v-250528',
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
kind: 'video',
|
||||
status: 'ready',
|
||||
remoteUrl: 'https://cdn.example.com/digital-human/output.mp4',
|
||||
},
|
||||
{
|
||||
publicId: 'asset_audio_result',
|
||||
kind: 'audio',
|
||||
status: 'ready',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman();
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Prompt prompt-remot');
|
||||
expect(text).toContain('数字人模板');
|
||||
expect(text).toContain('comfyui-agent');
|
||||
expect(text).toContain('远端产物 · 视频');
|
||||
expect(text).toContain('工作空间 · 音频 · asset_audio_result');
|
||||
|
||||
const remoteLink = root.root.find((node) => (
|
||||
node.type === 'a'
|
||||
&& node.props.href === 'https://cdn.example.com/digital-human/output.mp4'
|
||||
));
|
||||
expect(remoteLink.props.target).toBe('_blank');
|
||||
expect(remoteLink.props.rel).toBe('noreferrer');
|
||||
|
||||
const workspaceLink = root.root.find((node) => (
|
||||
node.type === 'a'
|
||||
&& node.props.href === '/workspace?q=asset_audio_result'
|
||||
));
|
||||
expect(workspaceLink.props.target).toBeUndefined();
|
||||
|
||||
const traceLinks = root.root.findAll((node) => node.type === 'a');
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=seedance-1-0-pro-t2v-250528&status=success&downstreamKeyId=88')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=seedance-1-0-pro-t2v-250528&status=failed&downstreamKeyId=88')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=seedance-1-0-pro-t2v-250528')).toBe(true);
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('links user digital human tasks back to self-service usage records', async () => {
|
||||
userApiMock.getDigitalHumanJobs.mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
id: 'dh_user_done',
|
||||
type: 'digital-human-generation',
|
||||
title: '数字人讲解任务',
|
||||
status: 'succeeded',
|
||||
progress: 5,
|
||||
progressTotal: 5,
|
||||
createdAt: '2026-06-08T05:00:00Z',
|
||||
result: {
|
||||
keyId: 88,
|
||||
keyName: '讲解 Key',
|
||||
templateName: '数字人模板',
|
||||
script: 'BossAI 数字人讲解脚本',
|
||||
request: {
|
||||
comfyApiModel: 'seedance-1-0-lite-t2v-250428',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderDigitalHuman('/user/digital-human');
|
||||
const traceLinks = root.root.findAll((node) => node.type === 'a');
|
||||
expect(userApiMock.getDigitalHumanJobs).toHaveBeenCalled();
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=BossAI+%E6%95%B0%E5%AD%97%E4%BA%BA%E8%AE%B2%E8%A7%A3%E8%84%9A%E6%9C%AC&key_id=88&key=%E8%AE%B2%E8%A7%A3+Key&status=success')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=BossAI+%E6%95%B0%E5%AD%97%E4%BA%BA%E8%AE%B2%E8%A7%A3%E8%84%9A%E6%9C%AC&key_id=88&key=%E8%AE%B2%E8%A7%A3+Key&status=failed')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=seedance-1-0-lite-t2v-250428')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link).startsWith('/logs'))).toBe(false);
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps side JSON panels bounded on mobile viewports', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).not.toContain('max-height: 280px;');
|
||||
expect(css).toContain('.digital-human-side pre {\n max-height: min(280px, calc(100dvh - var(--topbar-height) - 320px));');
|
||||
expect(css).toContain('overscroll-behavior: contain;');
|
||||
expect(css).toContain('.digital-human-side pre {\n max-height: calc(100dvh - var(--topbar-height) - 360px);');
|
||||
});
|
||||
|
||||
it('keeps template, picker, and upload control grids shrink-safe on phones', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));');
|
||||
expect(css).toContain('.digital-human-template-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr));');
|
||||
expect(css).toContain('.digital-human-upload-actions {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(132px, 100%), 1fr));');
|
||||
expect(css).toContain('.digital-human-picker-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr));');
|
||||
expect(css).not.toContain('grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));');
|
||||
});
|
||||
});
|
||||
+1038
-966
File diff suppressed because it is too large
Load Diff
@@ -466,7 +466,7 @@ export default function MoneyPrinter() {
|
||||
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
|
||||
const [scriptModel, setScriptModel] = useState('gpt-5.4');
|
||||
const [videoModel, setVideoModel] = useState(DEFAULT_BOOSAPI_VIDEO_MODEL);
|
||||
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('comfy');
|
||||
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('boosapi');
|
||||
const [style, setStyle] = useState(STYLE_PRESETS[0]);
|
||||
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
|
||||
const [duration, setDuration] = useState(30);
|
||||
@@ -493,6 +493,7 @@ export default function MoneyPrinter() {
|
||||
const activeJobFilterTabId = `money-printer-filter-tab-${jobFilter}`;
|
||||
const activeJobFilterPanelId = `money-printer-filter-panel-${jobFilter}`;
|
||||
const isCloudComfy = status?.comfy?.backend === 'cloud' && status?.builtinCloudTemplate?.enabled !== false;
|
||||
const moneyPrinterDisabled = Boolean(status?.disabled);
|
||||
const cloudDurationMin = status?.builtinCloudTemplate?.duration?.min ?? 3;
|
||||
const cloudDurationMax = status?.builtinCloudTemplate?.duration?.max ?? 12;
|
||||
const cloudDefaultDuration = status?.builtinCloudTemplate?.duration?.default ?? DEFAULT_BUILTIN_CLOUD_DURATION;
|
||||
@@ -602,6 +603,7 @@ export default function MoneyPrinter() {
|
||||
const hasSelectedComfyApiModel = Boolean(comfyApiModel.trim());
|
||||
const canSubmit = Boolean(topic.trim())
|
||||
&& !submitting
|
||||
&& !moneyPrinterDisabled
|
||||
&& (videoBackend !== 'comfy' || (comfyHasTemplate && hasSelectedComfyApiModel));
|
||||
const comfyApiModelHint = templateComfyApiModel
|
||||
? (hasConfiguredComfyApiVideoModels
|
||||
@@ -844,6 +846,11 @@ export default function MoneyPrinter() {
|
||||
) : error ? (
|
||||
<div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>
|
||||
) : null}
|
||||
{moneyPrinterDisabled ? (
|
||||
<div className="alert alert-warning" style={{ marginBottom: 12 }}>
|
||||
{status?.disabledReason || '短视频工厂暂时只支持本地模型部署。'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="money-printer-summary">
|
||||
<div className="money-printer-summary-card">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildRechargeRecordsCsv,
|
||||
formatComfyBalanceMicros,
|
||||
formatDurationSeconds,
|
||||
formatRechargeRecordTime,
|
||||
paymentFieldLabel,
|
||||
} from './PaymentManagement.js';
|
||||
|
||||
describe('PaymentManagement Comfy balance formatting', () => {
|
||||
it('formats normalized Comfy balance micros as USD', () => {
|
||||
expect(formatComfyBalanceMicros(49_800_000, 'usd')).toBe('49.8 usd');
|
||||
});
|
||||
|
||||
it('localizes Comfy balance and diagnostics field labels', () => {
|
||||
expect(paymentFieldLabel('amountMicros')).toBe('账户余额');
|
||||
expect(paymentFieldLabel('effectiveBalanceMicros')).toBe('可用余额');
|
||||
expect(paymentFieldLabel('prepaidBalanceMicros')).toBe('预付余额');
|
||||
expect(paymentFieldLabel('cloudCreditBalanceMicros')).toBe('云积分余额');
|
||||
expect(paymentFieldLabel('pendingChargesMicros')).toBe('待扣费用');
|
||||
expect(paymentFieldLabel('currency')).toBe('币种');
|
||||
expect(paymentFieldLabel('diagnostics.httpStatus')).toBe('HTTP 状态');
|
||||
});
|
||||
|
||||
it('formats sqlite UTC timestamps into local display time', () => {
|
||||
expect(formatRechargeRecordTime('2026-06-16 05:02:16', 'Asia/Shanghai')).toBe('2026/06/16 13:02:16');
|
||||
});
|
||||
|
||||
it('formats live Comfy login cache countdown', () => {
|
||||
expect(formatDurationSeconds(2064)).toBe('34 分 24 秒');
|
||||
expect(formatDurationSeconds(9)).toBe('9 秒');
|
||||
expect(formatDurationSeconds(0)).toBe('需重新换取');
|
||||
});
|
||||
|
||||
it('exports recharge records as csv with readable timestamps', () => {
|
||||
const csv = buildRechargeRecordsCsv([
|
||||
{
|
||||
publicId: 'rch_test',
|
||||
userId: 7,
|
||||
userEmail: 'user@example.com',
|
||||
userName: '测试用户',
|
||||
amountUsd: 50,
|
||||
provider: 'comfy_cloud_checkout',
|
||||
status: 'completed',
|
||||
note: '管理员已确认到账',
|
||||
createdAt: '2026-06-16 04:59:05',
|
||||
completedAt: '2026-06-16 05:01:00',
|
||||
},
|
||||
], 'Asia/Shanghai');
|
||||
|
||||
expect(csv).toContain('"测试用户","user@example.com","50","completed"');
|
||||
expect(csv).toContain('"2026/06/16 12:59:05"');
|
||||
expect(csv).toContain('"2026/06/16 13:01:00"');
|
||||
expect(csv).toContain('"rch_test"');
|
||||
});
|
||||
|
||||
it('keeps admin recharge actions explicit and compact', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/PaymentManagement.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('取消支付');
|
||||
expect(source).toContain('adminCancelRecharge');
|
||||
expect(source).toContain('onClick={exportRechargeRecords}');
|
||||
expect(source).toContain('setLiveCacheRemainingSecs');
|
||||
expect(source).toContain('lastTokenRefreshAttemptRef');
|
||||
expect(source).toContain('导出');
|
||||
expect(source).not.toContain('导出 CSV');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,916 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
api,
|
||||
type AdminComfyCloudBalanceResponse,
|
||||
type ComfyCloudBalance,
|
||||
type ComfyCloudDiagnostics,
|
||||
type PaymentConfigResponse,
|
||||
} from '../api.js';
|
||||
import PageHeader from '../components/PageHeader.js';
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === 'string' && error.trim()) return error.trim();
|
||||
const message = (error as any)?.message;
|
||||
return typeof message === 'string' && message.trim() ? message.trim() : fallback;
|
||||
}
|
||||
|
||||
function formatBoolean(value: boolean | undefined): string {
|
||||
if (value === true) return '已启用';
|
||||
if (value === false) return '未启用';
|
||||
return '--';
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined): string {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? String(value) : '--';
|
||||
}
|
||||
|
||||
function formatDurationSeconds(value: number | null | undefined): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return '需重新换取';
|
||||
}
|
||||
const total = Math.max(0, Math.floor(value));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
if (minutes <= 0) return `${seconds} 秒`;
|
||||
return `${minutes} 分 ${seconds} 秒`;
|
||||
}
|
||||
|
||||
const PAYMENT_FIELD_LABELS: Record<string, string> = {
|
||||
amountMicros: '账户余额',
|
||||
effectiveBalanceMicros: '可用余额',
|
||||
prepaidBalanceMicros: '预付余额',
|
||||
cloudCreditBalanceMicros: '云积分余额',
|
||||
pendingChargesMicros: '待扣费用',
|
||||
currency: '币种',
|
||||
'diagnostics.tokenKind': 'Token 类型',
|
||||
'diagnostics.tokenSource': 'Token 来源',
|
||||
'diagnostics.httpStatus': 'HTTP 状态',
|
||||
'diagnostics.apiBaseUrl': '上游 API 地址',
|
||||
'diagnostics.jwtIssuer': 'JWT 签发方',
|
||||
'diagnostics.jwtAudience': 'JWT 受众',
|
||||
'diagnostics.upstreamMessage': '上游返回信息',
|
||||
};
|
||||
|
||||
export function paymentFieldLabel(label: string): string {
|
||||
return PAYMENT_FIELD_LABELS[label] || label;
|
||||
}
|
||||
|
||||
export { formatDurationSeconds };
|
||||
|
||||
type RechargeRecord = {
|
||||
publicId: string;
|
||||
userId: number;
|
||||
userEmail?: string | null;
|
||||
userName?: string | null;
|
||||
amountUsd?: number | null;
|
||||
provider: string;
|
||||
status: string;
|
||||
label?: string | null;
|
||||
title?: string | null;
|
||||
note?: string | null;
|
||||
createdAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
};
|
||||
|
||||
export function formatComfyBalanceMicros(value: number | null | undefined, currency: string): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '--';
|
||||
const amount = value / 1_000_000;
|
||||
return `${amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 6,
|
||||
})} ${currency || ''}`.trim();
|
||||
}
|
||||
|
||||
export function formatRechargeRecordTime(value?: string | null, timeZone?: string): string {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '--';
|
||||
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)
|
||||
? `${raw.replace(' ', 'T')}Z`
|
||||
: raw;
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return raw;
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const text = value == null ? '' : String(value);
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function buildRechargeRecordsCsv(records: RechargeRecord[], timeZone?: string): string {
|
||||
const header = ['用户', '邮箱', '金额USD', '状态', 'Provider', '创建时间', '完成时间', '记录ID', '备注'];
|
||||
const rows = records.map((record) => [
|
||||
record.userName || record.userEmail || `用户 #${record.userId}`,
|
||||
record.userEmail || '',
|
||||
typeof record.amountUsd === 'number' && Number.isFinite(record.amountUsd) ? record.amountUsd : '',
|
||||
record.status,
|
||||
record.provider,
|
||||
formatRechargeRecordTime(record.createdAt, timeZone),
|
||||
formatRechargeRecordTime(record.completedAt, timeZone),
|
||||
record.publicId,
|
||||
record.note || '',
|
||||
]);
|
||||
return [header, ...rows].map((row) => row.map(csvCell).join(',')).join('\n');
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: 14,
|
||||
background: 'var(--color-surface)',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{paymentFieldLabel(label)}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text-primary)', wordBreak: 'break-word' }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BalanceField({
|
||||
label,
|
||||
value,
|
||||
currency,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null | undefined;
|
||||
currency: string;
|
||||
}) {
|
||||
return <Field label={label} value={formatComfyBalanceMicros(value, currency)} />;
|
||||
}
|
||||
|
||||
export default function PaymentManagement() {
|
||||
const [config, setConfig] = useState<PaymentConfigResponse | null>(null);
|
||||
const [balanceResponse, setBalanceResponse] = useState<AdminComfyCloudBalanceResponse | null>(null);
|
||||
const [diagnostics, setDiagnostics] = useState<ComfyCloudDiagnostics | null>(null);
|
||||
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||
const [loadingBalance, setLoadingBalance] = useState(false);
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [configError, setConfigError] = useState('');
|
||||
const [balanceError, setBalanceError] = useState('');
|
||||
const [settingsMessage, setSettingsMessage] = useState('');
|
||||
const [settingsError, setSettingsError] = useState('');
|
||||
const [customerTokenInput, setCustomerTokenInput] = useState('');
|
||||
const [customerTokenEndpointInput, setCustomerTokenEndpointInput] = useState('');
|
||||
const [firebaseApiKeyInput, setFirebaseApiKeyInput] = useState('');
|
||||
const [googleOAuthClientIdInput, setGoogleOAuthClientIdInput] = useState('');
|
||||
const [googleOAuthClientSecretInput, setGoogleOAuthClientSecretInput] = useState('');
|
||||
const [firebaseRefreshTokenInput, setFirebaseRefreshTokenInput] = useState('');
|
||||
const [checkoutEndpointInput, setCheckoutEndpointInput] = useState('');
|
||||
const [rechargeAmount, setRechargeAmount] = useState('10');
|
||||
const [rechargeError, setRechargeError] = useState('');
|
||||
const [rechargeMessage, setRechargeMessage] = useState('');
|
||||
const [rechargeLoading, setRechargeLoading] = useState(false);
|
||||
const [rechargeRecords, setRechargeRecords] = useState<RechargeRecord[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
const [recordsError, setRecordsError] = useState('');
|
||||
const [confirmingId, setConfirmingId] = useState('');
|
||||
const [cancellingId, setCancellingId] = useState('');
|
||||
const [clearingRecords, setClearingRecords] = useState(false);
|
||||
const [liveCacheRemainingSecs, setLiveCacheRemainingSecs] = useState<number | null>(null);
|
||||
const tokenRefreshInFlightRef = useRef(false);
|
||||
const lastTokenRefreshAttemptRef = useRef(0);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoadingConfig(true);
|
||||
setConfigError('');
|
||||
try {
|
||||
const payload = await api.getPaymentConfig();
|
||||
setConfig(payload);
|
||||
} catch (error) {
|
||||
setConfigError(getErrorMessage(error, '支付配置加载失败'));
|
||||
} finally {
|
||||
setLoadingConfig(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshBalance = useCallback(async () => {
|
||||
setLoadingBalance(true);
|
||||
setBalanceError('');
|
||||
try {
|
||||
const payload = await api.getComfyCloudBalance();
|
||||
setBalanceResponse(payload);
|
||||
setDiagnostics(null);
|
||||
} catch (error) {
|
||||
setBalanceError(getErrorMessage(error, 'Comfy Cloud 余额查询失败'));
|
||||
try {
|
||||
const result = await api.getComfyCloudDiagnostics();
|
||||
setDiagnostics(result.diagnostics || null);
|
||||
} catch {
|
||||
setDiagnostics(null);
|
||||
}
|
||||
} finally {
|
||||
setLoadingBalance(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const payment = config?.payment;
|
||||
const checkout = config?.comfyCloud?.checkout;
|
||||
const customerTokenConfigured = checkout?.customerTokenConfigured;
|
||||
const customerTokenEndpoint = typeof checkout?.customerTokenEndpoint === 'string' ? checkout.customerTokenEndpoint : '';
|
||||
const checkoutEndpoint = typeof checkout?.checkoutEndpoint === 'string' ? checkout.checkoutEndpoint : '';
|
||||
const firebaseLogin = checkout?.firebaseLogin;
|
||||
const balance: ComfyCloudBalance | null = balanceResponse?.balance || null;
|
||||
const balanceCurrency = String(balance?.currency || payment?.currency || '').trim();
|
||||
const finishedRechargeCount = useMemo(
|
||||
() => rechargeRecords.filter((record) => ['completed', 'cancelled', 'failed'].includes(record.status)).length,
|
||||
[rechargeRecords],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCheckoutEndpointInput(checkoutEndpoint || '');
|
||||
}, [checkoutEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomerTokenEndpointInput(customerTokenEndpoint || '');
|
||||
}, [customerTokenEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (customerTokenConfigured === true) {
|
||||
void refreshBalance();
|
||||
}
|
||||
}, [customerTokenConfigured, refreshBalance]);
|
||||
|
||||
useEffect(() => {
|
||||
setLiveCacheRemainingSecs(
|
||||
typeof firebaseLogin?.cacheRemainingSecs === 'number'
|
||||
? firebaseLogin.cacheRemainingSecs
|
||||
: null,
|
||||
);
|
||||
}, [firebaseLogin?.cacheRemainingSecs, firebaseLogin?.lastRefreshAtUnix]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!firebaseLogin?.configured || firebaseLogin.invalidGrant) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
setLiveCacheRemainingSecs((current) => {
|
||||
if (typeof current !== 'number' || !Number.isFinite(current)) return current;
|
||||
return Math.max(0, current - 1);
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [firebaseLogin?.configured, firebaseLogin?.invalidGrant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!firebaseLogin?.configured || firebaseLogin.invalidGrant || firebaseLogin.lastRefreshOk === false) {
|
||||
return;
|
||||
}
|
||||
if (typeof liveCacheRemainingSecs !== 'number' || liveCacheRemainingSecs > 60) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (tokenRefreshInFlightRef.current || now - lastTokenRefreshAttemptRef.current < 30_000) {
|
||||
return;
|
||||
}
|
||||
tokenRefreshInFlightRef.current = true;
|
||||
lastTokenRefreshAttemptRef.current = now;
|
||||
void (async () => {
|
||||
try {
|
||||
await refreshBalance();
|
||||
await loadConfig();
|
||||
} finally {
|
||||
tokenRefreshInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
firebaseLogin?.configured,
|
||||
firebaseLogin?.invalidGrant,
|
||||
firebaseLogin?.lastRefreshOk,
|
||||
liveCacheRemainingSecs,
|
||||
loadConfig,
|
||||
refreshBalance,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!firebaseLogin?.configured || firebaseLogin.invalidGrant) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
void loadConfig();
|
||||
}, 20_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [firebaseLogin?.configured, firebaseLogin?.invalidGrant, loadConfig]);
|
||||
|
||||
const saveSettings = useCallback(async () => {
|
||||
setSavingSettings(true);
|
||||
setSettingsError('');
|
||||
setSettingsMessage('');
|
||||
try {
|
||||
await api.updateComfyCloudPaymentSettings({
|
||||
customerToken: customerTokenInput.trim() || undefined,
|
||||
customerTokenEndpoint: customerTokenEndpointInput.trim(),
|
||||
firebaseApiKey: firebaseApiKeyInput.trim() || undefined,
|
||||
googleOAuthClientId: googleOAuthClientIdInput.trim() || undefined,
|
||||
googleOAuthClientSecret: googleOAuthClientSecretInput.trim() || undefined,
|
||||
firebaseRefreshToken: firebaseRefreshTokenInput.trim() || undefined,
|
||||
checkoutEndpoint: checkoutEndpointInput.trim(),
|
||||
});
|
||||
setCustomerTokenInput('');
|
||||
setFirebaseApiKeyInput('');
|
||||
setGoogleOAuthClientSecretInput('');
|
||||
setFirebaseRefreshTokenInput('');
|
||||
setSettingsMessage('Comfy 支付配置已保存,余额诊断和用户充值会立即使用新配置。');
|
||||
await loadConfig();
|
||||
} catch (error) {
|
||||
setSettingsError(getErrorMessage(error, 'Comfy 支付配置保存失败'));
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
}, [checkoutEndpointInput, customerTokenEndpointInput, customerTokenInput, firebaseApiKeyInput, firebaseRefreshTokenInput, googleOAuthClientIdInput, googleOAuthClientSecretInput, loadConfig]);
|
||||
|
||||
const clearCustomerToken = useCallback(async () => {
|
||||
setSavingSettings(true);
|
||||
setSettingsError('');
|
||||
setSettingsMessage('');
|
||||
try {
|
||||
await api.updateComfyCloudPaymentSettings({
|
||||
clearCustomerToken: true,
|
||||
customerTokenEndpoint: customerTokenEndpointInput.trim(),
|
||||
checkoutEndpoint: checkoutEndpointInput.trim(),
|
||||
});
|
||||
setCustomerTokenInput('');
|
||||
setSettingsMessage('已清空数据库中的 Comfy Customer Token 覆盖;如 config/env 有默认值会继续生效。');
|
||||
await loadConfig();
|
||||
} catch (error) {
|
||||
setSettingsError(getErrorMessage(error, 'Comfy Customer Token 清空失败'));
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
}, [checkoutEndpointInput, customerTokenEndpointInput, loadConfig]);
|
||||
|
||||
const startOAuth = useCallback(async () => {
|
||||
setSavingSettings(true);
|
||||
setSettingsError('');
|
||||
setSettingsMessage('');
|
||||
try {
|
||||
// Save any pending Firebase API key first so the login page mints with it.
|
||||
if (firebaseApiKeyInput.trim()) {
|
||||
await api.updateComfyCloudPaymentSettings({
|
||||
firebaseApiKey: firebaseApiKeyInput.trim() || undefined,
|
||||
customerTokenEndpoint: customerTokenEndpointInput.trim(),
|
||||
checkoutEndpoint: checkoutEndpointInput.trim(),
|
||||
});
|
||||
}
|
||||
const loginPath = '/api/admin/payment/comfy-cloud/firebase-login';
|
||||
if (window.location.hostname === 'localhost') {
|
||||
// 已在本机 localhost:直接同源跳登录页(Firebase 信任 localhost)。
|
||||
window.location.href = loginPath;
|
||||
} else {
|
||||
// 线上域名(tybossai.com 等):Firebase 不信任此域,跳本机 localhost
|
||||
// 登录页并带回执地址,登录成功后自动跳回本页。需本机已建 SSH 隧道。
|
||||
const port = window.location.port || '4000';
|
||||
const returnTo = encodeURIComponent(window.location.href);
|
||||
window.location.href = `http://localhost:${port}${loginPath}?return_to=${returnTo}`;
|
||||
}
|
||||
} catch (error) {
|
||||
setSettingsError(getErrorMessage(error, 'Comfy Cloud Google 登录失败'));
|
||||
setSavingSettings(false);
|
||||
}
|
||||
}, [checkoutEndpointInput, customerTokenEndpointInput, firebaseApiKeyInput]);
|
||||
|
||||
const startRecharge = useCallback(async () => {
|
||||
setRechargeError('');
|
||||
setRechargeMessage('');
|
||||
const amount = Number(rechargeAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setRechargeError('请输入有效的金额(USD)');
|
||||
return;
|
||||
}
|
||||
setRechargeLoading(true);
|
||||
try {
|
||||
const result = await api.adminComfyCloudRecharge(amount);
|
||||
const url = result?.checkout?.url;
|
||||
if (!url) {
|
||||
throw new Error('充值会话已创建,但未返回 Checkout URL');
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
setRechargeMessage(`已生成 $${amount} 的 Comfy Cloud Checkout 链接,请在新窗口完成付款后点「刷新 Comfy Cloud 余额」。`);
|
||||
} catch (error) {
|
||||
setRechargeError(getErrorMessage(error, 'Comfy 充值失败'));
|
||||
} finally {
|
||||
setRechargeLoading(false);
|
||||
}
|
||||
}, [rechargeAmount]);
|
||||
|
||||
const loadRechargeRecords = useCallback(async () => {
|
||||
setRecordsLoading(true);
|
||||
setRecordsError('');
|
||||
try {
|
||||
const result = await api.adminListRechargeRecords(100);
|
||||
setRechargeRecords(Array.isArray(result?.records) ? result.records : []);
|
||||
} catch (error) {
|
||||
setRecordsError(getErrorMessage(error, '充值记录加载失败'));
|
||||
} finally {
|
||||
setRecordsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRechargeRecords();
|
||||
}, [loadRechargeRecords]);
|
||||
|
||||
const confirmRecord = useCallback(async (publicId: string) => {
|
||||
setConfirmingId(publicId);
|
||||
setRecordsError('');
|
||||
try {
|
||||
await api.adminConfirmRecharge(publicId);
|
||||
await loadRechargeRecords();
|
||||
} catch (error) {
|
||||
setRecordsError(getErrorMessage(error, '确认到账失败'));
|
||||
} finally {
|
||||
setConfirmingId('');
|
||||
}
|
||||
}, [loadRechargeRecords]);
|
||||
|
||||
const cancelRecord = useCallback(async (publicId: string) => {
|
||||
if (cancellingId) return;
|
||||
const ok = window.confirm('确认取消这笔待支付记录吗?取消后不会给用户增加额度。');
|
||||
if (!ok) return;
|
||||
setCancellingId(publicId);
|
||||
setRecordsError('');
|
||||
try {
|
||||
await api.adminCancelRecharge(publicId);
|
||||
await loadRechargeRecords();
|
||||
setRechargeMessage('已取消待支付记录。');
|
||||
} catch (error) {
|
||||
setRecordsError(getErrorMessage(error, '取消支付失败'));
|
||||
} finally {
|
||||
setCancellingId('');
|
||||
}
|
||||
}, [cancellingId, loadRechargeRecords]);
|
||||
|
||||
const exportRechargeRecords = useCallback(() => {
|
||||
const csv = buildRechargeRecordsCsv(rechargeRecords);
|
||||
const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
link.href = url;
|
||||
link.download = `boosapi-recharge-records-${stamp}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [rechargeRecords]);
|
||||
|
||||
const clearFinishedRechargeRecords = useCallback(async () => {
|
||||
if (finishedRechargeCount <= 0 || clearingRecords) return;
|
||||
const ok = window.confirm(`将清理 ${finishedRechargeCount} 条已结束充值记录,待确认记录会保留。继续吗?`);
|
||||
if (!ok) return;
|
||||
setClearingRecords(true);
|
||||
setRecordsError('');
|
||||
try {
|
||||
const result = await api.adminClearRechargeRecords();
|
||||
await loadRechargeRecords();
|
||||
setRechargeMessage(`已清理 ${result.cleared || 0} 条已结束充值记录,待确认记录已保留。`);
|
||||
} catch (error) {
|
||||
setRecordsError(getErrorMessage(error, '清理充值记录失败'));
|
||||
} finally {
|
||||
setClearingRecords(false);
|
||||
}
|
||||
}, [clearingRecords, finishedRechargeCount, loadRechargeRecords]);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in admin-ops-page payment-ops-page" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<PageHeader
|
||||
title="支付管理"
|
||||
subtitle="Comfy Cloud 登录、充值与到账确认。"
|
||||
actions={(
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void loadConfig()} disabled={loadingConfig}>
|
||||
{loadingConfig ? '加载中...' : '刷新配置'}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{configError && <div className="alert alert-error">{configError}</div>}
|
||||
|
||||
<section style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--color-panel)',
|
||||
padding: 18,
|
||||
}} aria-label="Comfy Cloud Token 持久化">
|
||||
{customerTokenConfigured === false && (
|
||||
<div className="alert alert-warning" style={{ marginTop: 16 }}>
|
||||
Comfy Cloud 登录凭证未配置。点下方「Google 登录 Comfy Cloud」使用你的 Google 账号直接授权(无需注册 OAuth 应用),系统会自动保存 Firebase Refresh Token;也可以直接粘贴从 cloud.comfy.org 抓取的 Firebase Refresh Token 或 Bearer JWT。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{firebaseLogin?.configured && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
border: `1px solid ${firebaseLogin.invalidGrant ? 'var(--color-danger, #dc2626)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: 14,
|
||||
background: firebaseLogin.invalidGrant
|
||||
? 'color-mix(in srgb, var(--color-danger, #dc2626) 8%, var(--color-surface))'
|
||||
: 'var(--color-surface)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 14, color: 'var(--color-text-primary)' }}>Comfy 登录态</strong>
|
||||
<span
|
||||
className={
|
||||
firebaseLogin.invalidGrant
|
||||
? 'badge badge-warning'
|
||||
: firebaseLogin.lastRefreshOk === false
|
||||
? 'badge badge-warning'
|
||||
: 'badge badge-success'
|
||||
}
|
||||
>
|
||||
{firebaseLogin.invalidGrant
|
||||
? '已失效,需重新登录'
|
||||
: firebaseLogin.lastRefreshOk === false
|
||||
? '上次刷新失败'
|
||||
: firebaseLogin.lastRefreshOk === true
|
||||
? '正常'
|
||||
: '已配置'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}>
|
||||
<Field label="Refresh Token" value={firebaseLogin.configured ? '已保存' : '未配置'} />
|
||||
<Field
|
||||
label="ID Token 缓存剩余"
|
||||
value={formatDurationSeconds(liveCacheRemainingSecs)}
|
||||
/>
|
||||
<Field
|
||||
label="上次刷新"
|
||||
value={
|
||||
firebaseLogin.lastRefreshAtUnix
|
||||
? new Date(firebaseLogin.lastRefreshAtUnix * 1000).toLocaleString('zh-CN')
|
||||
: '尚未刷新'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{firebaseLogin.invalidGrant && (
|
||||
<div className="alert alert-error" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
登录态已失效(可能在 cloud.comfy.org 主动登出或改密)。请点下方「Google 登录 Comfy Cloud」通过 localhost 重新登录。
|
||||
{firebaseLogin.lastRefreshDetail ? ` 详情:${firebaseLogin.lastRefreshDetail}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{!firebaseLogin.invalidGrant && firebaseLogin.lastRefreshOk === false && firebaseLogin.lastRefreshDetail && (
|
||||
<div className="alert alert-warning" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
{firebaseLogin.lastRefreshDetail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: 14,
|
||||
background: 'var(--color-surface)',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 6 }}>
|
||||
Comfy Customer Token 持久化
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
点「Google 登录 Comfy Cloud」直接用你的 Google 账号授权,无需注册任何 OAuth 应用。登录成功后系统自动保存 Firebase Refresh Token,之后充值和查余额全程自动换取 token。
|
||||
<br/>
|
||||
<strong style={{ color: 'var(--color-text-primary)' }}>注意:</strong> Firebase 只允许 localhost 和 Comfy 自己的域名走弹窗登录。如果你在 tybossai.com 等线上域名访问遇到「unauthorized-domain」,请改用以下任一方式:
|
||||
<br/>① 通过 <code>http://localhost:4000/payment</code> 访问 BoosAPI,登录一次后 refresh token 会持久保存到数据库,线上域名也会自动使用;
|
||||
<br/>② 直接从 cloud.comfy.org 浏览器 IndexedDB(<code>firebaseLocalStorageDb</code> → 找 <code>stsTokenManager.refreshToken</code>)复制后粘到下方「Firebase Refresh Token」字段。
|
||||
</div>
|
||||
{settingsError && <div className="alert alert-error" style={{ marginBottom: 12 }}>{settingsError}</div>}
|
||||
{settingsMessage && <div className="alert alert-success" style={{ marginBottom: 12 }}>{settingsMessage}</div>}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||
gap: 12,
|
||||
}}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Comfy Session / Bearer JWT</span>
|
||||
<input
|
||||
className="input"
|
||||
value={customerTokenInput}
|
||||
onChange={(event) => setCustomerTokenInput(event.target.value)}
|
||||
placeholder="session=eyJ... 或 Bearer eyJ..."
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Firebase Web API Key(可选)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={firebaseApiKeyInput}
|
||||
onChange={(event) => setFirebaseApiKeyInput(event.target.value)}
|
||||
placeholder="留空使用 Comfy Cloud 默认 Firebase Key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Google OAuth Client ID</span>
|
||||
<input
|
||||
className="input"
|
||||
value={googleOAuthClientIdInput}
|
||||
onChange={(event) => setGoogleOAuthClientIdInput(event.target.value)}
|
||||
placeholder="xxx.apps.googleusercontent.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Google OAuth Client Secret</span>
|
||||
<input
|
||||
className="input"
|
||||
value={googleOAuthClientSecretInput}
|
||||
onChange={(event) => setGoogleOAuthClientSecretInput(event.target.value)}
|
||||
placeholder="GOCSPX-..."
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Firebase Refresh Token</span>
|
||||
<input
|
||||
className="input"
|
||||
value={firebaseRefreshTokenInput}
|
||||
onChange={(event) => setFirebaseRefreshTokenInput(event.target.value)}
|
||||
placeholder="可选:粘贴后即可自动充值/查余额,无需 Google 授权"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Token Provider Endpoint</span>
|
||||
<input
|
||||
className="input"
|
||||
value={customerTokenEndpointInput}
|
||||
onChange={(event) => setCustomerTokenEndpointInput(event.target.value)}
|
||||
placeholder="https://your-service.example/comfy-token"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Checkout Endpoint</span>
|
||||
<input
|
||||
className="input"
|
||||
value={checkoutEndpointInput}
|
||||
onChange={(event) => setCheckoutEndpointInput(event.target.value)}
|
||||
placeholder="/customers/credit"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void saveSettings()} disabled={savingSettings}>
|
||||
{savingSettings ? '保存中...' : '保存 Comfy 配置'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void startOAuth()} disabled={savingSettings}>
|
||||
Google 登录 Comfy Cloud
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void clearCustomerToken()} disabled={savingSettings}>
|
||||
清空运行时 Token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--color-panel)',
|
||||
padding: 18,
|
||||
}} aria-label="Comfy Cloud 余额">
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>Comfy Cloud</div>
|
||||
<strong style={{ fontSize: 18, color: 'var(--color-text-primary)' }}>余额诊断</strong>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void refreshBalance()} disabled={loadingBalance}>
|
||||
{loadingBalance ? '查询中...' : '刷新 Comfy Cloud 余额'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{balanceError && <div className="alert alert-error" style={{ marginBottom: 16 }}>{balanceError}</div>}
|
||||
{diagnostics && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<Field label="diagnostics.tokenKind" value={String(diagnostics.tokenKind || '--')} />
|
||||
<Field label="diagnostics.tokenSource" value={String(diagnostics.tokenSource || '--')} />
|
||||
<Field label="diagnostics.httpStatus" value={formatNumber(diagnostics.httpStatus)} />
|
||||
<Field label="diagnostics.apiBaseUrl" value={String(diagnostics.apiBaseUrl || '--')} />
|
||||
<Field label="diagnostics.jwtIssuer" value={String(diagnostics.jwtIssuer || '--')} />
|
||||
<Field label="diagnostics.jwtAudience" value={String(diagnostics.jwtAudience || '--')} />
|
||||
<Field label="diagnostics.upstreamMessage" value={String(diagnostics.upstreamMessage || '--')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
|
||||
gap: 12,
|
||||
}}>
|
||||
<BalanceField label="amountMicros" value={balance?.amountMicros} currency={balanceCurrency} />
|
||||
<BalanceField label="effectiveBalanceMicros" value={balance?.effectiveBalanceMicros} currency={balanceCurrency} />
|
||||
<BalanceField label="prepaidBalanceMicros" value={balance?.prepaidBalanceMicros} currency={balanceCurrency} />
|
||||
<BalanceField label="cloudCreditBalanceMicros" value={balance?.cloudCreditBalanceMicros} currency={balanceCurrency} />
|
||||
<BalanceField label="pendingChargesMicros" value={balance?.pendingChargesMicros} currency={balanceCurrency} />
|
||||
<Field label="currency" value={balanceCurrency || '--'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--color-panel)',
|
||||
padding: 18,
|
||||
}} aria-label="Comfy Cloud 充值">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>Comfy Cloud</div>
|
||||
<strong style={{ fontSize: 18, color: 'var(--color-text-primary)' }}>充值积分</strong>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6 }}>
|
||||
直接调用 Comfy Cloud 的 customer credit API 创建一笔充值会话,新窗口打开后用绑定的支付方式付款即可到账。需要 Comfy Cloud 登录凭证已配置(上方 Refresh Token / Customer Token / Token Endpoint 任一)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rechargeError && <div className="alert alert-error" style={{ marginBottom: 12 }}>{rechargeError}</div>}
|
||||
{rechargeMessage && <div className="alert alert-success" style={{ marginBottom: 12 }}>{rechargeMessage}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 180 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>金额(USD)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={rechargeAmount}
|
||||
onChange={(event) => setRechargeAmount(event.target.value)}
|
||||
placeholder="例如 10"
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{[5, 10, 20, 50, 100].map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setRechargeAmount(String(preset))}
|
||||
disabled={rechargeLoading}
|
||||
>
|
||||
${preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void startRecharge()}
|
||||
disabled={rechargeLoading || !customerTokenConfigured}
|
||||
>
|
||||
{rechargeLoading ? '生成 Checkout 链接...' : '立即充值'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{customerTokenConfigured === false && (
|
||||
<div className="alert alert-warning" style={{ marginTop: 12 }}>
|
||||
还没配置 Comfy Cloud 登录凭证,无法创建充值会话。请先回到上方完成 Google 登录或粘贴 Refresh Token / Customer Token。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--color-panel)',
|
||||
padding: 18,
|
||||
}} aria-label="用户充值记录">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>用户充值</div>
|
||||
<strong style={{ fontSize: 18, color: 'var(--color-text-primary)' }}>充值记录与确认到账</strong>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6 }}>
|
||||
Comfy Cloud 支付无自动回调。用户付款后,在此点「确认到账」给对应用户的 API Key 增加额度(独立计费池)。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void loadRechargeRecords()}
|
||||
disabled={recordsLoading || clearingRecords}
|
||||
>
|
||||
{recordsLoading ? '加载中...' : '刷新记录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={exportRechargeRecords}
|
||||
disabled={rechargeRecords.length === 0}
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void clearFinishedRechargeRecords()}
|
||||
disabled={finishedRechargeCount === 0 || clearingRecords}
|
||||
>
|
||||
{clearingRecords ? '清理中...' : `清理已结束 ${finishedRechargeCount}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recordsError && <div className="alert alert-error" style={{ marginBottom: 12 }}>{recordsError}</div>}
|
||||
|
||||
{rechargeRecords.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: 120 }}>
|
||||
<div className="empty-state-title">暂无充值记录</div>
|
||||
<div className="empty-state-desc">用户在充值页发起充值后会出现在这里。</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rechargeRecords.map((rec) => {
|
||||
const pending = rec.status === 'pending';
|
||||
const tone = rec.status === 'completed' ? 'badge badge-success'
|
||||
: rec.status === 'cancelled' ? 'badge badge-warning' : 'badge';
|
||||
return (
|
||||
<div key={rec.publicId} style={{
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-surface)',
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
{rec.userName || rec.userEmail || `用户 #${rec.userId}`}
|
||||
<span style={{ marginLeft: 8, fontSize: 13, color: 'var(--color-primary)' }}>
|
||||
${typeof rec.amountUsd === 'number' ? rec.amountUsd : '--'}
|
||||
</span>
|
||||
<span className={tone} style={{ marginLeft: 8 }}>{rec.status}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{rec.provider} · 创建 {formatRechargeRecordTime(rec.createdAt)}
|
||||
{rec.completedAt ? ` · 完成 ${formatRechargeRecordTime(rec.completedAt)}` : ''}
|
||||
{' · '}
|
||||
<code style={{ fontSize: 11 }}>{rec.publicId}</code>
|
||||
</div>
|
||||
</div>
|
||||
{pending ? (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void confirmRecord(rec.publicId)}
|
||||
disabled={confirmingId === rec.publicId || cancellingId === rec.publicId}
|
||||
>
|
||||
{confirmingId === rec.publicId ? '确认中...' : '确认到账'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void cancelRecord(rec.publicId)}
|
||||
disabled={confirmingId === rec.publicId || cancellingId === rec.publicId}
|
||||
>
|
||||
{cancellingId === rec.publicId ? '取消中...' : '取消支付'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{rec.status === 'completed' ? '已到账' : '—'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -985,20 +985,56 @@ export default function PictureAgent() {
|
||||
|
||||
return (
|
||||
<div className="picture-agent-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">生图助手</h1>
|
||||
<p className="page-subtitle">
|
||||
{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
||||
</p>
|
||||
<section className="console-hero">
|
||||
<div className="console-hero-copy">
|
||||
<span className="console-kicker">Picture Studio</span>
|
||||
<h1>生图助手</h1>
|
||||
<p>{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<div className="console-hero-rail" aria-label="生图助手摘要">
|
||||
<div>
|
||||
<span>队列</span>
|
||||
<strong>{activeJobs.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>已完成</span>
|
||||
<strong>{jobs.filter((job) => job.status === 'succeeded').length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>图库</span>
|
||||
<strong>{images.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-hero-actions">
|
||||
<span className="picture-agent-status-pill">{audience === 'user' ? '普通用户' : '管理员'}</span>
|
||||
<select className="picture-agent-select" value={model} onChange={(event) => setModel(event.target.value)}>
|
||||
{models.length === 0 && <option value={model || 'gpt-image-2'}>{model || 'gpt-image-2'}</option>}
|
||||
{models.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="picture-agent-summary">
|
||||
<div className="picture-agent-summary-card">
|
||||
<span>当前模型</span>
|
||||
<strong>{model || '默认'}</strong>
|
||||
<em>{status?.upstreamLabel || 'BoosAPI 图片路由'}</em>
|
||||
</div>
|
||||
<div className="picture-agent-summary-card">
|
||||
<span>进行中</span>
|
||||
<strong>{activeJobs.length} 个</strong>
|
||||
<em>{activeJobs.length > 0 ? '生成中…' : '队列空闲'}</em>
|
||||
</div>
|
||||
<div className="picture-agent-summary-card">
|
||||
<span>已完成</span>
|
||||
<strong>{jobs.filter((job) => job.status === 'succeeded').length} 个</strong>
|
||||
<em>{jobs.filter((job) => job.status === 'failed' || job.status === 'cancelled').length} 个失败/取消</em>
|
||||
</div>
|
||||
<div className="picture-agent-summary-card">
|
||||
<span>图库</span>
|
||||
<strong>{images.length} 张</strong>
|
||||
<em>最多保留 48 张</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-shell">
|
||||
|
||||
+52
-12
@@ -81,6 +81,7 @@ type RuntimeSettings = {
|
||||
logCleanupProgramLogsEnabled: boolean;
|
||||
logCleanupRetentionDays: number;
|
||||
modelAvailabilityProbeEnabled: boolean;
|
||||
registrationDisabled: boolean;
|
||||
codexUpstreamWebsocketEnabled: boolean;
|
||||
responsesCompactFallbackToResponsesEnabled: boolean;
|
||||
disableCrossProtocolFallback: boolean;
|
||||
@@ -363,6 +364,7 @@ export default function Settings() {
|
||||
logCleanupProgramLogsEnabled: false,
|
||||
logCleanupRetentionDays: 30,
|
||||
modelAvailabilityProbeEnabled: false,
|
||||
registrationDisabled: false,
|
||||
codexUpstreamWebsocketEnabled: false,
|
||||
responsesCompactFallbackToResponsesEnabled: false,
|
||||
disableCrossProtocolFallback: false,
|
||||
@@ -386,6 +388,7 @@ export default function Settings() {
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
|
||||
const [savingModelAvailabilityProbe, setSavingModelAvailabilityProbe] = useState(false);
|
||||
const [savingRegistrationDisabled, setSavingRegistrationDisabled] = useState(false);
|
||||
const [savingProxyTransport, setSavingProxyTransport] = useState(false);
|
||||
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
|
||||
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
|
||||
@@ -748,7 +751,7 @@ export default function Settings() {
|
||||
? `${runtime.adminIpAllowlist.length} 条白名单`
|
||||
: '未限制 IP';
|
||||
const modelGovernanceSummary = [
|
||||
blockedBrands.length > 0 ? `${blockedBrands.length} 个品牌屏蔽` : '',
|
||||
blockedBrands.length > 0 ? `${blockedBrands.length} 个推荐屏蔽词` : '',
|
||||
allowedModels.length > 0 ? `${allowedModels.length} 个模型白名单` : '',
|
||||
].filter(Boolean).join(' / ') || '全部模型开放';
|
||||
const settingsSections: SettingsNavItem[] = [
|
||||
@@ -861,6 +864,7 @@ export default function Settings() {
|
||||
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
|
||||
: 30,
|
||||
modelAvailabilityProbeEnabled: !!runtimeInfo.modelAvailabilityProbeEnabled,
|
||||
registrationDisabled: !!runtimeInfo.registrationDisabled,
|
||||
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
|
||||
responsesCompactFallbackToResponsesEnabled: !!runtimeInfo.responsesCompactFallbackToResponsesEnabled,
|
||||
disableCrossProtocolFallback: !!runtimeInfo.disableCrossProtocolFallback,
|
||||
@@ -1061,6 +1065,24 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const persistRegistrationDisabled = async (disabled: boolean) => {
|
||||
setSavingRegistrationDisabled(true);
|
||||
// Optimistic toggle so the checkbox responds immediately.
|
||||
setRuntime((prev) => ({ ...prev, registrationDisabled: disabled }));
|
||||
try {
|
||||
const res = await api.updateRuntimeSettings({ registrationDisabled: disabled });
|
||||
const next = typeof res?.registrationDisabled === 'boolean' ? res.registrationDisabled : disabled;
|
||||
setRuntime((prev) => ({ ...prev, registrationDisabled: next }));
|
||||
toast.success(next ? '已禁止用户注册' : '已允许用户注册');
|
||||
} catch (err: any) {
|
||||
// Revert on failure.
|
||||
setRuntime((prev) => ({ ...prev, registrationDisabled: !disabled }));
|
||||
toast.error(err?.message || '保存失败');
|
||||
} finally {
|
||||
setSavingRegistrationDisabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveModelAvailabilityProbeSettings = async () => {
|
||||
if (runtime.modelAvailabilityProbeEnabled === savedModelAvailabilityProbeEnabled) {
|
||||
toast.info('批量测活设置未变化');
|
||||
@@ -1277,15 +1299,15 @@ export default function Settings() {
|
||||
const resolved = Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands;
|
||||
setRuntime((prev) => ({ ...prev, globalBlockedBrands: resolved }));
|
||||
setBlockedBrands(resolved);
|
||||
toast.success('品牌屏蔽设置已保存');
|
||||
toast.success('模型品牌推荐屏蔽词已保存');
|
||||
try {
|
||||
await api.rebuildRoutes(false);
|
||||
toast.success('路由已重建');
|
||||
} catch {
|
||||
toast.error('品牌屏蔽已保存,但路由重建失败,请手动重建');
|
||||
toast.error('模型品牌推荐屏蔽词已保存,但路由重建失败,请手动重建');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存品牌屏蔽设置失败');
|
||||
toast.error(err?.message || '保存模型品牌推荐屏蔽词失败');
|
||||
} finally {
|
||||
setSavingBrandFilter(false);
|
||||
}
|
||||
@@ -2572,17 +2594,17 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Brand Filter */}
|
||||
{/* Model Brand Recommendation Filter */}
|
||||
<div id="settings-governance" className="card animate-slide-up stagger-6" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||
<div style={settingsModernHeaderStyle}>
|
||||
<div style={settingsModernTitleBlockStyle}>
|
||||
<div style={settingsModernTitleStyle}>全局品牌屏蔽</div>
|
||||
<div style={settingsModernTitleStyle}>模型品牌推荐屏蔽词</div>
|
||||
<div style={settingsModernDescriptionStyle}>
|
||||
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
|
||||
用于 LLM 对话推荐和模型路由推荐:选中某个模型品牌后,路由重建会跳过匹配该品牌的模型。这里不是屏蔽 BoosAPI 平台品牌。
|
||||
</div>
|
||||
</div>
|
||||
<span style={getSettingsPillStyle(blockedBrands.length > 0 ? 'warning' : 'neutral')}>
|
||||
{blockedBrands.length > 0 ? `已屏蔽 ${blockedBrands.length}` : '未屏蔽'}
|
||||
{blockedBrands.length > 0 ? `已设置 ${blockedBrands.length}` : '未设置'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
@@ -2612,19 +2634,19 @@ export default function Settings() {
|
||||
);
|
||||
})}
|
||||
{allBrandNames === null && (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载品牌列表中...</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载模型品牌中...</span>
|
||||
)}
|
||||
{allBrandNames !== null && allBrandNames.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可用品牌</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可用模型品牌</span>
|
||||
)}
|
||||
</div>
|
||||
{blockedBrands.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-warning)', marginBottom: 10 }}>
|
||||
已屏蔽 {blockedBrands.length} 个品牌:{blockedBrands.join('、')}
|
||||
已设置 {blockedBrands.length} 个模型品牌推荐屏蔽词:{blockedBrands.join('、')}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleSaveBrandFilter} disabled={savingBrandFilter} className="btn btn-primary" style={{ fontSize: 12, padding: '6px 16px' }}>
|
||||
{savingBrandFilter ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存品牌屏蔽'}
|
||||
{savingBrandFilter ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存模型品牌推荐屏蔽词'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2970,6 +2992,24 @@ export default function Settings() {
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '12px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border-light)', marginBottom: 14, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={runtime.registrationDisabled}
|
||||
disabled={savingRegistrationDisabled}
|
||||
onChange={(e) => void persistRegistrationDisabled(e.target.checked)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)' }}>禁止用户自助注册</span>
|
||||
<span style={{ display: 'block', fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.6 }}>
|
||||
开启后 <code style={{ fontFamily: 'var(--font-mono)' }}>/api/users/register</code> 会直接拒绝(403),仅管理员可在后台手动创建用户。已注册用户不受影响。
|
||||
{savingRegistrationDisabled ? ' 保存中...' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
当前识别到的管理端 IP(由服务端判定):
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import PageHeader from '../components/PageHeader.js';
|
||||
|
||||
type BalanceRow = {
|
||||
userId: number;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
role?: string | null;
|
||||
status?: string | null;
|
||||
keyCount: number;
|
||||
usedCost: number;
|
||||
maxCost: number | null;
|
||||
remainingCost: number | null;
|
||||
unlimitedKeyCount: number;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === 'string' && error.trim()) return error.trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function fmtCost(value: number | null | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return '不限';
|
||||
return `$${value.toFixed(6)}`;
|
||||
}
|
||||
|
||||
export default function UserBalances() {
|
||||
const [rows, setRows] = useState<BalanceRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [adjustingId, setAdjustingId] = useState<number | null>(null);
|
||||
const [adjustInputs, setAdjustInputs] = useState<Record<number, string>>({});
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const load = useCallback(async (targetPage: number, query: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.listUserBalances({ page: targetPage, pageSize: 50, q: query || undefined });
|
||||
setRows(Array.isArray(res?.data) ? res.data : []);
|
||||
setTotalPages(res?.meta?.totalPages || 1);
|
||||
setPage(res?.meta?.page || targetPage);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, '加载用户余额失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(1, '');
|
||||
}, [load]);
|
||||
|
||||
const applyAdjust = useCallback(async (userId: number, sign: 1 | -1) => {
|
||||
const raw = (adjustInputs[userId] || '').trim();
|
||||
const amount = Number(raw);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setError('请输入正数金额');
|
||||
return;
|
||||
}
|
||||
setAdjustingId(userId);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const res = await api.adjustUserBalance(userId, sign * amount);
|
||||
setMessage(`用户 #${userId} 额度已${sign > 0 ? '增加' : '扣减'} $${amount},当前剩余 ${fmtCost(res.data.remainingCost)}`);
|
||||
setAdjustInputs((prev) => ({ ...prev, [userId]: '' }));
|
||||
await load(page, q);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, '调整额度失败'));
|
||||
} finally {
|
||||
setAdjustingId(null);
|
||||
}
|
||||
}, [adjustInputs, load, page, q]);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in admin-ops-page user-balances-ops-page" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<PageHeader
|
||||
title="用户余额管理"
|
||||
subtitle="查看每个用户的额度池(API Key 的 max_cost − used_cost),可直接增减额度。"
|
||||
actions={(
|
||||
<div className="page-actions user-balances-page-actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void load(1, q); }}
|
||||
placeholder="搜索用户名 / 邮箱 / ID"
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void load(1, q)} disabled={loading}>
|
||||
{loading ? '加载中...' : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{message && <div className="alert alert-success">{message}</div>}
|
||||
|
||||
<section style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--color-panel)',
|
||||
padding: 18,
|
||||
}}>
|
||||
{rows.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: 160 }}>
|
||||
<div className="empty-state-title">{loading ? '加载中...' : '没有用户'}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.userId} style={{
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-surface)',
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
{row.username || row.email || `用户 #${row.userId}`}
|
||||
<span style={{ marginLeft: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>#{row.userId}</span>
|
||||
{row.role === 'admin' && <span className="badge" style={{ marginLeft: 8 }}>管理员</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{row.email || ''} · {row.keyCount} 个 Key
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>剩余 / 上限</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-primary)' }}>
|
||||
{fmtCost(row.remainingCost)} / {fmtCost(row.maxCost)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>已用 {fmtCost(row.usedCost)}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={adjustInputs[row.userId] || ''}
|
||||
onChange={(e) => setAdjustInputs((prev) => ({ ...prev, [row.userId]: e.target.value }))}
|
||||
placeholder="$"
|
||||
style={{ width: 90 }}
|
||||
disabled={adjustingId === row.userId}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void applyAdjust(row.userId, 1)}
|
||||
disabled={adjustingId === row.userId}
|
||||
>
|
||||
{adjustingId === row.userId ? '...' : '加额度'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void applyAdjust(row.userId, -1)}
|
||||
disabled={adjustingId === row.userId}
|
||||
>
|
||||
扣减
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 14 }}>
|
||||
<button type="button" className="btn btn-ghost" disabled={page <= 1 || loading} onClick={() => void load(page - 1, q)}>上一页</button>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-muted)', alignSelf: 'center' }}>{page} / {totalPages}</span>
|
||||
<button type="button" className="btn btn-ghost" disabled={page >= totalPages || loading} onClick={() => void load(page + 1, q)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,15 @@ describe('UserDashboard recharge entry', () => {
|
||||
successRequests: 4,
|
||||
totalCost: 0.12,
|
||||
},
|
||||
billing: {
|
||||
keyCount: 1,
|
||||
usedCost: 0.12,
|
||||
maxCost: 50,
|
||||
remainingCost: 49.88,
|
||||
unlimitedKeyCount: 0,
|
||||
rechargePath: '/user/recharge',
|
||||
usagePath: '/user/usage',
|
||||
},
|
||||
recentLogs: [],
|
||||
});
|
||||
});
|
||||
@@ -107,6 +116,8 @@ describe('UserDashboard recharge entry', () => {
|
||||
expect(text).toContain('用户自助工作台');
|
||||
expect(text).toContain('密钥、额度、使用日志一处处理');
|
||||
expect(text).toContain('1/1 个密钥启用');
|
||||
expect(text).toContain('剩余 10,525 积分');
|
||||
expect(text).toContain('当前剩余:10,525 积分');
|
||||
expect(text).toContain('已消耗 $0.120000');
|
||||
expect(text).toContain('调用状态正常');
|
||||
expect(root.root.findAll((node) => isInternalLinkTo(node, '/user/dashboard/keys')).length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { userApi } from '../api.js';
|
||||
import { userApi, type UserBillingSummary } from '../api.js';
|
||||
import { clearSession, getSessionToken } from '../authSession.js';
|
||||
import UserAvatar from '../components/UserAvatar.js';
|
||||
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
||||
import { formatRechargePointBalance } from '../userRecharge.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
|
||||
interface UserProfile {
|
||||
@@ -61,6 +62,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [summary, setSummary] = useState<UserSummary | null>(null);
|
||||
const [billing, setBilling] = useState<UserBillingSummary | null>(null);
|
||||
const [recentLogs, setRecentLogs] = useState<LogItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -82,6 +84,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
setProfile(dashboardRes.user || null);
|
||||
setKeys(Array.isArray(dashboardRes.keys) ? dashboardRes.keys : []);
|
||||
setSummary(dashboardRes.summary || { totalRequests: 0, successRequests: 0, totalCost: 0 });
|
||||
setBilling(dashboardRes.billing || null);
|
||||
setRecentLogs(Array.isArray(dashboardRes.recentLogs) ? dashboardRes.recentLogs : []);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载失败');
|
||||
@@ -91,6 +94,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
}
|
||||
setKeys([]);
|
||||
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
|
||||
setBilling(null);
|
||||
setRecentLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -139,6 +143,9 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
const failedRequests = Math.max(0, (summary?.totalRequests || 0) - (summary?.successRequests || 0));
|
||||
const activeKeyCount = keys.filter((key) => key.enabled).length;
|
||||
const lastActivityLabel = recentLogs[0] ? formatDateLabel(recentLogs[0].created_at) : '暂无调用';
|
||||
const pointBalanceLabel = billing
|
||||
? formatRechargePointBalance(billing.remainingCost, billing.maxCost)
|
||||
: '等待余额同步';
|
||||
const primarySuggestion = activeKeyCount === 0
|
||||
? {
|
||||
title: '先创建一个 API 密钥',
|
||||
@@ -208,8 +215,8 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
},
|
||||
{
|
||||
label: '额度',
|
||||
title: `已消耗 ${formatMoney(summary?.totalCost)}`,
|
||||
detail: '系统内选择充值档位,再跳转 Comfy Checkout 完成付款。',
|
||||
title: `剩余 ${pointBalanceLabel}`,
|
||||
detail: `已消耗 ${formatMoney(summary?.totalCost)},系统内选择充值档位后跳转 Comfy Checkout。`,
|
||||
to: '/user/recharge',
|
||||
action: '去充值',
|
||||
},
|
||||
@@ -271,7 +278,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
<div>
|
||||
<span>账户充值</span>
|
||||
<strong>添加积分后继续调用模型与 Comfy API</strong>
|
||||
<p>进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。</p>
|
||||
<p>当前剩余:{pointBalanceLabel}。进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。</p>
|
||||
</div>
|
||||
<UserRechargeOptions internalOnly />
|
||||
</section>
|
||||
|
||||
@@ -127,6 +127,7 @@ describe('UserKeys billing center', () => {
|
||||
expect(text).toContain('20 / 100');
|
||||
expect(text).toContain('$0.120000 / $1.500');
|
||||
expect(text).toContain('账单累计 $0.456000');
|
||||
expect(text).toContain('当前剩余 291 积分');
|
||||
expect(text).toContain('1,280 tokens');
|
||||
expect(text).toContain('计费运行建议');
|
||||
expect(text).toContain('额度状态正常');
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { userApi } from '../api.js';
|
||||
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
||||
import { USER_RECHARGE_CONFIG } from '../userRecharge.js';
|
||||
import { USER_RECHARGE_CONFIG, formatRechargePointBalance } from '../userRecharge.js';
|
||||
import { buildUserUsageHref } from './helpers/userUsageLinks.js';
|
||||
|
||||
interface ApiKey {
|
||||
@@ -421,6 +421,9 @@ export default function UserKeys() {
|
||||
const usedCost = keys.reduce((sum, item) => sum + asNumber(item.usedCost), 0);
|
||||
const maxCost = keys.reduce((sum, item) => sum + (hasPositiveLimit(item.maxCost) ? Number(item.maxCost) : 0), 0);
|
||||
const hasUnlimitedCost = keys.some((item) => !hasPositiveLimit(item.maxCost));
|
||||
const limitedCostKeys = keys.filter((item) => hasPositiveLimit(item.maxCost));
|
||||
const limitedCostLimit = limitedCostKeys.reduce((sum, item) => sum + Number(item.maxCost), 0);
|
||||
const limitedCostUsed = limitedCostKeys.reduce((sum, item) => sum + asNumber(item.usedCost), 0);
|
||||
const totalLogs = asNumber(usageSummary?.total, recentLogs.length);
|
||||
const failedLogs = asNumber(usageSummary?.failed, recentLogs.filter((log) => log.status !== 'success').length);
|
||||
const totalCost = asNumber(usageSummary?.total_cost ?? usageSummary?.totalCost, usedCost);
|
||||
@@ -438,6 +441,8 @@ export default function UserKeys() {
|
||||
? `${formatCount(usedRequests)} / 不限`
|
||||
: `${formatCount(usedRequests)} / ${formatCount(maxRequests)}`,
|
||||
usedCost,
|
||||
remainingCost: limitedCostLimit > 0 ? Math.max(0, limitedCostLimit - limitedCostUsed) : null,
|
||||
maxCost: limitedCostLimit > 0 ? limitedCostLimit : null,
|
||||
costLabel: hasUnlimitedCost || !maxCost
|
||||
? `${formatMoney(usedCost)} / 不限`
|
||||
: `${formatMoney(usedCost)} / ${formatMoney(maxCost)}`,
|
||||
@@ -474,7 +479,8 @@ export default function UserKeys() {
|
||||
<section className="user-keys-recharge-panel" aria-label="账户充值">
|
||||
<div>
|
||||
<span>账户余额</span>
|
||||
<strong>需要更多额度时,可跳转到 {recharge.providerLabel} 添加积分。</strong>
|
||||
<strong>当前剩余 {formatRechargePointBalance(overview.remainingCost, overview.maxCost)}</strong>
|
||||
<p>需要更多额度时,可跳转到 {recharge.providerLabel} 添加积分。</p>
|
||||
</div>
|
||||
<UserRechargeOptions compact internalOnly />
|
||||
</section>
|
||||
|
||||
@@ -125,15 +125,19 @@ describe('UserRecharge page', () => {
|
||||
));
|
||||
const primaryButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).includes('创建记录并支付')
|
||||
&& String(node.props.className || '').includes('user-recharge-primary-action')
|
||||
));
|
||||
|
||||
expect(text).toContain('账户充值');
|
||||
expect(text).toContain('为模型和 Comfy API 添加积分');
|
||||
expect(text).toContain('$1 = 211 积分');
|
||||
expect(text).toContain('10,550 积分');
|
||||
expect(text).toContain(USER_RECHARGE_CONFIG.providerLabel);
|
||||
expect(text).toContain('充值记录');
|
||||
expect(text).toContain('rch_test_1');
|
||||
expect(text).toContain('付款后由管理员核对到账。');
|
||||
expect(text).toContain('291 积分');
|
||||
expect(text).toContain('已用 $0.1200 / 剩余 $1.3800');
|
||||
expect(optionButtons).toHaveLength(payableOptions.length);
|
||||
expect(text).not.toContain('待配置');
|
||||
expect(disabledOptions).toHaveLength(0);
|
||||
@@ -195,6 +199,50 @@ describe('UserRecharge page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('calculates points for a custom amount before creating checkout', async () => {
|
||||
let root: ReturnType<typeof create>;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/recharge']}>
|
||||
<UserRecharge />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
try {
|
||||
const amountInput = root!.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['aria-label'] === '自定义充值金额,单位美元'
|
||||
));
|
||||
await act(async () => {
|
||||
amountInput.props.onChange({ target: { value: '12' } });
|
||||
});
|
||||
|
||||
expect(collectText(root!.root)).toContain('2,532 积分');
|
||||
|
||||
const customButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).includes('按自定义金额支付')
|
||||
));
|
||||
await act(async () => {
|
||||
customButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(userApiMock.createRechargeCheckout).toHaveBeenCalledWith({
|
||||
optionId: 'custom-usd-12',
|
||||
amountUsd: 12,
|
||||
});
|
||||
} finally {
|
||||
root!.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to current-tab navigation when the checkout popup is blocked', async () => {
|
||||
(window.open as any).mockReturnValueOnce(null);
|
||||
let root: ReturnType<typeof create>;
|
||||
@@ -213,7 +261,7 @@ describe('UserRecharge page', () => {
|
||||
try {
|
||||
const primaryButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).includes('创建记录并支付')
|
||||
&& String(node.props.className || '').includes('user-recharge-primary-action')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { userApi, type UserRechargeRecord } from '../api.js';
|
||||
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
||||
import { USER_RECHARGE_CONFIG, type UserRechargeCheckoutOption } from '../userRecharge.js';
|
||||
import {
|
||||
USER_RECHARGE_CONFIG,
|
||||
USER_RECHARGE_POINTS_PER_USD,
|
||||
calculateRechargePoints,
|
||||
formatRechargePointBalance,
|
||||
formatRechargePoints,
|
||||
type UserRechargeCheckoutOption,
|
||||
} from '../userRecharge.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
|
||||
function formatUsd(value?: number | null) {
|
||||
@@ -11,6 +18,15 @@ function formatUsd(value?: number | null) {
|
||||
: '不限';
|
||||
}
|
||||
|
||||
function customRechargeOption(amountUsd: number): UserRechargeCheckoutOption {
|
||||
return {
|
||||
id: `custom-usd-${amountUsd}`,
|
||||
label: `$${amountUsd.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`,
|
||||
title: '自定义金额',
|
||||
amountUsd,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
return value ? formatDateTimeLocal(value) : '--';
|
||||
}
|
||||
@@ -52,6 +68,7 @@ export default function UserRecharge() {
|
||||
const [cancellingId, setCancellingId] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [customAmount, setCustomAmount] = useState('10');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -76,6 +93,10 @@ export default function UserRecharge() {
|
||||
const remaining = billing.remainingCost == null ? '不限' : formatUsd(billing.remainingCost);
|
||||
return `已用 ${formatUsd(billing.usedCost)} / 剩余 ${remaining}`;
|
||||
}, [billing]);
|
||||
const pointBalanceText = useMemo(() => {
|
||||
if (!billing) return '等待余额同步';
|
||||
return formatRechargePointBalance(billing.remainingCost, billing.maxCost);
|
||||
}, [billing]);
|
||||
|
||||
const openCheckout = async (option: UserRechargeCheckoutOption) => {
|
||||
if (pendingOptionId) return;
|
||||
@@ -146,6 +167,16 @@ export default function UserRecharge() {
|
||||
};
|
||||
|
||||
const defaultOption = recharge.defaultOption;
|
||||
const customAmountValue = Number(customAmount);
|
||||
const customAmountValid = Number.isFinite(customAmountValue) && customAmountValue > 0;
|
||||
const customPoints = customAmountValid ? calculateRechargePoints(customAmountValue) : 0;
|
||||
const openCustomCheckout = () => {
|
||||
if (!customAmountValid) {
|
||||
setError('请输入有效的充值金额(USD)。');
|
||||
return;
|
||||
}
|
||||
void openCheckout(customRechargeOption(Number(customAmountValue.toFixed(2))));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container user-recharge-page animate-fade-in">
|
||||
@@ -155,7 +186,7 @@ export default function UserRecharge() {
|
||||
<h1>为模型和 Comfy API 添加积分</h1>
|
||||
<p>
|
||||
先创建系统内充值记录,再跳转到 {recharge.providerLabel} 完成付款。
|
||||
充值记录会和使用记录一起保留,便于到账核对。
|
||||
按 $1 = {USER_RECHARGE_POINTS_PER_USD} 积分换算,充值记录会和使用记录一起保留,便于到账核对。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -165,7 +196,7 @@ export default function UserRecharge() {
|
||||
disabled={pendingOptionId === defaultOption.id}
|
||||
onClick={() => void openCheckout(defaultOption)}
|
||||
>
|
||||
{pendingOptionId === defaultOption.id ? '创建记录中...' : '创建记录并支付'}
|
||||
{pendingOptionId === defaultOption.id ? '创建记录中...' : `支付 ${defaultOption.label} · ${formatRechargePoints(defaultOption.amountUsd)}`}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -175,7 +206,7 @@ export default function UserRecharge() {
|
||||
<div className="user-recharge-section-head">
|
||||
<div>
|
||||
<span>充值额度</span>
|
||||
<strong>先落库记录,再打开 Comfy Checkout</strong>
|
||||
<strong>$1 = {USER_RECHARGE_POINTS_PER_USD} 积分,付款前先确认预计到账</strong>
|
||||
</div>
|
||||
<small>{recharge.providerLabel}</small>
|
||||
</div>
|
||||
@@ -184,13 +215,39 @@ export default function UserRecharge() {
|
||||
onPay={openCheckout}
|
||||
pendingOptionId={pendingOptionId}
|
||||
/>
|
||||
<div className="user-recharge-custom" aria-label="自定义充值金额">
|
||||
<div>
|
||||
<span>自定义金额</span>
|
||||
<strong>{customAmountValid ? `${customPoints.toLocaleString('zh-CN')} 积分` : '输入金额后计算'}</strong>
|
||||
<p>按美元金额实时换算,提交后同样会先创建系统充值记录。</p>
|
||||
</div>
|
||||
<label>
|
||||
<span>USD</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={customAmount}
|
||||
onChange={(event) => setCustomAmount(event.target.value)}
|
||||
aria-label="自定义充值金额,单位美元"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!customAmountValid || Boolean(pendingOptionId)}
|
||||
onClick={openCustomCheckout}
|
||||
>
|
||||
{pendingOptionId?.startsWith('custom-usd-') ? '创建记录中...' : '按自定义金额支付'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="user-recharge-info-grid" aria-label="充值说明">
|
||||
<div>
|
||||
<span>余额状态</span>
|
||||
<strong>{billingText}</strong>
|
||||
<p>余额不足时,创作任务会提示回到此页创建充值记录。</p>
|
||||
<strong>{pointBalanceText}</strong>
|
||||
<p>{billingText}。余额不足时,创作任务会提示回到此页创建充值记录。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>到账说明</span>
|
||||
|
||||
@@ -56,9 +56,16 @@ function commitLoginSession(
|
||||
navigate: ReturnType<typeof useNavigate>,
|
||||
) {
|
||||
commitSession('user', payload.token);
|
||||
clearSession('admin');
|
||||
onLogin?.('user');
|
||||
navigate('/user/dashboard');
|
||||
if (payload.role === 'admin') {
|
||||
// Admin-role user gets both audiences so /admin/* routes work without re-login.
|
||||
commitSession('admin', payload.token);
|
||||
onLogin?.('admin');
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
clearSession('admin');
|
||||
onLogin?.('user');
|
||||
navigate('/user/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
||||
@@ -91,10 +98,6 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
||||
setError(t(resolveLoginErrorMessage(401, '')));
|
||||
return;
|
||||
}
|
||||
if (payload.role === 'admin') {
|
||||
setError(t('管理员请使用管理员 API Token 登录。'));
|
||||
return;
|
||||
}
|
||||
commitLoginSession(payload, onLogin, navigate);
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
@@ -124,10 +127,6 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
||||
setError(t(resolveLoginErrorMessage(401, '')));
|
||||
return;
|
||||
}
|
||||
if (payload.role === 'admin') {
|
||||
setError(t('管理员请使用管理员 API Token 登录。'));
|
||||
return;
|
||||
}
|
||||
commitLoginSession(payload, onLogin, navigate);
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('DigitalHuman template picker accessibility', () => {
|
||||
it('exposes the active workflow template card as a pressed button', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain("className={`digital-human-template-card${active ? ' is-active' : ''}`}");
|
||||
expect(source).toContain('aria-pressed={active}');
|
||||
expect(source).toContain('onClick={() => setTemplateId(workflow.publicId)}');
|
||||
});
|
||||
|
||||
it('keeps the workspace asset picker accessible and keyboard dismissible', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('const assetPickerTitleId = useId();');
|
||||
expect(source).toContain('const assetPickerDialogRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(source).toContain('const lastAssetPickerActiveElementRef = useRef<HTMLElement | null>(null);');
|
||||
expect(source).toContain('const closeAssetPicker = useCallback(() => {');
|
||||
expect(source).toContain('document.body.style.overflow = ');
|
||||
expect(source).toContain('dialog.focus({ preventScroll: true });');
|
||||
expect(source).toContain("event.key === 'Escape'");
|
||||
expect(source).toContain("import { trapFocusInContainer } from '../components/focusTrap.js';");
|
||||
expect(source).toContain('trapFocusInContainer(event, assetPickerDialogRef.current);');
|
||||
expect(source).toContain('lastAssetPickerActiveElementRef.current?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('role="dialog"');
|
||||
expect(source).toContain('aria-modal="true"');
|
||||
expect(source).toContain('aria-labelledby={assetPickerTitleId}');
|
||||
expect(source).toContain('ref={assetPickerDialogRef}');
|
||||
expect(source).toContain('tabIndex={-1}');
|
||||
expect(source).toContain('<h3 id={assetPickerTitleId}>');
|
||||
});
|
||||
|
||||
it('treats output mapping as optional and auto-infers mappings for plain workflow graphs', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain("const REQUIRED_MAPPING_KEYS: MappingKey[] = ['prompt', 'script', 'avatar', 'referenceAudio'];");
|
||||
expect(source).toContain('return REQUIRED_MAPPING_KEYS.every((key) => {');
|
||||
expect(source).not.toContain("['prompt', 'script', 'avatar', 'referenceAudio', 'output'].every");
|
||||
expect(source).not.toContain("['输出节点', mapped.output.nodeId]");
|
||||
expect(source).toContain('|| inferMappingFromWorkflow(workflow || null);');
|
||||
expect(source).toContain("node.inputs && typeof node.inputs === 'object'");
|
||||
expect(source).toContain("node.params && typeof node.params === 'object'");
|
||||
expect(source).toContain("['output', '输出视频节点(可选)'],");
|
||||
expect(source).toContain('输出节点可选,系统会从 Comfy 历史中自动识别产物。');
|
||||
});
|
||||
|
||||
it('links remote Comfy outputs directly while preserving workspace asset links', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('function jobAssetHref(asset: any, workspacePath: string) {');
|
||||
expect(source).toContain('if (asset?.remoteUrl) return String(asset.remoteUrl);');
|
||||
expect(source).toContain("if (asset?.publicId) return `${workspacePath}?q=${encodeURIComponent(asset.publicId)}`;");
|
||||
expect(source).toContain("if (asset?.remoteUrl) return `远端产物 · ${kind}`;");
|
||||
expect(source).toContain("target={asset.remoteUrl ? '_blank' : undefined}");
|
||||
expect(source).toContain("rel={asset.remoteUrl ? 'noreferrer' : undefined}");
|
||||
expect(source).not.toContain('href={`${workspacePath}?q=${encodeURIComponent(asset.publicId)}`}');
|
||||
});
|
||||
|
||||
it('surfaces the digital human Comfy status contract in the page and API client', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
const api = readFileSync(resolve(process.cwd(), 'src/web/api.ts'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(api).toContain('export type DigitalHumanStatusResponse = {');
|
||||
expect(api).toContain('request<DigitalHumanStatusResponse>("/api/digital-human/status")');
|
||||
expect(api).toContain('userRequest<DigitalHumanStatusResponse>("/api/digital-human/status")');
|
||||
expect(source).toContain('type DigitalHumanStatusResponse');
|
||||
expect(source).toContain('const [status, setStatus] = useState<DigitalHumanStatusResponse | null>(null);');
|
||||
expect(source).toContain('client.getDigitalHumanStatus(),');
|
||||
expect(source).toContain('<div className="digital-human-summary">');
|
||||
expect(source).toContain('Comfy 状态');
|
||||
expect(source).toContain('status?.keyName ? `使用 ${status.keyName}` :');
|
||||
});
|
||||
});
|
||||
@@ -21,4 +21,16 @@ describe('PictureAgent mobile layout', () => {
|
||||
expect(css).not.toContain('min-height: 340px;\n padding: 10px;');
|
||||
expect(css).not.toContain('min-height: 300px;\n display: grid;');
|
||||
});
|
||||
|
||||
it('keeps generation controls breathable in narrow side panels', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.picture-agent-tabs + [role="tabpanel"] {\n display: grid;\n gap: 14px;');
|
||||
expect(css).toContain('.picture-agent-presets {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));');
|
||||
expect(css).toContain('.picture-agent-size-row {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));');
|
||||
expect(css).toContain('.picture-agent-size-preview {\n grid-column: 1 / -1;');
|
||||
expect(css).toContain('.picture-agent-submit {\n min-height: 44px;');
|
||||
expect(css).toMatch(/@media \(max-width: 640px\) \{[\s\S]*?\.picture-agent-presets \{[\s\S]*?grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
|
||||
expect(css).not.toContain('grid-template-columns: 1fr 1fr minmax(92px, 0.8fr);');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Settings model brand recommendation filter copy', () => {
|
||||
it('frames brand filtering as model-brand recommendation terms instead of platform branding', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, 'Settings.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('模型品牌推荐屏蔽词');
|
||||
expect(source).toContain('LLM 对话推荐');
|
||||
expect(source).toContain('这里不是屏蔽 BoosAPI 平台品牌');
|
||||
expect(source).not.toContain('全局品牌屏蔽');
|
||||
expect(source).not.toContain('保存品牌屏蔽');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export type UserRechargeConfig = {
|
||||
};
|
||||
|
||||
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
|
||||
export const USER_RECHARGE_POINTS_PER_USD = 211;
|
||||
|
||||
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeCheckoutOption[] = [
|
||||
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5 },
|
||||
@@ -44,6 +45,29 @@ export function isUserRechargeOptionPayable(option: UserRechargeOption): option
|
||||
return !option.disabled && (Boolean(checkoutUrl) || hasValidAmount(option));
|
||||
}
|
||||
|
||||
export function calculateRechargePoints(amountUsd?: number | null): number {
|
||||
if (typeof amountUsd !== 'number' || !Number.isFinite(amountUsd) || amountUsd <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(amountUsd * USER_RECHARGE_POINTS_PER_USD);
|
||||
}
|
||||
|
||||
export function formatRechargePoints(amountUsd?: number | null): string {
|
||||
return `${calculateRechargePoints(amountUsd).toLocaleString('zh-CN')} 积分`;
|
||||
}
|
||||
|
||||
export function formatRechargePointBalance(
|
||||
remainingCost?: number | null,
|
||||
maxCost?: number | null,
|
||||
): string {
|
||||
if (remainingCost == null) {
|
||||
return '不限积分';
|
||||
}
|
||||
const balance = Math.max(0, remainingCost);
|
||||
const points = formatRechargePoints(balance);
|
||||
return maxCost == null ? points : points;
|
||||
}
|
||||
|
||||
function normalizeRechargeOption(value: any, index: number): UserRechargeOption | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const checkoutUrl = String(value.checkoutUrl || value.checkout_url || value.url || '').trim();
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('website GSAP motion contract', () => {
|
||||
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: reduce)"');
|
||||
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: no-preference) and (max-width: 640px)"');
|
||||
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: no-preference) and (min-width: 641px)"');
|
||||
expect(source).toContain('ScrollTrigger.config({ ignoreMobileResize: true });');
|
||||
expect(source).toContain('matchMedia.revert();');
|
||||
});
|
||||
|
||||
@@ -86,9 +87,12 @@ describe('website GSAP motion contract', () => {
|
||||
expect(source).toContain('const activeMobileLink = links.find((link) => (');
|
||||
expect(source).toContain('link.getAttribute("href") === `#${id}` && Boolean(link.closest(".mobile-nav-rail"))');
|
||||
expect(source).not.toContain('CSS.escape');
|
||||
expect(source).toContain('activeMobileLink?.scrollIntoView({');
|
||||
expect(source).toContain('block: "nearest",');
|
||||
expect(source).toContain('inline: "center",');
|
||||
expect(source).toContain('centerMobileNavLink(activeMobileLink);');
|
||||
expect(source).toContain('function centerMobileNavLink(link: HTMLAnchorElement) {');
|
||||
expect(source).toContain('const rail = link.closest<HTMLElement>(".mobile-nav-rail");');
|
||||
expect(source).toContain('rail.scrollTo({');
|
||||
expect(source).toContain('left: Math.max(0, nextScrollLeft),');
|
||||
expect(source).toContain('behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",');
|
||||
expect(source).toContain('const syncActiveFromHash = () => {');
|
||||
expect(source).toContain('window.location.hash.slice(1)');
|
||||
expect(source).toContain('const ANCHOR_NAV_EVENT = "bossai:anchor-nav";');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -23,6 +23,11 @@ describe('website /new experimental page', () => {
|
||||
expect(home).not.toContain('new-acid-page');
|
||||
expect(routes).toContain('path == "/new"');
|
||||
expect(routes).toContain('path.starts_with("/new/")');
|
||||
expect(routes).not.toContain('path == "/new1"');
|
||||
expect(routes).not.toContain('path == "/new2"');
|
||||
expect(routes).not.toContain('path == "/new3"');
|
||||
expect(routes).not.toContain('path == "/new4"');
|
||||
expect(routes).not.toContain('path == "/new5"');
|
||||
});
|
||||
|
||||
it('uses Three.js as a client-only lightweight background with mobile and reduced-motion fallback', () => {
|
||||
@@ -32,7 +37,8 @@ describe('website /new experimental page', () => {
|
||||
|
||||
expect(pkg).toContain('"three"');
|
||||
expect(pkg).toContain('"lenis"');
|
||||
expect(pkg).not.toContain('"@react-three/fiber"');
|
||||
expect(source).not.toContain('@react-three/fiber');
|
||||
expect(source).not.toContain('@react-three/drei');
|
||||
expect(source).toContain('"use client";');
|
||||
expect(source).toContain('import("three")');
|
||||
expect(source).toContain('import("gsap")');
|
||||
@@ -59,4 +65,20 @@ describe('website /new experimental page', () => {
|
||||
expect(css).toContain('@media (max-width: 640px)');
|
||||
expect(css).toContain('@media (prefers-reduced-motion: reduce)');
|
||||
});
|
||||
it('removes the retired /new1-/new5 comparison experiments and their extra R3F dependencies', () => {
|
||||
const routes = read('src/routes/mod.rs');
|
||||
const css = read('website/app/globals.css');
|
||||
const pkg = read('website/package.json');
|
||||
|
||||
for (const route of ['new1', 'new2', 'new3', 'new4', 'new5']) {
|
||||
expect(existsSync(resolve(process.cwd(), `website/app/${route}`))).toBe(false);
|
||||
expect(routes).not.toContain(`path == "/${route}"`);
|
||||
expect(routes).not.toContain(`path.starts_with("/${route}/")`);
|
||||
expect(routes).not.toContain(`return "${route}.html".to_string();`);
|
||||
expect(css).not.toContain(`.${route}-`);
|
||||
}
|
||||
|
||||
expect(pkg).not.toContain('"@react-three/fiber"');
|
||||
expect(pkg).not.toContain('"@react-three/drei"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user