feat: refine BoosAPI console and user workspace
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { clearSession, commitSession, getSessionToken } from './authSession.js';
|
||||
import App from './App.js';
|
||||
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
||||
import UserLoginPage from './pages/auth/UserLoginPage.js';
|
||||
import UserRegister from './pages/UserRegister.js';
|
||||
|
||||
vi.mock('./pages/Dashboard.js', () => ({
|
||||
default: () => <div>Dashboard Page</div>,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
@@ -11,6 +21,77 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function createMemoryStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unsignedJwt(payload: Record<string, unknown>): string {
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
.toString('base64url');
|
||||
return `test.${encodedPayload}.signature`;
|
||||
}
|
||||
|
||||
function PathProbe({ onPath }: { onPath: (path: string) => void }) {
|
||||
const location = useLocation();
|
||||
onPath(location.pathname);
|
||||
return null;
|
||||
}
|
||||
|
||||
function stubAppRuntime(storage: ReturnType<typeof createMemoryStorage>) {
|
||||
const matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
onchange: null,
|
||||
});
|
||||
const documentElement = {
|
||||
setAttribute: vi.fn(),
|
||||
getAttribute: vi.fn(() => null),
|
||||
};
|
||||
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
vi.stubGlobal('window', {
|
||||
innerWidth: 1280,
|
||||
matchMedia,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('document', {
|
||||
body: { style: {}, dataset: {} },
|
||||
documentElement,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getElementById: vi.fn(() => null),
|
||||
});
|
||||
vi.stubGlobal('Node', { TEXT_NODE: 3, ELEMENT_NODE: 1 });
|
||||
vi.stubGlobal('MutationObserver', class {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
takeRecords = vi.fn(() => []);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearSession('admin');
|
||||
clearSession('user');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('Login surface', () => {
|
||||
it('renders a poster-style hero with a floating admin login panel', () => {
|
||||
const root = create(
|
||||
@@ -51,18 +132,267 @@ describe('Login surface', () => {
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '管理员令牌'
|
||||
));
|
||||
expect(tokenInput.props.type).toBe('password');
|
||||
expect(root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('login-mode-tab')
|
||||
))).toHaveLength(0);
|
||||
expect(pageText).toContain('仅允许使用管理员 API Token 进入控制台。');
|
||||
expect(pageText).toContain('管理员后台不接受账号密码或用户 API Key 登录。');
|
||||
expect(root.root.findAllByProps({ id: 'admin-email-input' })).toHaveLength(0);
|
||||
expect(root.root.findAllByProps({ id: 'admin-password-input' })).toHaveLength(0);
|
||||
expect(root.root.findAllByProps({ id: 'admin-api-key-input' })).toHaveLength(0);
|
||||
const form = root.root.findByProps({ id: 'admin-login-panel-token' });
|
||||
expect(form.props.onSubmit).toBeTypeOf('function');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('only accepts the admin token on /boos and clears stale user session', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
stubAppRuntime(storage);
|
||||
commitSession('user', 'stale-user-jwt', undefined, storage);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ enabled: true, mode: 'admin-token' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const paths: string[] = [];
|
||||
let root!: ReactTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/boos']}>
|
||||
<App />
|
||||
<PathProbe onPath={(path) => paths.push(path)} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.root.findByProps({ id: 'admin-token-input' }).props.onChange({
|
||||
target: { value: 'admin-config-token' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await root.root.findByProps({ id: 'admin-login-panel-token' }).props.onSubmit({
|
||||
preventDefault: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/info', expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer admin-config-token' },
|
||||
}));
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/login-api-key', expect.anything());
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/login', expect.anything());
|
||||
expect(getSessionToken('admin', storage)).toBe('admin-config-token');
|
||||
expect(getSessionToken('user', storage)).toBeNull();
|
||||
expect(paths.at(-1)).toBe('/dashboard');
|
||||
} finally {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not expose account password or API Key controls on the admin login page', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onLogin = vi.fn();
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
<AdminLoginPage t={(text) => text} onLogin={onLogin} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
try {
|
||||
expect(root.root.findAllByProps({ id: 'admin-email-input' })).toHaveLength(0);
|
||||
expect(root.root.findAllByProps({ id: 'admin-password-input' })).toHaveLength(0);
|
||||
expect(root.root.findAllByProps({ id: 'admin-api-key-input' })).toHaveLength(0);
|
||||
expect(collectText(root.root)).not.toContain('该 API Key 不属于管理员账号。');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
expect(getSessionToken('admin', storage)).toBeNull();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('connects user login mode tabs to the active login form panel', () => {
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
<UserLoginPage t={(text) => text} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
try {
|
||||
const loginModeTabs = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('login-mode-tab')
|
||||
));
|
||||
expect(tokenInput.props.type).toBe('password');
|
||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']);
|
||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['邮箱密码', 'API Key']);
|
||||
expect(loginModeTabs.every((node) => node.props.role === 'tab')).toBe(true);
|
||||
expect(loginModeTabs[0].props['aria-selected']).toBe(true);
|
||||
expect(loginModeTabs[0].props['aria-controls']).toBe('user-login-panel-account');
|
||||
|
||||
const activePanel = root.root.find((node) => node.props.role === 'tabpanel');
|
||||
expect(activePanel.props.id).toBe('user-login-panel-account');
|
||||
expect(activePanel.props['aria-labelledby']).toBe('user-login-tab-account');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects admin API Key login on the user login page', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
commitSession('user', 'stale-user-jwt', undefined, storage);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
token: 'jwt-admin-from-key',
|
||||
user: {
|
||||
role: 'admin',
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onLogin = vi.fn();
|
||||
const paths: string[] = [];
|
||||
const root = create(
|
||||
<MemoryRouter initialEntries={['/user/login']}>
|
||||
<UserLoginPage t={(text) => text} onLogin={onLogin} />
|
||||
<PathProbe onPath={(path) => paths.push(path)} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
try {
|
||||
const apiKeyTab = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('login-mode-tab')
|
||||
))[1];
|
||||
|
||||
await act(async () => {
|
||||
apiKeyTab.props.onClick();
|
||||
});
|
||||
|
||||
const input = root.root.findByProps({ id: 'user-api-key-input' });
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'sk-admin-user-key' } });
|
||||
});
|
||||
|
||||
const form = root.root.findByProps({ id: 'user-login-panel-apiKey' });
|
||||
await act(async () => {
|
||||
await form.props.onSubmit({ preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/users/login-api-key', expect.objectContaining({
|
||||
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');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects API Key login when JWT role falls back to admin', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
const adminJwt = unsignedJwt({ sub: '5', role: 'admin', exp: 9_999_999_999 });
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
token: adminJwt,
|
||||
user: {
|
||||
id: 5,
|
||||
email: 'admin@example.test',
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onLogin = vi.fn();
|
||||
const paths: string[] = [];
|
||||
const root = create(
|
||||
<MemoryRouter initialEntries={['/user/login']}>
|
||||
<UserLoginPage t={(text) => text} onLogin={onLogin} />
|
||||
<PathProbe onPath={(path) => paths.push(path)} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
try {
|
||||
const apiKeyTab = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('login-mode-tab')
|
||||
))[1];
|
||||
|
||||
await act(async () => {
|
||||
apiKeyTab.props.onClick();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.root.findByProps({ id: 'user-api-key-input' }).props.onChange({
|
||||
target: { value: 'sk-admin-bound' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await root.root.findByProps({ id: 'user-login-panel-apiKey' }).props.onSubmit({
|
||||
preventDefault: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers the admin dashboard on public user auth pages when both sessions exist', () => {
|
||||
const storage = createMemoryStorage();
|
||||
stubAppRuntime(storage);
|
||||
commitSession('admin', 'jwt-admin', undefined, storage);
|
||||
commitSession('user', 'jwt-user-stale', undefined, storage);
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
|
||||
const paths: string[] = [];
|
||||
let root!: ReactTestRenderer;
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/login']}>
|
||||
<App />
|
||||
<PathProbe onPath={(path) => paths.push(path)} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(paths.at(-1)).toBe('/dashboard');
|
||||
} finally {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('renders OAuth provider entries as configured placeholders on user login', () => {
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
@@ -80,8 +410,98 @@ describe('Login surface', () => {
|
||||
expect(providers.map((node) => node.props['data-provider'])).toEqual(['google', 'github', 'wechat']);
|
||||
expect(providers.every((node) => node.props.disabled === true)).toBe(true);
|
||||
expect(collectText(root.root)).toContain('待配置');
|
||||
|
||||
const wechatProvider = providers.find((node) => node.props['data-provider'] === 'wechat');
|
||||
const wechatIcons = wechatProvider?.findAll((node) => (
|
||||
node.type === 'svg'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('login-oauth-wechat-glyph')
|
||||
));
|
||||
expect(wechatIcons).toHaveLength(1);
|
||||
expect(wechatIcons?.[0]?.props.width).toBe('30');
|
||||
expect(wechatIcons?.[0]?.props.height).toBe('30');
|
||||
expect(wechatIcons?.[0]?.props.viewBox).toBe('0 0 64 64');
|
||||
expect(wechatProvider?.findAll((node) => node.type === 'img')).toHaveLength(0);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a complete local WeChat glyph on the register OAuth placeholder', () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
id: 'captcha-test',
|
||||
image: 'data:image/png;base64,test',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
<UserRegister />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
try {
|
||||
const oauthSlot = root.root.find((node) => node.props['data-testid'] === 'oauth-slot');
|
||||
const wechatProvider = oauthSlot.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['data-provider'] === 'wechat'
|
||||
));
|
||||
const wechatIcon = wechatProvider.find((node) => (
|
||||
node.type === 'svg'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('login-oauth-wechat-glyph')
|
||||
));
|
||||
const vectorParts = wechatIcon.findAll((node) => node.type === 'path' || node.type === 'circle');
|
||||
|
||||
expect(oauthSlot.props['data-mode']).toBe('register');
|
||||
expect(wechatProvider.props.disabled).toBe(true);
|
||||
expect(wechatProvider.props.onClick).toBeUndefined();
|
||||
expect(wechatIcon.props.width).toBe('30');
|
||||
expect(wechatIcon.props.height).toBe('30');
|
||||
expect(wechatIcon.props.viewBox).toBe('0 0 64 64');
|
||||
expect(wechatIcon.props['aria-hidden']).toBe('true');
|
||||
expect(vectorParts).toHaveLength(6);
|
||||
expect(wechatProvider.findAll((node) => node.type === 'img')).toHaveLength(0);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
vi.stubGlobal('fetch', originalFetch);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the local WeChat OAuth mark unclipped in CSS', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.login-oauth-provider[data-provider="wechat"] .login-oauth-provider-mark {\n');
|
||||
expect(css).toContain(' overflow: visible;\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, 0.72),');
|
||||
expect(css).toContain('.login-oauth-wechat-glyph {\n display: block;\n width: 30px;\n height: 30px;\n flex: 0 0 30px;\n overflow: visible;\n}');
|
||||
});
|
||||
|
||||
it('keeps login brand panels bounded on short and mobile viewports', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).not.toContain('min-height: 650px;');
|
||||
expect(css).not.toContain('min-height: 580px;');
|
||||
expect(css).toContain('.login-brand-panel {\n min-height: min(650px, calc(100dvh - 92px));');
|
||||
expect(css).toContain('.login-shell[data-variant="user"] .login-brand-panel {\n min-height: min(580px, calc(100dvh - 92px));');
|
||||
expect(css).toContain('@media (max-height: 760px) and (min-width: 1181px) {');
|
||||
expect(css).toContain('.login-brand-panel,\n .login-shell[data-variant="user"] .login-brand-panel {\n min-height: auto;');
|
||||
expect(css).toContain('@media (max-width: 1180px) {');
|
||||
expect(css).toContain('.login-shell[data-variant="user"] .login-brand-panel {\n min-height: auto;');
|
||||
});
|
||||
|
||||
it('keeps admin and user login columns shrink-safe before the single-column breakpoint', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('grid-template-columns: minmax(min(620px, 100%), 1fr) minmax(min(360px, 100%), 420px);');
|
||||
expect(css).toContain('grid-template-columns: minmax(min(520px, 100%), 1.08fr) minmax(min(380px, 100%), 0.92fr);');
|
||||
expect(css).toContain('grid-template-columns: minmax(min(440px, 100%), 0.95fr) minmax(min(380px, 100%), 1.05fr);');
|
||||
expect(css).not.toContain('grid-template-columns: minmax(620px, 1fr) minmax(360px, 420px);');
|
||||
expect(css).not.toContain('grid-template-columns: minmax(520px, 1.08fr) minmax(380px, 0.92fr);');
|
||||
expect(css).not.toContain('grid-template-columns: minmax(440px, 0.95fr) minmax(380px, 1.05fr);');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('App route transition feedback', () => {
|
||||
it('tracks pathname and query changes without remounting page content for search-only navigation', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('const routeTransitionKey = `${location.pathname}${location.search}`;');
|
||||
expect(source).toContain('const previousRouteTransitionKeyRef = useRef(routeTransitionKey);');
|
||||
expect(source).toContain('const sidebarRef = useRef<HTMLElement>(null);');
|
||||
expect(source).toContain('const mainRef = useRef<HTMLElement>(null);');
|
||||
expect(source).toContain("import { shouldFocusMainOnRouteChange } from './pages/helpers/routeFocus.js';");
|
||||
expect(source).toContain('previousRouteTransitionKeyRef.current === routeTransitionKey');
|
||||
expect(source).toContain('className="skip-link"');
|
||||
expect(source).toContain('href="#main-content"');
|
||||
expect(source).toContain('mainRef.current?.focus({ preventScroll: true });');
|
||||
expect(source).toContain("{t('跳到主内容')}");
|
||||
expect(source).toContain('ref={mainRef}');
|
||||
expect(source).toContain('id="main-content"');
|
||||
expect(source).toContain('tabIndex={-1}');
|
||||
expect(source).toContain("aria-label={t('主内容')}");
|
||||
expect(source).toContain('const pageMotionKey = `${location.pathname}${location.search}`;');
|
||||
expect(source).toContain('key={location.pathname} motionKey={pageMotionKey}');
|
||||
expect(source).toContain('<Suspense fallback={null}>');
|
||||
expect(source).not.toContain('function RouteLoadingFallback');
|
||||
expect(source).not.toContain('fallback={<RouteLoadingFallback />}');
|
||||
expect(source).not.toContain('routeTransitioning');
|
||||
expect(source).not.toContain('is-route-transitioning');
|
||||
expect(source).toContain('className="sr-only"');
|
||||
expect(source).toContain('aria-live="polite"');
|
||||
expect(source).toContain('aria-atomic="true"');
|
||||
});
|
||||
|
||||
it('moves focus to the main region on shell-driven route changes without stealing field focus', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('const shouldFocusMain = shouldFocusMainOnRouteChange(document.activeElement, {');
|
||||
expect(source).toContain('body: document.body,');
|
||||
expect(source).toContain('topbar: topbarRef.current,');
|
||||
expect(source).toContain('sidebar: sidebarRef.current,');
|
||||
expect(source).not.toContain('const shouldFocusMain = !activeElement');
|
||||
expect(source).toContain('let focusFrameId: number | null = null;');
|
||||
expect(source).toContain("const schedule = typeof window.requestAnimationFrame === 'function'");
|
||||
expect(source).toContain('focusFrameId = schedule(() => {');
|
||||
expect(source).toContain('mainRef.current?.focus({ preventScroll: true });');
|
||||
expect(source).toContain("typeof window.cancelAnimationFrame === 'function'");
|
||||
expect(source).toContain('window.cancelAnimationFrame(focusFrameId);');
|
||||
});
|
||||
|
||||
it('does not render a blocking shell loading frame while lazy routes resolve', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.skip-link');
|
||||
expect(css).toContain('.skip-link:focus,\n.skip-link:focus-visible');
|
||||
expect(css).toContain('.sr-only');
|
||||
expect(css).toContain('.skip-link,\n .skip-link:focus,\n .skip-link:focus-visible');
|
||||
expect(source).not.toContain('RouteLoadingFallback');
|
||||
expect(source).not.toContain('animate-fade-in');
|
||||
expect(source).not.toContain('aria-busy={routeTransitioning}');
|
||||
expect(css).not.toContain('.route-progress');
|
||||
expect(css).not.toContain('@keyframes route-progress-sweep');
|
||||
expect(css).not.toContain('.main-content.is-route-transitioning');
|
||||
});
|
||||
});
|
||||
+175
-4
@@ -11,17 +11,166 @@ describe('App sidebar config', () => {
|
||||
expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
|
||||
});
|
||||
|
||||
it('places downstream key navigation under 控制台 instead of 系统', () => {
|
||||
it('places downstream key navigation under 接入管理 instead of 系统', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
const consoleGroupIndex = source.indexOf("label: '控制台'");
|
||||
const accessGroupIndex = source.indexOf("label: '接入管理'");
|
||||
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
|
||||
const systemGroupIndex = source.indexOf("label: '系统'");
|
||||
|
||||
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
|
||||
expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(downstreamIndex).toBeGreaterThan(accessGroupIndex);
|
||||
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
|
||||
});
|
||||
|
||||
it('keeps access navigation flat and non-collapsible', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
const accessGroupIndex = source.indexOf("label: '接入管理'");
|
||||
const creativeGroupIndex = source.indexOf("label: '创作应用'");
|
||||
const accessGroupSource = source.slice(accessGroupIndex, creativeGroupIndex);
|
||||
|
||||
expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(source).not.toContain("label: '模型接入'");
|
||||
expect(source).not.toContain("label: '渠道管理'");
|
||||
expect(source).not.toContain("label: '接入配置'");
|
||||
expect(source).not.toContain("label: '接入与路由'");
|
||||
expect(accessGroupSource).not.toContain('children:');
|
||||
expect(accessGroupSource).not.toContain('collapsed');
|
||||
expect(accessGroupSource).not.toContain('expanded');
|
||||
expect(accessGroupSource).not.toContain('aria-expanded');
|
||||
expect(accessGroupSource).toContain("{ to: '/sites', label: '站点管理'");
|
||||
expect(accessGroupSource).toContain("{ to: '/routes', label: '路由管理'");
|
||||
expect(accessGroupSource).toContain("{ to: '/ip-management', label: 'IP 管理'");
|
||||
});
|
||||
|
||||
it('adds an accessible collapsible desktop sidebar', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(source).toContain("const BOOSAPI_SIDEBAR_COLLAPSED_KEY = 'boosapi.sidebar.collapsed';");
|
||||
expect(source).toContain('function resolveStoredSidebarCollapsed(): boolean {');
|
||||
expect(source).toContain('const [sidebarCollapsed, setSidebarCollapsed] = useState(() => resolveStoredSidebarCollapsed());');
|
||||
expect(source).toContain('className={`app-layout ${sidebarCollapsed ? \'sidebar-is-collapsed\' : \'\'}`.trim()}');
|
||||
expect(source).toContain('<aside ref={sidebarRef} className="sidebar" data-collapsed={sidebarCollapsed ? \'true\' : \'false\'}>');
|
||||
expect(source).toContain('className="sidebar-collapse-button"');
|
||||
expect(source).toContain("aria-label={sidebarCollapsed ? t('展开侧边栏') : t('收起侧边栏')}");
|
||||
expect(source).toContain('aria-controls={sidebarNavId}');
|
||||
expect(source).toContain('aria-expanded={!sidebarCollapsed}');
|
||||
expect(source).toContain('onClick={() => setSidebarCollapsed((value) => !value)}');
|
||||
expect(css).toContain('.app-layout.sidebar-is-collapsed .sidebar {');
|
||||
expect(css).toContain('flex-basis: 72px;');
|
||||
expect(css).toContain('.sidebar-collapse-button {');
|
||||
});
|
||||
|
||||
it('uses flat sidebar selection styling without tree-like rails', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
const sidebarCss = css.slice(css.indexOf('/* ===== Sidebar ===== */'));
|
||||
const sidebarActiveBeforeRules = Array.from(
|
||||
sidebarCss.matchAll(/\.sidebar-item\.active::before\s*\{[^}]*\}/g),
|
||||
(match) => match[0],
|
||||
);
|
||||
|
||||
expect(sidebarActiveBeforeRules.length).toBeGreaterThan(0);
|
||||
sidebarActiveBeforeRules.forEach((rule) => {
|
||||
expect(rule).toContain('content: none;');
|
||||
expect(rule).toContain('display: none;');
|
||||
expect(rule).not.toContain('left: 6px;');
|
||||
expect(rule).not.toContain('width: 3px;');
|
||||
expect(rule).not.toContain('height: 16px;');
|
||||
});
|
||||
const mobileActiveBeforeRules = Array.from(
|
||||
sidebarCss.matchAll(/\.mobile-nav-item\.active::before\s*\{[^}]*\}/g),
|
||||
(match) => match[0],
|
||||
);
|
||||
expect(mobileActiveBeforeRules.length).toBeGreaterThan(0);
|
||||
mobileActiveBeforeRules.forEach((rule) => {
|
||||
expect(rule).toContain('content: none;');
|
||||
expect(rule).toContain('display: none;');
|
||||
});
|
||||
const mobileLabelRules = Array.from(
|
||||
sidebarCss.matchAll(/\.mobile-nav-label\s*\{[^}]*\}/g),
|
||||
(match) => match[0],
|
||||
);
|
||||
expect(mobileLabelRules.length).toBeGreaterThan(0);
|
||||
mobileLabelRules.forEach((rule) => {
|
||||
expect(rule).toContain('text-transform: none;');
|
||||
expect(rule).toContain('letter-spacing: 0;');
|
||||
});
|
||||
expect(sidebarCss).not.toContain('inset 4px 0 0');
|
||||
expect(sidebarCss).not.toContain('sidebar-submenu');
|
||||
expect(sidebarCss).not.toContain('mobile-nav-parent');
|
||||
const sidebarGroupRules = Array.from(
|
||||
css.matchAll(/\.sidebar-group(?:\:last-of-type)?\s*\{[^}]*\}/g),
|
||||
(match) => match[0],
|
||||
);
|
||||
expect(sidebarGroupRules.length).toBeGreaterThan(0);
|
||||
const hasNonZeroDeclaration = (rule: string, property: string) => (
|
||||
Array.from(rule.matchAll(new RegExp(`${property}\\s*:\\s*([^;]+)`, 'g')))
|
||||
.some((match) => match[1]?.trim() !== '0')
|
||||
);
|
||||
const treeLikeSidebarGroupRules = sidebarGroupRules.filter((rule) => (
|
||||
hasNonZeroDeclaration(rule, 'border-left')
|
||||
|| hasNonZeroDeclaration(rule, 'padding-left')
|
||||
|| hasNonZeroDeclaration(rule, 'margin-left')
|
||||
));
|
||||
expect(treeLikeSidebarGroupRules).toEqual([]);
|
||||
expect(sidebarGroupRules.some((rule) => rule.includes('border-left: 0;'))).toBe(true);
|
||||
expect(sidebarGroupRules.some((rule) => rule.includes('padding-left: 0;'))).toBe(true);
|
||||
|
||||
const mobileFlatRules = Array.from(
|
||||
css.matchAll(/\.mobile-nav-group,\s*\.mobile-nav-label,\s*\.mobile-nav-item\s*\{[^}]*\}/g),
|
||||
(match) => match[0],
|
||||
);
|
||||
expect(mobileFlatRules.length).toBeGreaterThan(0);
|
||||
mobileFlatRules.forEach((rule) => {
|
||||
expect(rule).toContain('margin-left: 0;');
|
||||
expect(rule).toContain('border-left: 0;');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps internal usage log navigation and removes external monitor from admin shell', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
const adminGroupsSource = source.slice(
|
||||
source.indexOf('export const sidebarGroups'),
|
||||
source.indexOf('export const userSidebarGroups'),
|
||||
);
|
||||
|
||||
expect(adminGroupsSource).toContain("{ to: '/logs', label: '使用日志'");
|
||||
expect(adminGroupsSource).toContain("{ to: '/events', label: '系统日志'");
|
||||
expect(adminGroupsSource).not.toContain("{ to: '/monitor', label: '外部监控'");
|
||||
expect(adminGroupsSource).not.toContain("{ to: '/events', label: '程序日志'");
|
||||
expect(adminGroupsSource).not.toContain('children:');
|
||||
expect(adminGroupsSource).not.toContain('aria-expanded');
|
||||
expect(adminGroupsSource).not.toContain('sidebar-submenu');
|
||||
expect(adminGroupsSource).not.toContain('mobile-nav-parent');
|
||||
expect(adminGroupsSource).toContain("label: '接入管理'");
|
||||
expect(adminGroupsSource).toContain("{ to: '/ops', label: '运维监控'");
|
||||
const accessGroupSource = adminGroupsSource.slice(
|
||||
adminGroupsSource.indexOf("label: '接入管理'"),
|
||||
adminGroupsSource.indexOf("label: '创作应用'"),
|
||||
);
|
||||
const systemGroupSource = adminGroupsSource.slice(adminGroupsSource.indexOf("label: '系统'"));
|
||||
expect(accessGroupSource).toContain("{ to: '/logs', label: '使用日志'");
|
||||
expect(accessGroupSource).not.toContain("{ to: '/events', label: '系统日志'");
|
||||
expect(systemGroupSource).toContain("{ to: '/events', label: '系统日志'");
|
||||
expect(systemGroupSource).not.toContain("{ to: '/logs', label: '使用日志'");
|
||||
expect(source).toContain('<Route path="/logs" element={<ProxyLogs />} />');
|
||||
expect(source).not.toContain("const Monitors = lazy(() => import('./pages/Monitors.js'));");
|
||||
expect(source).not.toContain('<Route path="/monitor"');
|
||||
expect(source).toContain('<Route path="/events" element={<ProgramLogs />} />');
|
||||
});
|
||||
|
||||
it('keeps user-side usage records navigation and route intact', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
const userGroupsSource = source.slice(
|
||||
source.indexOf('export const userSidebarGroups'),
|
||||
source.indexOf('const topNavItems'),
|
||||
);
|
||||
|
||||
expect(userGroupsSource).toContain("{ to: '/user/usage', label: '使用记录'");
|
||||
expect(source).toContain("const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));");
|
||||
expect(source).toContain('<Route path="/user/usage" element={<UsageRecords />} />');
|
||||
});
|
||||
|
||||
it('adds standalone OAuth 管理 navigation entry', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
|
||||
@@ -29,4 +178,26 @@ describe('App sidebar config', () => {
|
||||
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
|
||||
expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />');
|
||||
});
|
||||
|
||||
it('registers the model tester route used by route diagnostics', () => {
|
||||
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
const tokenRoutesSource = readFileSync(resolve(process.cwd(), 'src/web/pages/TokenRoutes.tsx'), 'utf8');
|
||||
const topNavSource = appSource.slice(
|
||||
appSource.indexOf('const topNavItems'),
|
||||
appSource.indexOf('function isExactSidebarPath'),
|
||||
);
|
||||
|
||||
expect(appSource).toContain("const ModelTester = lazy(() => import('./pages/ModelTester.js'));");
|
||||
expect(topNavSource).toContain("{ label: '模型测试', to: '/model-tester' }");
|
||||
expect(appSource).toContain('<Route path="/model-tester" element={<ModelTester />} />');
|
||||
expect(tokenRoutesSource).toContain('navigate(`/model-tester?model=${encodeURIComponent(model)}`)');
|
||||
});
|
||||
|
||||
it('adds a system-internal recharge page to the user workspace', () => {
|
||||
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
|
||||
expect(appSource).toContain("const UserRecharge = lazy(() => import('./pages/UserRecharge.js'));");
|
||||
expect(appSource).toContain("{ to: '/user/recharge', label: '账户充值'");
|
||||
expect(appSource).toContain('<Route path="/user/recharge" element={<UserRecharge />} />');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readAppSource(): string {
|
||||
return readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
describe('App topbar menu accessibility', () => {
|
||||
it('exposes theme and account dropdowns as controlled menus', () => {
|
||||
const source = readAppSource();
|
||||
|
||||
expect(source).toContain('const themeMenuId = useId();');
|
||||
expect(source).toContain('const userMenuId = useId();');
|
||||
expect(source).toContain('const userTriggerRef = useRef<HTMLButtonElement>(null);');
|
||||
expect(source).toContain('const themeTriggerRef = useRef<HTMLButtonElement>(null);');
|
||||
expect(source).toContain('const searchDialogId = useId();');
|
||||
expect(source).toContain('const notificationPanelId = useId();');
|
||||
expect(source).toContain('function openSearch() {');
|
||||
expect(source).toContain('aria-haspopup="menu"');
|
||||
expect(source).toContain('aria-haspopup="dialog"');
|
||||
expect(source).toContain('aria-expanded={showSearch}');
|
||||
expect(source).toContain('aria-controls={showSearch ? searchDialogId : undefined}');
|
||||
expect(source).toContain('aria-controls={themeMenuPresence.shouldRender ? themeMenuId : undefined}');
|
||||
expect(source).toContain('aria-controls={userMenuPresence.shouldRender ? userMenuId : undefined}');
|
||||
expect(source).toContain('aria-controls={showNotifications ? notificationPanelId : undefined}');
|
||||
expect(source).toContain('role="menu"');
|
||||
expect(source).toContain('aria-label={t(\'主题菜单\')}');
|
||||
expect(source).toContain('aria-label={t(\'账户菜单\')}');
|
||||
expect(source).toContain('role="menuitemradio"');
|
||||
expect(source).toContain('role="menuitem"');
|
||||
expect(source).toContain('dialogId={searchDialogId}');
|
||||
expect(source).toContain("type ThemePreset = 'clearops' | 'terminal' | 'studio';");
|
||||
expect(source).toContain('const THEME_PRESETS: Array<{ value: ThemePreset; label: string; description: string }> = [');
|
||||
expect(source).toContain("const [themePreset, setThemePreset] = useState<ThemePreset>(() => resolveStoredThemePreset());");
|
||||
expect(source).toContain("document.documentElement.setAttribute('data-theme-preset', themePreset);");
|
||||
expect(source).toContain("document.documentElement.setAttribute('data-theme-tone', resolvedThemeTone);");
|
||||
expect(source).toContain('className={`theme-preset-swatch theme-preset-swatch-${preset.value}`}');
|
||||
});
|
||||
|
||||
it('keeps closing dropdown actions out of the tab order and closes menus on Escape', () => {
|
||||
const source = readAppSource();
|
||||
|
||||
expect(source).toContain('tabIndex={themeMenuPresence.isVisible ? 0 : -1}');
|
||||
expect(source).toContain('tabIndex={userMenuPresence.isVisible ? 0 : -1}');
|
||||
expect(source).toContain('aria-hidden={!themeMenuPresence.isVisible}');
|
||||
expect(source).toContain('aria-hidden={!userMenuPresence.isVisible}');
|
||||
expect(source).toContain('if (!showThemeMenu && !showUserMenu) return undefined;');
|
||||
expect(source).toContain("if (e.key !== 'Escape') return;");
|
||||
expect(source).toContain('setShowThemeMenu(false);');
|
||||
expect(source).toContain('setShowUserMenu(false);');
|
||||
expect(source).toContain('setShowNotifications(false);');
|
||||
expect(source).toContain('setShowSearch(false);');
|
||||
expect(source).toContain('triggerRef.current?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('themeTriggerRef.current?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('if (e.defaultPrevented) return;');
|
||||
expect(source).toContain("e.key.toLowerCase() === 'k'");
|
||||
expect(source).toContain("document.addEventListener('keydown', handler);");
|
||||
expect(source).toContain("return () => document.removeEventListener('keydown', handler);");
|
||||
});
|
||||
|
||||
it('supports keyboard opening and roving focus inside topbar menus', () => {
|
||||
const source = readAppSource();
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain("type MenuFocusEdge = 'first' | 'last';");
|
||||
expect(source).toContain("type PendingMenuFocus = { menu: 'theme' | 'user'; edge: MenuFocusEdge } | null;");
|
||||
expect(source).toContain('const [pendingMenuFocus, setPendingMenuFocus] = useState<PendingMenuFocus>(null);');
|
||||
expect(source).toContain('const focusMenuItem = (menuRef: React.RefObject<HTMLDivElement>, edge: MenuFocusEdge = \'first\') => {');
|
||||
expect(source).toContain('target?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('if (!pendingMenuFocus) return;');
|
||||
expect(source).toContain('const menuRef = pendingMenuFocus.menu === \'theme\' ? themeMenuRef : userMenuRef;');
|
||||
expect(source).toContain('setPendingMenuFocus(null);');
|
||||
expect(source).toContain('const handleTopbarMenuTriggerKeyDown = (');
|
||||
expect(source).toContain("if (!['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) return;");
|
||||
expect(source).toContain("const focusEdge: MenuFocusEdge = e.key === 'ArrowUp' ? 'last' : 'first';");
|
||||
expect(source).toContain("setPendingMenuFocus({ menu: 'theme', edge: focusEdge });");
|
||||
expect(source).toContain("setPendingMenuFocus({ menu: 'user', edge: focusEdge });");
|
||||
expect(source).toContain('const handleTopbarMenuKeyDown = (');
|
||||
expect(source).toContain("if (e.key === 'Escape') {\n e.preventDefault();\n setShowThemeMenu(false);");
|
||||
expect(source).toContain("if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {");
|
||||
expect(source).toContain('items[nextIndex]?.focus({ preventScroll: true });');
|
||||
expect(source).toContain("if (e.key === 'Home') {");
|
||||
expect(source).toContain('items[0]?.focus({ preventScroll: true });');
|
||||
expect(source).toContain("if (e.key === 'End') {");
|
||||
expect(source).toContain('items[items.length - 1]?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('target?.focus({ preventScroll: true });');
|
||||
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'theme')}");
|
||||
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'user')}");
|
||||
expect(source).toContain('onKeyDown={handleNotificationTriggerKeyDown}');
|
||||
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'theme')}");
|
||||
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'user')}");
|
||||
expect(css).toContain('.user-dropdown-item:focus-visible');
|
||||
expect(css).toContain('[data-theme="dark"] .user-dropdown-item:focus-visible');
|
||||
});
|
||||
|
||||
it('keeps the desktop topbar grid shrink-safe before the mobile breakpoint', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
const finalTopbarStart = css.indexOf('/* Final topbar layout guard: keep brand, nav, and user actions in their own lanes. */');
|
||||
const finalTopbarEnd = css.indexOf('@media (max-width: 768px)', finalTopbarStart);
|
||||
const finalTopbarCss = css.slice(finalTopbarStart, finalTopbarEnd);
|
||||
|
||||
expect(finalTopbarStart).toBeGreaterThanOrEqual(0);
|
||||
expect(finalTopbarEnd).toBeGreaterThan(finalTopbarStart);
|
||||
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(230px, 100%), 280px) auto minmax(min(300px, 100%), 1fr);');
|
||||
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(170px, 100%), 220px) auto minmax(min(220px, 100%), 1fr);');
|
||||
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(150px, 100%), 190px) auto minmax(min(150px, 100%), 1fr);');
|
||||
expect(finalTopbarCss).toContain('width: max-content;');
|
||||
expect(finalTopbarCss).toContain('max-width: min(430px, 100%);');
|
||||
expect(finalTopbarCss).toContain('overflow-x: auto;');
|
||||
expect(finalTopbarCss).toContain('.topbar-nav::-webkit-scrollbar {\n display: none;\n}');
|
||||
expect(finalTopbarCss).not.toContain('width: min(520px, 100%);');
|
||||
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(230px, 280px) minmax(320px, max-content) minmax(300px, 1fr);');
|
||||
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(170px, 220px) minmax(280px, max-content) minmax(220px, 1fr);');
|
||||
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(150px, 190px) minmax(240px, max-content) minmax(150px, 1fr);');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,685 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import App from './App.js';
|
||||
|
||||
const { apiMock, authSessionMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getEvents: vi.fn(),
|
||||
},
|
||||
authSessionMock: {
|
||||
hasValidAuthSession: vi.fn(),
|
||||
persistAuthSession: vi.fn(),
|
||||
clearAuthSession: vi.fn(),
|
||||
getSessionToken: vi.fn(),
|
||||
commitSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
|
||||
return {
|
||||
...actual,
|
||||
createPortal: (node: unknown) => node,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('./authSession.js', () => ({
|
||||
hasValidAuthSession: authSessionMock.hasValidAuthSession,
|
||||
persistAuthSession: authSessionMock.persistAuthSession,
|
||||
clearAuthSession: authSessionMock.clearAuthSession,
|
||||
getSessionToken: authSessionMock.getSessionToken,
|
||||
commitSession: authSessionMock.commitSession,
|
||||
clearSession: authSessionMock.clearSession,
|
||||
}));
|
||||
|
||||
vi.mock('./components/SearchModal.js', () => ({
|
||||
default: ({ open, dialogId }: { open: boolean; dialogId?: string }) => (
|
||||
open ? <div id={dialogId} role="dialog" aria-modal="true" aria-label="搜索" /> : null
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./components/NotificationPanel.js', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./components/TooltipLayer.js', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./components/useAnimatedVisibility.js', () => ({
|
||||
useAnimatedVisibility: (open: boolean) => ({
|
||||
shouldRender: open,
|
||||
isVisible: open,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./i18n.js', () => ({
|
||||
I18nProvider: ({ children }: { children: ReactNode }) => children,
|
||||
useI18n: () => ({
|
||||
language: 'zh',
|
||||
toggleLanguage: vi.fn(),
|
||||
t: (text: string) => text,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./pages/Dashboard.js', () => ({
|
||||
default: ({ adminName }: { adminName?: string }) => <div>{adminName || 'Dashboard'}</div>,
|
||||
}));
|
||||
|
||||
function createLocalStorage() {
|
||||
const store = new Map<string, string>([
|
||||
['theme_mode', 'light'],
|
||||
['theme_preset', 'clearops'],
|
||||
['metapi.firstUseDocReminder', '1'],
|
||||
['admin_profile', JSON.stringify({
|
||||
name: '管理员',
|
||||
avatarSeed: 'seed-1',
|
||||
avatarStyle: 'identicon',
|
||||
})],
|
||||
]);
|
||||
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
dump: () => new Map(store),
|
||||
};
|
||||
}
|
||||
|
||||
function setupRuntime() {
|
||||
const matchMedia = (query: string) => ({
|
||||
matches: query.includes('prefers-color-scheme') ? false : false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
onchange: null,
|
||||
});
|
||||
|
||||
const storage = createLocalStorage();
|
||||
const documentElementAttributes = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
vi.stubGlobal('window', {
|
||||
innerWidth: 1280,
|
||||
matchMedia,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
},
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('document', {
|
||||
body: { style: {} },
|
||||
activeElement: null,
|
||||
documentElement: {
|
||||
setAttribute: vi.fn((name: string, value: string) => {
|
||||
documentElementAttributes.set(name, value);
|
||||
}),
|
||||
getAttribute: vi.fn((name: string) => documentElementAttributes.get(name) ?? null),
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
return { documentElementAttributes, storage };
|
||||
}
|
||||
|
||||
function keyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function documentKeyEvent(overrides: Partial<KeyboardEvent> & { key: string }) {
|
||||
return {
|
||||
ctrlKey: false,
|
||||
defaultPrevented: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function latestDocumentKeydownListener(): EventListener {
|
||||
const calls = vi.mocked(document.addEventListener).mock.calls;
|
||||
const listeners = calls
|
||||
.filter(([type]) => type === 'keydown')
|
||||
.map(([, listener]) => listener as EventListener);
|
||||
const listener = listeners.at(-1);
|
||||
if (!listener) throw new Error('No document keydown listener registered');
|
||||
return listener;
|
||||
}
|
||||
|
||||
function findButton(root: ReactTestInstance, matcher: (ariaLabel: string) => boolean): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props['aria-label'] === 'string'
|
||||
&& matcher(node.props['aria-label'])
|
||||
));
|
||||
}
|
||||
|
||||
function findMenus(root: ReactTestInstance, label: string): ReactTestInstance[] {
|
||||
return root.findAll((node) => node.props.role === 'menu' && node.props['aria-label'] === label);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
describe('App topbar menu behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
apiMock.getEvents.mockResolvedValue([]);
|
||||
authSessionMock.hasValidAuthSession.mockReturnValue(true);
|
||||
authSessionMock.getSessionToken.mockImplementation((audience: string) =>
|
||||
audience === 'admin' ? 'fake-admin-token' : null,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the theme menu from the keyboard trigger', async () => {
|
||||
setupRuntime();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||
const event = keyEvent('ArrowDown');
|
||||
|
||||
await act(async () => {
|
||||
themeTrigger.props.onKeyDown(event);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
|
||||
expect(findMenus(root.root, '主题菜单')).toHaveLength(1);
|
||||
expect(findMenus(root.root, '账户菜单')).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('moves focus across theme menu items without scrolling the page', async () => {
|
||||
setupRuntime();
|
||||
const menuItems: Array<{ label: string; focus: ReturnType<typeof vi.fn> }> = [
|
||||
{ label: '跟随系统', focus: vi.fn() },
|
||||
{ label: '浅色模式', focus: vi.fn() },
|
||||
{ label: '深色模式', focus: vi.fn() },
|
||||
{ label: '清透运维', focus: vi.fn() },
|
||||
{ label: '深色终端', focus: vi.fn() },
|
||||
{ label: '云端实验室', focus: vi.fn() },
|
||||
];
|
||||
let activeElement: unknown = menuItems[0];
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
configurable: true,
|
||||
get: () => activeElement,
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
const children = Array.isArray(element.props.children)
|
||||
? element.props.children
|
||||
: [element.props.children];
|
||||
const isThemeMenuWrapper = (
|
||||
element.type === 'div'
|
||||
&& element.props.style?.position === 'relative'
|
||||
&& children.some((child: any) => (
|
||||
child?.type === 'button'
|
||||
&& child.props?.['aria-haspopup'] === 'menu'
|
||||
&& String(child.props?.['aria-label'] || '').includes('浅色模式')
|
||||
))
|
||||
);
|
||||
if (isThemeMenuWrapper) {
|
||||
return {
|
||||
contains: () => true,
|
||||
querySelectorAll: () => menuItems,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||
const openEvent = keyEvent('ArrowDown');
|
||||
await act(async () => {
|
||||
themeTrigger.props.onKeyDown(openEvent);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(menuItems[0]!.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
|
||||
const menu = findMenus(root.root, '主题菜单')[0]!;
|
||||
const arrowDownEvent = keyEvent('ArrowDown');
|
||||
await act(async () => {
|
||||
menu.props.onKeyDown(arrowDownEvent);
|
||||
});
|
||||
expect(arrowDownEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(menuItems[1]!.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
|
||||
activeElement = menuItems[1];
|
||||
const endEvent = keyEvent('End');
|
||||
await act(async () => {
|
||||
menu.props.onKeyDown(endEvent);
|
||||
});
|
||||
expect(endEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(menuItems[5]!.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
|
||||
activeElement = menuItems[2];
|
||||
const homeEvent = keyEvent('Home');
|
||||
await act(async () => {
|
||||
menu.props.onKeyDown(homeEvent);
|
||||
});
|
||||
expect(homeEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(menuItems[0]!.focus).toHaveBeenCalledTimes(2);
|
||||
expect(menuItems[0]!.focus).toHaveBeenLastCalledWith({ preventScroll: true });
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('returns focus to the theme trigger after selecting a theme', async () => {
|
||||
setupRuntime();
|
||||
const themeFocus = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => (
|
||||
element.type === 'button'
|
||||
&& String(element.props.className || '').includes('topbar-icon-btn')
|
||||
&& element.props['aria-haspopup'] === 'menu'
|
||||
? { focus: themeFocus }
|
||||
: null
|
||||
),
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||
await act(async () => {
|
||||
themeTrigger.props.onKeyDown(keyEvent('ArrowDown'));
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const darkModeItem = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props.role === 'menuitemradio'
|
||||
&& collectText(node) === '深色模式'
|
||||
));
|
||||
await act(async () => {
|
||||
darkModeItem.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(themeFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('persists named theme presets and exposes root theme attributes', async () => {
|
||||
const runtime = setupRuntime();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||
await act(async () => {
|
||||
themeTrigger.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const terminalPresetItem = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props.role === 'menuitemradio'
|
||||
&& collectText(node).includes('深色终端')
|
||||
));
|
||||
await act(async () => {
|
||||
terminalPresetItem.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runtime.storage.getItem('theme_preset')).toBe('terminal');
|
||||
expect(runtime.documentElementAttributes.get('data-theme-preset')).toBe('terminal');
|
||||
expect(runtime.documentElementAttributes.get('data-theme-tone')).toBe('dark');
|
||||
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the notification panel from the keyboard trigger as a controlled dialog', async () => {
|
||||
setupRuntime();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const notificationTrigger = findButton(root.root, (label) => label === '通知');
|
||||
expect(notificationTrigger.props['aria-haspopup']).toBe('dialog');
|
||||
expect(notificationTrigger.props['aria-expanded']).toBe(false);
|
||||
expect(notificationTrigger.props['aria-controls']).toBeUndefined();
|
||||
|
||||
const openEvent = keyEvent('ArrowDown');
|
||||
await act(async () => {
|
||||
notificationTrigger.props.onKeyDown(openEvent);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const openedNotificationTrigger = findButton(root.root, (label) => label === '通知');
|
||||
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(openedNotificationTrigger.props['aria-expanded']).toBe(true);
|
||||
expect(typeof openedNotificationTrigger.props['aria-controls']).toBe('string');
|
||||
|
||||
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||
await act(async () => {
|
||||
themeTrigger.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(false);
|
||||
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('opens search as a controlled dialog trigger and closes other topbar surfaces', async () => {
|
||||
setupRuntime();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const notificationTrigger = findButton(root.root, (label) => label === '通知');
|
||||
await act(async () => {
|
||||
notificationTrigger.props.onKeyDown(keyEvent('ArrowDown'));
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(true);
|
||||
|
||||
const searchTrigger = findButton(root.root, (label) => label === '搜索 (Ctrl+K)');
|
||||
expect(searchTrigger.props['aria-haspopup']).toBe('dialog');
|
||||
expect(searchTrigger.props['aria-expanded']).toBe(false);
|
||||
expect(searchTrigger.props['aria-controls']).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
searchTrigger.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const openedSearchTrigger = findButton(root.root, (label) => label === '搜索 (Ctrl+K)');
|
||||
expect(openedSearchTrigger.props['aria-expanded']).toBe(true);
|
||||
expect(typeof openedSearchTrigger.props['aria-controls']).toBe('string');
|
||||
const searchDialog = root.root.find((node) => (
|
||||
node.props.role === 'dialog'
|
||||
&& node.props.id === openedSearchTrigger.props['aria-controls']
|
||||
));
|
||||
expect(searchDialog.props['aria-modal']).toBe('true');
|
||||
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(false);
|
||||
|
||||
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||
await act(async () => {
|
||||
themeTrigger.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
|
||||
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('opens search from Ctrl or Command K without stealing handled shortcuts', async () => {
|
||||
setupRuntime();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const keydown = latestDocumentKeydownListener();
|
||||
const ignoredEvent = documentKeyEvent({ ctrlKey: true, defaultPrevented: true, key: 'k' });
|
||||
await act(async () => {
|
||||
keydown(ignoredEvent as unknown as Event);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(ignoredEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
|
||||
|
||||
const ctrlEvent = documentKeyEvent({ ctrlKey: true, key: 'K' });
|
||||
await act(async () => {
|
||||
keydown(ctrlEvent as unknown as Event);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(ctrlEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(true);
|
||||
|
||||
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||
await act(async () => {
|
||||
themeTrigger.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
|
||||
|
||||
const metaEvent = documentKeyEvent({ key: 'k', metaKey: true });
|
||||
await act(async () => {
|
||||
keydown(metaEvent as unknown as Event);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(metaEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(true);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('renders skip-to-main and route status landmarks in the app shell', async () => {
|
||||
setupRuntime();
|
||||
let root!: WebTestRenderer;
|
||||
const mainFocus = vi.fn();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => (
|
||||
element.type === 'main'
|
||||
? { focus: mainFocus }
|
||||
: null
|
||||
),
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const skipLink = root.root.find((node) => (
|
||||
node.type === 'a'
|
||||
&& node.props.className === 'skip-link'
|
||||
));
|
||||
expect(skipLink.props.href).toBe('#main-content');
|
||||
await act(async () => {
|
||||
skipLink.props.onClick();
|
||||
});
|
||||
expect(mainFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
|
||||
const main = root.root.find((node) => node.type === 'main');
|
||||
expect(main.props.id).toBe('main-content');
|
||||
expect(main.props.tabIndex).toBe(-1);
|
||||
expect(main.props['aria-label']).toBe('主内容');
|
||||
|
||||
const liveRegion = root.root.find((node) => (
|
||||
node.props.className === 'sr-only'
|
||||
&& node.props['aria-live'] === 'polite'
|
||||
));
|
||||
expect(liveRegion.props['aria-atomic']).toBe('true');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the account menu from the keyboard trigger and closes it with Escape', async () => {
|
||||
setupRuntime();
|
||||
const accountFocus = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => (
|
||||
element.type === 'button'
|
||||
&& String(element.props.className || '').includes('topbar-user-trigger')
|
||||
? { focus: accountFocus }
|
||||
: null
|
||||
),
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const accountTrigger = findButton(root.root, (label) => label.startsWith('账户菜单:'));
|
||||
const openEvent = keyEvent('ArrowUp');
|
||||
|
||||
await act(async () => {
|
||||
accountTrigger.props.onKeyDown(openEvent);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(findButton(root.root, (label) => label.startsWith('账户菜单:')).props['aria-expanded']).toBe(true);
|
||||
const menus = findMenus(root.root, '账户菜单');
|
||||
expect(menus).toHaveLength(1);
|
||||
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
|
||||
|
||||
const closeEvent = keyEvent('Escape');
|
||||
await act(async () => {
|
||||
menus[0]!.props.onKeyDown(closeEvent);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(closeEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(accountFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(findButton(root.root, (label) => label.startsWith('账户菜单:')).props['aria-expanded']).toBe(false);
|
||||
expect(findMenus(root.root, '账户菜单')).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,20 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('App topbar tooltips', () => {
|
||||
it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => {
|
||||
it('removes hover-only tooltips from topbar and fixed desktop sidebar navigation', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
const topbarStart = source.indexOf('<div className="topbar-right">');
|
||||
const topbarEnd = source.indexOf('</header>');
|
||||
const sidebarItemStart = source.indexOf('const renderDesktopSidebarItem');
|
||||
const sidebarItemEnd = source.indexOf('return (', source.indexOf('<a'));
|
||||
|
||||
expect(topbarStart).toBeGreaterThanOrEqual(0);
|
||||
expect(topbarEnd).toBeGreaterThan(topbarStart);
|
||||
|
||||
const topbarSection = source.slice(topbarStart, topbarEnd);
|
||||
expect(topbarSection).not.toContain('data-tooltip=');
|
||||
expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}");
|
||||
expect(source).not.toContain('data-tooltip={sidebarCollapsed');
|
||||
expect(sidebarItemStart).toBeGreaterThanOrEqual(0);
|
||||
expect(sidebarItemEnd).toBeGreaterThan(sidebarItemStart);
|
||||
});
|
||||
});
|
||||
|
||||
+572
-124
File diff suppressed because it is too large
Load Diff
+354
-2
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api, type ProxyTestRequestEnvelope } from './api.js';
|
||||
import { persistAuthSession } from './authSession.js';
|
||||
import { api, buildSiteProbeStreamUrl, userApi, type ProxyTestRequestEnvelope } from './api.js';
|
||||
import { commitSession, persistAuthSession } from './authSession.js';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const store = new Map<string, string>();
|
||||
@@ -122,6 +122,20 @@ describe('api proxy test timeout handling', () => {
|
||||
expect(result.error.message).toBe('请求超时(120s)');
|
||||
});
|
||||
|
||||
it('builds site probe stream URLs with frontend camelCase query names', () => {
|
||||
expect(buildSiteProbeStreamUrl(9, {
|
||||
scope: 'single',
|
||||
modelName: ' gpt-4o ',
|
||||
latencyThresholdMs: 1800.9,
|
||||
})).toBe('/api/sites/9/probe-stream?scope=single&modelName=gpt-4o&latencyThresholdMs=1800');
|
||||
|
||||
expect(buildSiteProbeStreamUrl(9, {
|
||||
scope: 'all',
|
||||
modelName: 'gpt-4o',
|
||||
latencyThresholdMs: 0,
|
||||
})).toBe('/api/sites/9/probe-stream?scope=all');
|
||||
});
|
||||
|
||||
it('times out replay hydration file-content fetches after 30 seconds', async () => {
|
||||
installPendingFetch();
|
||||
|
||||
@@ -179,3 +193,341 @@ describe('api proxy test timeout handling', () => {
|
||||
expect(api.proxyTestStream).toBe(api.testProxyStream);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api oauth compatibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.stubGlobal('localStorage', createMemoryStorage());
|
||||
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('normalizes oauth provider responses before the page renders selectors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
providers: [
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
type: 'codex',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
provider: 'undefined',
|
||||
label: 'undefined',
|
||||
platform: 'undefined',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
value: 'gemini-cli',
|
||||
displayName: 'Gemini CLI',
|
||||
requires_project_id: true,
|
||||
},
|
||||
],
|
||||
defaults: {
|
||||
systemProxyConfigured: true,
|
||||
},
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await api.getOAuthProviders();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/oauth/providers', expect.any(Object));
|
||||
expect(result.defaults?.systemProxyConfigured).toBe(true);
|
||||
expect(result.providers).toEqual([
|
||||
{
|
||||
provider: 'codex',
|
||||
label: 'Codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
{
|
||||
provider: 'gemini-cli',
|
||||
label: 'Gemini CLI',
|
||||
platform: 'gemini-cli',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: true,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes legacy oauth connection list responses into the page contract', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
site_id: 2,
|
||||
username: 'codex-user',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-account-123',
|
||||
oauthProjectId: 'project-a',
|
||||
models_preview: '["gpt-5.4","gpt-5.2-codex"]',
|
||||
expires_at: '2030-01-01T00:00:00+00:00',
|
||||
token_expires_at: 1893456000000,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await api.getOAuthConnections({ limit: 25, offset: 5 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/oauth/connections?limit=25&offset=5');
|
||||
expect(result.items).toEqual([
|
||||
expect.objectContaining({
|
||||
accountId: 7,
|
||||
siteId: 2,
|
||||
username: 'codex-user',
|
||||
provider: 'codex',
|
||||
accountKey: 'chatgpt-account-123',
|
||||
projectId: 'project-a',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4', 'gpt-5.2-codex'],
|
||||
expiresAt: '2030-01-01T00:00:00+00:00',
|
||||
tokenExpiresAt: 1893456000000,
|
||||
status: 'healthy',
|
||||
}),
|
||||
]);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('uses the Rust oauth route paths for quota and proxy mutations', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await api.refreshOAuthConnectionQuota(7);
|
||||
await api.refreshOAuthConnectionQuotaBatch([7, 8]);
|
||||
await api.updateOAuthConnectionProxy(7, { proxyUrl: 'http://127.0.0.1:7890', useSystemProxy: false });
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[0], (call[1] as RequestInit | undefined)?.method])).toEqual([
|
||||
['/api/oauth/connections/7/refresh-quota', 'POST'],
|
||||
['/api/oauth/connections/refresh-quota-batch', 'POST'],
|
||||
['/api/oauth/connections/7/proxy', 'POST'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the Rust oauth route paths for connection import, deletion, and route pools', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await api.deleteOAuthConnection(7);
|
||||
await api.importOAuthConnections({ type: 'codex', access_token: 'token-a' });
|
||||
await api.importOAuthConnections({ items: [{ type: 'claude', access_token: 'token-b' }] });
|
||||
await api.createOAuthRouteUnit({
|
||||
accountIds: [7, 8],
|
||||
name: 'OAuth Pool',
|
||||
strategy: 'round_robin',
|
||||
});
|
||||
await api.updateOAuthRouteUnit(42, {
|
||||
name: 'OAuth Pool 2',
|
||||
strategy: 'stick_until_unavailable',
|
||||
enabled: true,
|
||||
});
|
||||
await api.deleteOAuthRouteUnit(42);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[0], (call[1] as RequestInit | undefined)?.method])).toEqual([
|
||||
['/api/oauth/connections/7', 'DELETE'],
|
||||
['/api/oauth/import', 'POST'],
|
||||
['/api/oauth/import', 'POST'],
|
||||
['/api/oauth/route-units', 'POST'],
|
||||
['/api/oauth/route-units/42', 'PUT'],
|
||||
['/api/oauth/route-units/42', 'DELETE'],
|
||||
]);
|
||||
|
||||
const singleImportInit = fetchMock.mock.calls[1]?.[1] as RequestInit | undefined;
|
||||
const batchImportInit = fetchMock.mock.calls[2]?.[1] as RequestInit | undefined;
|
||||
expect(singleImportInit?.body).toBe(JSON.stringify({ data: { type: 'codex', access_token: 'token-a' } }));
|
||||
expect(batchImportInit?.body).toBe(JSON.stringify({ items: [{ type: 'claude', access_token: 'token-b' }] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('api workflow model folder compatibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.stubGlobal('localStorage', createMemoryStorage());
|
||||
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('normalizes model folder responses from items, names, data, and value fields', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
success: true,
|
||||
items: [
|
||||
{ name: 'wan2.1-t2v', pathIndex: 0 },
|
||||
{ value: 'seedance-1-0-lite-t2v-250428' },
|
||||
],
|
||||
names: ['wan2.1-t2v', 'seedance-1-0-pro-t2v-250528'],
|
||||
data: [{ filename: 'kling-video' }],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const payload = await api.getWorkflowModelFolder('video_models');
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('/workflow-ui/api/experiment/models/video_models');
|
||||
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.headers).toBeInstanceOf(Headers);
|
||||
expect(((fetchMock.mock.calls[0]?.[1] as RequestInit)?.headers as Headers).get('Authorization')).toBe('Bearer token-1');
|
||||
expect(payload.names).toEqual([
|
||||
'wan2.1-t2v',
|
||||
'seedance-1-0-lite-t2v-250428',
|
||||
'kling-video',
|
||||
'seedance-1-0-pro-t2v-250528',
|
||||
]);
|
||||
expect(payload.items.map((item) => item.name)).toEqual(payload.names);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api proxy log filters', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.stubGlobal('localStorage', createMemoryStorage());
|
||||
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('serializes downstream key filters for server-driven proxy logs', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
items: [],
|
||||
total: 0,
|
||||
summary: { total: 0, success: 0, failed: 0 },
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await api.getProxyLogsQuery({
|
||||
limit: 25,
|
||||
offset: 50,
|
||||
status: 'failed',
|
||||
downstreamKeyId: 17,
|
||||
search: 'gpt-4o',
|
||||
});
|
||||
|
||||
const url = String(fetchMock.mock.calls[0]?.[0] || '');
|
||||
expect(url).toContain('/api/stats/proxy-logs?');
|
||||
expect(url).toContain('downstreamKeyId=17');
|
||||
expect(url).toContain('status=failed');
|
||||
expect(url).toContain('search=gpt-4o');
|
||||
expect(url).toContain('view=query');
|
||||
});
|
||||
|
||||
it('parses proxy log billing details from snake_case JSON strings', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: 42,
|
||||
model_requested: 'seedance-comfy',
|
||||
status: 'success',
|
||||
estimated_cost: 0.35,
|
||||
billing_details: JSON.stringify({
|
||||
source: 'comfy-native-task',
|
||||
totalCost: 0.35,
|
||||
steps: [{ name: 'archive_outputs', cost: 0.35 }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
summary: { total: 1, success: 1, failed: 0, cost: 0.35 },
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const response = await api.getProxyLogsQuery({ limit: 1 });
|
||||
|
||||
expect(response.items[0]?.billingDetails).toMatchObject({
|
||||
source: 'comfy-native-task',
|
||||
totalCost: 0.35,
|
||||
steps: [{ name: 'archive_outputs', cost: 0.35 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('user recharge api', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
const storage = createMemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
commitSession('user', 'user-token', undefined, storage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates recharge checkout records before returning the checkout url', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
success: true,
|
||||
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
|
||||
record: {
|
||||
id: 1,
|
||||
publicId: 'rch_test',
|
||||
provider: 'comfy_checkout',
|
||||
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
|
||||
status: 'pending',
|
||||
},
|
||||
}), { status: 201, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const payload = await userApi.createRechargeCheckout({
|
||||
optionId: 'usd-50',
|
||||
amountUsd: 50,
|
||||
});
|
||||
|
||||
expect(payload.record.publicId).toBe('rch_test');
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/recharge/checkout');
|
||||
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.method).toBe('POST');
|
||||
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.body).toBe(JSON.stringify({
|
||||
optionId: 'usd-50',
|
||||
amountUsd: 50,
|
||||
}));
|
||||
});
|
||||
|
||||
it('keeps billing guidance on quota errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
error: { message: '下游密钥成本额度已用完' },
|
||||
retry_after_secs: 60,
|
||||
billing: {
|
||||
reason: 'insufficient_quota',
|
||||
rechargePath: '/user/recharge',
|
||||
usagePath: '/user/usage',
|
||||
},
|
||||
}), { status: 429, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(userApi.createMoneyPrinterJob({ topic: 'demo' })).rejects.toMatchObject({
|
||||
status: 429,
|
||||
message: '下游密钥成本额度已用完',
|
||||
billing: {
|
||||
rechargePath: '/user/recharge',
|
||||
usagePath: '/user/usage',
|
||||
},
|
||||
retryAfterSecs: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+395
-21
@@ -11,6 +11,22 @@ type RequestOptions = RequestInit & {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ApiRequestError = Error & {
|
||||
status?: number;
|
||||
payload?: any;
|
||||
billing?: any;
|
||||
retryAfterSecs?: number | null;
|
||||
};
|
||||
|
||||
function createApiRequestError(message: string, status: number, payload: any): ApiRequestError {
|
||||
const error = new Error(message) as ApiRequestError;
|
||||
error.status = status;
|
||||
error.payload = payload;
|
||||
error.billing = payload?.billing ?? payload?.error?.billing ?? null;
|
||||
error.retryAfterSecs = payload?.retry_after_secs ?? payload?.retryAfterSecs ?? null;
|
||||
return error;
|
||||
}
|
||||
|
||||
function requireAuthToken(): string {
|
||||
const token = getSessionToken('admin', localStorage);
|
||||
if (!token) {
|
||||
@@ -28,13 +44,15 @@ function requireAuthToken(): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
async function extractResponseErrorMessage(res: Response): Promise<string> {
|
||||
async function extractResponseError(res: Response): Promise<ApiRequestError> {
|
||||
let message = `HTTP ${res.status}`;
|
||||
let payload: any = null;
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (text) {
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
payload = json;
|
||||
if (json?.message && typeof json.message === "string") {
|
||||
message = json.message;
|
||||
} else if (json?.error && typeof json.error === "string") {
|
||||
@@ -47,12 +65,19 @@ async function extractResponseErrorMessage(res: Response): Promise<string> {
|
||||
} else {
|
||||
message = `${message}: ${text.slice(0, 120)}`;
|
||||
}
|
||||
if (typeof json?.path === "string" && json.path.trim()) {
|
||||
message = `${message} (${json.path.trim()})`;
|
||||
}
|
||||
} catch {
|
||||
message = `${message}: ${text.slice(0, 120)}`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return message;
|
||||
return createApiRequestError(message, res.status, payload);
|
||||
}
|
||||
|
||||
async function extractResponseErrorMessage(res: Response): Promise<string> {
|
||||
return (await extractResponseError(res)).message;
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T = any>(res: Response): Promise<T> {
|
||||
@@ -248,13 +273,54 @@ export async function fetchSessionResponse(
|
||||
: fetchAuthenticatedResponse(url, options);
|
||||
}
|
||||
|
||||
export type SiteProbeOptions = {
|
||||
scope?: "single" | "all";
|
||||
modelName?: string;
|
||||
latencyThresholdMs?: number;
|
||||
};
|
||||
|
||||
export function buildSiteProbeStreamUrl(
|
||||
siteId: number,
|
||||
options: SiteProbeOptions = {},
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
const scope = options.scope === "all" ? "all" : "single";
|
||||
params.set("scope", scope);
|
||||
|
||||
const modelName = typeof options.modelName === "string"
|
||||
? options.modelName.trim()
|
||||
: "";
|
||||
if (scope === "single" && modelName) {
|
||||
params.set("modelName", modelName);
|
||||
}
|
||||
|
||||
const latencyThresholdMs = Number(options.latencyThresholdMs);
|
||||
if (Number.isFinite(latencyThresholdMs) && latencyThresholdMs > 0) {
|
||||
params.set("latencyThresholdMs", String(Math.trunc(latencyThresholdMs)));
|
||||
}
|
||||
|
||||
return `/api/sites/${siteId}/probe-stream?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function fetchSiteProbeStream(
|
||||
siteId: number,
|
||||
options: SiteProbeOptions = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
return fetchSessionResponse("admin", buildSiteProbeStreamUrl(siteId, options), {
|
||||
signal,
|
||||
headers: { Accept: "text/event-stream" },
|
||||
timeoutMs: options.scope === "all" ? 300_000 : 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function userRequest<T = any>(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const res = await fetchUserResponse(url, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
throw await extractResponseError(res);
|
||||
}
|
||||
return parseJsonResponse<T>(res);
|
||||
}
|
||||
@@ -265,7 +331,7 @@ async function request<T = any>(
|
||||
): Promise<T> {
|
||||
const res = await fetchAuthenticatedResponse(url, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
throw await extractResponseError(res);
|
||||
}
|
||||
return parseJsonResponse<T>(res);
|
||||
}
|
||||
@@ -299,6 +365,20 @@ function parseJsonArrayField(value: any): any[] {
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObjectField(value: any): any {
|
||||
if (!value) return null;
|
||||
if (typeof value === "object") return value;
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRouteRow(row: any): any {
|
||||
if (!row || typeof row !== "object") return row;
|
||||
return {
|
||||
@@ -392,6 +472,8 @@ function normalizeProxyLogRow(row: any): any {
|
||||
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
||||
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
|
||||
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
|
||||
billingDetails:
|
||||
row.billingDetails ?? parseJsonObjectField(row.billing_details),
|
||||
contentSummary: row.contentSummary ?? row.content_summary ?? null,
|
||||
requestContent: row.requestContent ?? row.request_content ?? null,
|
||||
responseContent: row.responseContent ?? row.response_content ?? null,
|
||||
@@ -472,6 +554,144 @@ function normalizeProxyDebugTraceDetail(response: any): ProxyDebugTraceDetail {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOAuthConnectionRow(row: any): OAuthConnectionInfo | null {
|
||||
if (!row || typeof row !== "object") return null;
|
||||
const rawAccountId = row.accountId ?? row.account_id ?? row.id;
|
||||
const accountId = Number(rawAccountId);
|
||||
if (!Number.isFinite(accountId) || accountId <= 0) return null;
|
||||
|
||||
const rawSiteId = row.siteId ?? row.site_id ?? row.site?.id ?? 0;
|
||||
const siteId = Number(rawSiteId);
|
||||
const provider = String(row.provider ?? row.oauthProvider ?? row.oauth_provider ?? "").trim();
|
||||
const modelsPreview = parseJsonArrayField(row.modelsPreview ?? row.models_preview)
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean);
|
||||
const modelCount = Number(row.modelCount ?? row.model_count ?? modelsPreview.length);
|
||||
const routeChannelCount = Number(row.routeChannelCount ?? row.route_channel_count);
|
||||
const rawStatus = String(row.status ?? "").trim().toLowerCase();
|
||||
const status: OAuthConnectionInfo["status"] = rawStatus === "healthy" || rawStatus === "active" || rawStatus === "enabled"
|
||||
? "healthy"
|
||||
: "abnormal";
|
||||
const rawSite = row.site && typeof row.site === "object" ? row.site : null;
|
||||
|
||||
return {
|
||||
...row,
|
||||
accountId,
|
||||
siteId: Number.isFinite(siteId) ? siteId : 0,
|
||||
provider,
|
||||
username: row.username ?? null,
|
||||
email: row.email ?? null,
|
||||
accountKey: row.accountKey ?? row.account_key ?? row.oauthAccountKey ?? row.oauth_account_key ?? null,
|
||||
planType: row.planType ?? row.plan_type ?? null,
|
||||
projectId: row.projectId ?? row.project_id ?? row.oauthProjectId ?? row.oauth_project_id ?? null,
|
||||
modelCount: Number.isFinite(modelCount) ? modelCount : modelsPreview.length,
|
||||
modelsPreview,
|
||||
status,
|
||||
quota: row.quota ?? null,
|
||||
expiresAt: row.expiresAt ?? row.expires_at ?? null,
|
||||
tokenExpiresAt: row.tokenExpiresAt ?? row.token_expires_at ?? null,
|
||||
routeChannelCount: Number.isFinite(routeChannelCount) ? routeChannelCount : undefined,
|
||||
lastModelSyncAt: row.lastModelSyncAt ?? row.last_model_sync_at ?? null,
|
||||
lastModelSyncError: row.lastModelSyncError ?? row.last_model_sync_error ?? null,
|
||||
proxyUrl: row.proxyUrl ?? row.proxy_url ?? null,
|
||||
useSystemProxy: row.useSystemProxy ?? row.use_system_proxy ?? false,
|
||||
routeUnit: row.routeUnit ?? row.route_unit ?? null,
|
||||
routeParticipation: row.routeParticipation ?? row.route_participation ?? null,
|
||||
site: rawSite
|
||||
? {
|
||||
id: Number(rawSite.id ?? rawSite.siteId ?? rawSite.site_id ?? siteId) || 0,
|
||||
name: String(rawSite.name ?? rawSite.siteName ?? rawSite.site_name ?? "").trim(),
|
||||
url: String(rawSite.url ?? rawSite.siteUrl ?? rawSite.site_url ?? "").trim(),
|
||||
platform: String(rawSite.platform ?? provider).trim(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOAuthConnectionsResponse(response: any): OAuthConnectionsResponse {
|
||||
const wrapped = wrapItemsResponse(response);
|
||||
const items = unwrapArrayResponse(wrapped)
|
||||
.map(normalizeOAuthConnectionRow)
|
||||
.filter((item): item is OAuthConnectionInfo => item != null);
|
||||
return {
|
||||
...wrapped,
|
||||
items,
|
||||
total: Number(wrapped.total ?? items.length),
|
||||
limit: Number(wrapped.limit ?? items.length),
|
||||
offset: Number(wrapped.offset ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function cleanOAuthProviderText(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
const text = value.trim();
|
||||
const lowerText = text.toLowerCase();
|
||||
if (lowerText === "undefined" || lowerText === "null") return "";
|
||||
return text;
|
||||
}
|
||||
|
||||
function resolveOAuthProviderFallbackLabel(providerKey: string): string {
|
||||
const normalized = providerKey.trim().toLowerCase();
|
||||
if (normalized === "codex") return "Codex";
|
||||
if (normalized === "openai") return "OpenAI";
|
||||
if (normalized === "anthropic") return "Anthropic";
|
||||
if (normalized === "claude") return "Claude";
|
||||
if (normalized === "google") return "Google AI";
|
||||
if (normalized === "gemini-cli" || normalized === "gemini") return "Gemini CLI";
|
||||
if (normalized === "antigravity") return "Antigravity";
|
||||
return providerKey;
|
||||
}
|
||||
|
||||
function normalizeOAuthProviderRow(row: any): OAuthProviderInfo | null {
|
||||
if (!row || typeof row !== "object") return null;
|
||||
|
||||
const provider = cleanOAuthProviderText(row.provider)
|
||||
|| cleanOAuthProviderText(row.oauthProvider)
|
||||
|| cleanOAuthProviderText(row.oauth_provider)
|
||||
|| cleanOAuthProviderText(row.id)
|
||||
|| cleanOAuthProviderText(row.key)
|
||||
|| cleanOAuthProviderText(row.value)
|
||||
|| cleanOAuthProviderText(row.type);
|
||||
if (!provider) return null;
|
||||
|
||||
const label = cleanOAuthProviderText(row.label)
|
||||
|| cleanOAuthProviderText(row.name)
|
||||
|| cleanOAuthProviderText(row.displayName)
|
||||
|| cleanOAuthProviderText(row.display_name)
|
||||
|| resolveOAuthProviderFallbackLabel(provider);
|
||||
const platform = cleanOAuthProviderText(row.platform)
|
||||
|| cleanOAuthProviderText(row.type)
|
||||
|| provider;
|
||||
|
||||
return {
|
||||
provider,
|
||||
label,
|
||||
platform,
|
||||
enabled: row.enabled !== false,
|
||||
loginType: "oauth",
|
||||
requiresProjectId: row.requiresProjectId === true || row.requires_project_id === true,
|
||||
supportsDirectAccountRouting: row.supportsDirectAccountRouting ?? row.supports_direct_account_routing ?? true,
|
||||
supportsCloudValidation: row.supportsCloudValidation ?? row.supports_cloud_validation ?? true,
|
||||
supportsNativeProxy: row.supportsNativeProxy ?? row.supports_native_proxy ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOAuthProvidersResponse(response: any): OAuthProvidersResponse {
|
||||
const rawProviders: unknown[] = Array.isArray(response?.providers) ? response.providers : [];
|
||||
const providers = rawProviders
|
||||
.map(normalizeOAuthProviderRow)
|
||||
.filter((item): item is OAuthProviderInfo => item != null);
|
||||
return {
|
||||
...response,
|
||||
providers: Array.from(
|
||||
new Map(providers.map((provider) => [provider.provider, provider])).values(),
|
||||
),
|
||||
defaults: response?.defaults && typeof response.defaults === "object"
|
||||
? response.defaults
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function streamSse(
|
||||
url: string,
|
||||
handlers: {
|
||||
@@ -789,6 +1009,7 @@ export type ProxyLogListItem = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
estimatedCost?: number | null;
|
||||
billingDetails?: ProxyLogBillingDetails;
|
||||
contentSummary?: string | null;
|
||||
requestContent?: string | null;
|
||||
responseContent?: string | null;
|
||||
@@ -816,6 +1037,7 @@ export type ProxyLogsQuery = {
|
||||
search?: string;
|
||||
client?: string;
|
||||
siteId?: number;
|
||||
downstreamKeyId?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
@@ -1002,6 +1224,8 @@ export type OAuthConnectionInfo = {
|
||||
modelsPreview: string[];
|
||||
status: "healthy" | "abnormal";
|
||||
quota?: OAuthQuotaInfo | null;
|
||||
expiresAt?: string | null;
|
||||
tokenExpiresAt?: number | null;
|
||||
routeChannelCount?: number;
|
||||
lastModelSyncAt?: string | null;
|
||||
lastModelSyncError?: string | null;
|
||||
@@ -1034,11 +1258,12 @@ export type OAuthQuotaBatchRefreshResponse = {
|
||||
export type OAuthImportResponse = {
|
||||
success: boolean;
|
||||
imported: number;
|
||||
updated?: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
items: Array<{
|
||||
name: string;
|
||||
status: "imported" | "skipped" | "failed";
|
||||
status: "imported" | "updated" | "skipped" | "failed";
|
||||
accountId?: number;
|
||||
provider?: string;
|
||||
message?: string;
|
||||
@@ -1192,6 +1417,22 @@ export type DigitalHumanJobsResponse = {
|
||||
jobs: DigitalHumanJob[];
|
||||
};
|
||||
|
||||
export type DigitalHumanStatusResponse = {
|
||||
success: boolean;
|
||||
mode: string;
|
||||
keyName?: string;
|
||||
capabilities?: string[];
|
||||
runninghub?: {
|
||||
configured?: boolean;
|
||||
endpoint?: string;
|
||||
};
|
||||
comfy?: {
|
||||
configured?: boolean;
|
||||
backend?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MoneyPrinterJob = DigitalHumanJob;
|
||||
|
||||
export type MoneyPrinterJobResponse = {
|
||||
@@ -1219,6 +1460,7 @@ export type MoneyPrinterStatusResponse = {
|
||||
label?: string;
|
||||
nodeType?: string;
|
||||
model?: string;
|
||||
models?: string[];
|
||||
duration?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
@@ -1229,6 +1471,117 @@ export type MoneyPrinterStatusResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type UserRechargeRecord = {
|
||||
id: number;
|
||||
publicId: string;
|
||||
userId?: number;
|
||||
optionId?: string | null;
|
||||
label?: string | null;
|
||||
title?: string | null;
|
||||
amountUsd?: number | null;
|
||||
provider: string;
|
||||
checkoutUrl: string;
|
||||
status: string;
|
||||
note?: string | null;
|
||||
metadata?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
};
|
||||
|
||||
export type UserBillingSummary = {
|
||||
keyCount: number;
|
||||
usedCost: number;
|
||||
maxCost?: number | null;
|
||||
remainingCost?: number | null;
|
||||
unlimitedKeyCount: number;
|
||||
rechargePath: string;
|
||||
usagePath: string;
|
||||
};
|
||||
|
||||
export type UserRechargeRecordsResponse = {
|
||||
success: boolean;
|
||||
records: UserRechargeRecord[];
|
||||
items: UserRechargeRecord[];
|
||||
billing?: UserBillingSummary;
|
||||
arrivalNote?: string;
|
||||
checkoutProvider?: string;
|
||||
usagePath?: string;
|
||||
rechargePath?: string;
|
||||
};
|
||||
|
||||
export type CreateUserRechargeCheckoutPayload = {
|
||||
optionId?: string;
|
||||
amountUsd?: number;
|
||||
};
|
||||
|
||||
export type CreateUserRechargeCheckoutResponse = {
|
||||
success: boolean;
|
||||
record: UserRechargeRecord;
|
||||
checkoutUrl: string;
|
||||
arrivalNote?: string;
|
||||
checkoutProvider?: string;
|
||||
billing?: UserBillingSummary;
|
||||
usagePath?: string;
|
||||
rechargePath?: string;
|
||||
};
|
||||
|
||||
export type WorkflowModelItem = {
|
||||
name: string;
|
||||
pathIndex?: number;
|
||||
path_index?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type WorkflowModelFolderResponse = {
|
||||
success: boolean;
|
||||
items: WorkflowModelItem[];
|
||||
names: string[];
|
||||
};
|
||||
|
||||
function normalizeWorkflowModelFolderResponse(value: any): WorkflowModelFolderResponse {
|
||||
const rawItems = Array.isArray(value)
|
||||
? value
|
||||
: ["items", "data", "models", "names"].flatMap((key) => (
|
||||
Array.isArray(value?.[key]) ? value[key] : []
|
||||
));
|
||||
const seen = new Set<string>();
|
||||
const items: WorkflowModelItem[] = [];
|
||||
|
||||
for (const item of rawItems) {
|
||||
const name = String(
|
||||
typeof item === "string"
|
||||
? item
|
||||
: item?.name ?? item?.model ?? item?.value ?? item?.filename ?? item?.path ?? "",
|
||||
).trim();
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
items.push({
|
||||
...(item && typeof item === "object" && !Array.isArray(item) ? item : {}),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: value?.success ?? true,
|
||||
items,
|
||||
names: items.map((item) => item.name),
|
||||
};
|
||||
}
|
||||
|
||||
async function workflowModelFolderRequest(
|
||||
requester: <T = any>(url: string, options?: RequestOptions) => Promise<T>,
|
||||
folder: string,
|
||||
): Promise<WorkflowModelFolderResponse> {
|
||||
const normalized = folder.trim();
|
||||
if (!normalized) return { success: true, items: [], names: [] };
|
||||
const value = await requester<any>(
|
||||
`/workflow-ui/api/experiment/models/${encodeURIComponent(normalized)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
return normalizeWorkflowModelFolderResponse(value);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: async () => {
|
||||
@@ -1258,12 +1611,14 @@ export const api = {
|
||||
}),
|
||||
getSiteAvailableModels: (siteId: number) =>
|
||||
request(`/api/sites/${siteId}/available-models`),
|
||||
probeSiteNow: (siteId: number, options?: { scope?: 'single' | 'all'; modelName?: string; latencyThresholdMs?: number }) =>
|
||||
probeSiteNow: (siteId: number, options?: SiteProbeOptions) =>
|
||||
request(`/api/sites/${siteId}/probe-now`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options || {}),
|
||||
timeoutMs: options?.scope === 'all' ? 120_000 : 30_000,
|
||||
}),
|
||||
probeSiteStream: (siteId: number, options?: SiteProbeOptions, signal?: AbortSignal) =>
|
||||
fetchSiteProbeStream(siteId, options, signal),
|
||||
|
||||
// Accounts
|
||||
getAccounts: async (params?: { includeOauth?: boolean }) => {
|
||||
@@ -1643,7 +1998,8 @@ export const api = {
|
||||
|
||||
// OAuth
|
||||
getOAuthProviders: () =>
|
||||
request("/api/oauth/providers") as Promise<OAuthProvidersResponse>,
|
||||
request("/api/oauth/providers")
|
||||
.then(normalizeOAuthProvidersResponse) as Promise<OAuthProvidersResponse>,
|
||||
startOAuthProvider: (
|
||||
provider: string,
|
||||
data?: {
|
||||
@@ -1672,14 +2028,14 @@ export const api = {
|
||||
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
||||
request(
|
||||
`/api/oauth/connections${buildQueryString(params)}`,
|
||||
) as Promise<OAuthConnectionsResponse>,
|
||||
).then(normalizeOAuthConnectionsResponse) as Promise<OAuthConnectionsResponse>,
|
||||
refreshOAuthConnectionQuota: (accountId: number) =>
|
||||
request(`/api/oauth/connections/${accountId}/quota/refresh`, {
|
||||
request(`/api/oauth/connections/${accountId}/refresh-quota`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
|
||||
refreshOAuthConnectionQuotaBatch: (accountIds: number[]) =>
|
||||
request("/api/oauth/connections/quota/refresh-batch", {
|
||||
request("/api/oauth/connections/refresh-quota-batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ accountIds }),
|
||||
}) as Promise<OAuthQuotaBatchRefreshResponse>,
|
||||
@@ -1688,7 +2044,7 @@ export const api = {
|
||||
data: { proxyUrl?: string | null; useSystemProxy?: boolean },
|
||||
) =>
|
||||
request(`/api/oauth/connections/${accountId}/proxy`, {
|
||||
method: "PATCH",
|
||||
method: "POST",
|
||||
body: JSON.stringify(data || {}),
|
||||
}) as Promise<{ success: true }>,
|
||||
rebindOAuthConnection: (
|
||||
@@ -1717,6 +2073,18 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}) as Promise<OAuthRouteUnitMutationResponse>,
|
||||
updateOAuthRouteUnit: (
|
||||
routeUnitId: number,
|
||||
data: {
|
||||
name?: string;
|
||||
strategy?: OAuthRouteUnitStrategy;
|
||||
enabled?: boolean;
|
||||
},
|
||||
) =>
|
||||
request(`/api/oauth/route-units/${routeUnitId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data || {}),
|
||||
}) as Promise<OAuthRouteUnitMutationResponse>,
|
||||
deleteOAuthRouteUnit: (routeUnitId: number) =>
|
||||
request(`/api/oauth/route-units/${routeUnitId}`, {
|
||||
method: "DELETE",
|
||||
@@ -1923,15 +2291,6 @@ export const api = {
|
||||
testNotification: () =>
|
||||
request("/api/settings/notify/test", { method: "POST" }),
|
||||
|
||||
// Monitor embed
|
||||
getMonitorConfig: () => request("/api/monitor/config"),
|
||||
updateMonitorConfig: (data: { ldohCookie?: string | null }) =>
|
||||
request("/api/monitor/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
initMonitorSession: () => request("/api/monitor/session", { method: "POST" }),
|
||||
|
||||
// Models marketplace
|
||||
getModelsMarketplace: (options?: {
|
||||
refresh?: boolean;
|
||||
@@ -2057,6 +2416,8 @@ export const api = {
|
||||
}),
|
||||
getWorkflowNodeTypes: () =>
|
||||
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
||||
getWorkflowModelFolder: (folder: string) =>
|
||||
workflowModelFolderRequest(request, folder),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
request<{ success: boolean; items: WorkflowSummary[] }>(`/api/workflows${buildQueryString(params)}`),
|
||||
createWorkflow: (data: WorkflowSavePayload) =>
|
||||
@@ -2075,6 +2436,8 @@ export const api = {
|
||||
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
getDigitalHumanStatus: () =>
|
||||
request<DigitalHumanStatusResponse>("/api/digital-human/status"),
|
||||
createDigitalHumanJob: (data: any) =>
|
||||
request<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
||||
method: "POST",
|
||||
@@ -2129,10 +2492,17 @@ export const userApi = {
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteApiKey: (id: number) =>
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
|
||||
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
|
||||
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; key_id?: number; key?: string; status?: string; date_from?: string; date_to?: string }) =>
|
||||
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
||||
getSummary: () => userRequest("/api/user/summary"),
|
||||
getDashboard: () => userRequest("/api/user/dashboard"),
|
||||
getRechargeRecords: () =>
|
||||
userRequest<UserRechargeRecordsResponse>("/api/user/recharge-records"),
|
||||
createRechargeCheckout: (data: CreateUserRechargeCheckoutPayload) =>
|
||||
userRequest<CreateUserRechargeCheckoutResponse>("/api/user/recharge/checkout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getModelsMarketplace: (options?: {
|
||||
refresh?: boolean;
|
||||
includePricing?: boolean;
|
||||
@@ -2162,6 +2532,8 @@ export const userApi = {
|
||||
}),
|
||||
getWorkflowNodeTypes: () =>
|
||||
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
||||
getWorkflowModelFolder: (folder: string) =>
|
||||
workflowModelFolderRequest(userRequest, folder),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
userRequest<{ success: boolean; items: WorkflowSummary[] }>(`/api/user/workflows${buildQueryString(params)}`),
|
||||
createWorkflow: (data: WorkflowSavePayload) =>
|
||||
@@ -2180,6 +2552,8 @@ export const userApi = {
|
||||
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
getDigitalHumanStatus: () =>
|
||||
userRequest<DigitalHumanStatusResponse>("/api/digital-human/status"),
|
||||
createDigitalHumanJob: (data: any) =>
|
||||
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clearSession } from './authSession.js';
|
||||
|
||||
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
||||
export const THEME_PRESET_STORAGE_KEY = 'theme_preset';
|
||||
export const LEGACY_THEME_STORAGE_KEY = 'theme';
|
||||
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
||||
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
|
||||
@@ -25,6 +26,7 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
|
||||
if (!target) return;
|
||||
clearSession('admin', target as never);
|
||||
target.removeItem(THEME_MODE_STORAGE_KEY);
|
||||
target.removeItem(THEME_PRESET_STORAGE_KEY);
|
||||
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
||||
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
||||
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
describe('AppMotion animation contract', () => {
|
||||
it('uses GSAP matchMedia so page transitions respect reduced motion and viewport density', () => {
|
||||
const source = readSource('src/web/components/AppMotion.tsx');
|
||||
|
||||
expect(source).toContain("import { gsap } from 'gsap';");
|
||||
expect(source).toContain('const mm = gsap.matchMedia();');
|
||||
expect(source).toContain("reduceMotion: '(prefers-reduced-motion: reduce)'");
|
||||
expect(source).toContain("isDesktop: '(min-width: 900px)'");
|
||||
expect(source).toContain('if (conditions.reduceMotion) {');
|
||||
expect(source).toContain("gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
|
||||
expect(source).toContain('return () => mm.revert();');
|
||||
});
|
||||
|
||||
it('keeps route/login entrance motion scoped, subtle, and cleaned up on unmount', () => {
|
||||
const source = readSource('src/web/components/AppMotion.tsx');
|
||||
|
||||
expect(source).toContain('motionVariant === \'login\' ? 14 : (conditions.isDesktop ? 26 : 16)');
|
||||
expect(source).toContain('const timeline = gsap.timeline({');
|
||||
expect(source).toContain("ease: 'power2.out'");
|
||||
expect(source).toContain("overwrite: 'auto'");
|
||||
expect(source).toContain('timeline.fromTo(');
|
||||
expect(source).toContain("stagger: {\n each: motionVariant === 'login' ? 0.045 : 0.026,");
|
||||
expect(source).toContain('timeline.kill();');
|
||||
expect(source).toContain('gsap.killTweensOf([root, ...targets]);');
|
||||
expect(source).toContain("gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });");
|
||||
});
|
||||
|
||||
it('includes the newer dashboard operations blocks in the GSAP entrance target set', () => {
|
||||
const source = readSource('src/web/components/AppMotion.tsx');
|
||||
|
||||
expect(source).toContain("':scope > .dashboard-war-room'");
|
||||
expect(source).toContain("':scope > .dashboard-command-strip'");
|
||||
expect(source).toContain("':scope .dashboard-pulse-card'");
|
||||
expect(source).toContain("':scope .dashboard-risk-panel'");
|
||||
expect(source).toContain("':scope .dashboard-command-item'");
|
||||
expect(source).toContain("':scope .ops-monitor-log-card'");
|
||||
expect(source).toContain("':scope .responsive-batch-action-bar'");
|
||||
});
|
||||
|
||||
it('keeps responsive batch action bars reachable by the GSAP selector', () => {
|
||||
const mobileBatchBar = readSource('src/web/components/MobileBatchBar.tsx');
|
||||
const responsiveBatchBar = readSource('src/web/components/ResponsiveBatchActionBar.tsx');
|
||||
|
||||
expect(mobileBatchBar).toContain('responsive-batch-action-bar');
|
||||
expect(responsiveBatchBar).toContain('responsive-batch-action-bar');
|
||||
expect(mobileBatchBar).toContain('className="mobile-actions-info" role="status" aria-live="polite"');
|
||||
expect(responsiveBatchBar).toContain('<span role="status" aria-live="polite"');
|
||||
});
|
||||
|
||||
it('keeps high-frequency overlay motion reduced-motion friendly', () => {
|
||||
const css = readSource('src/web/index.css');
|
||||
|
||||
expect(css).toContain('@media (prefers-reduced-motion: reduce) {');
|
||||
expect(css).toContain('.user-dropdown,\n .user-dropdown.is-closing');
|
||||
expect(css).toContain('.modal-backdrop,\n .modal-backdrop.is-closing');
|
||||
expect(css).toContain('.mobile-drawer-panel,\n .mobile-drawer-panel-right');
|
||||
expect(css).toContain('.filter-panel.filter-collapsible,\n .filter-panel.filter-collapsible.is-closing');
|
||||
expect(css).toContain('animation: none !important;');
|
||||
expect(css).toContain('transition: none !important;');
|
||||
expect(css).toContain('transform: none !important;');
|
||||
expect(css).toContain('.user-dropdown.is-closing,\n .modal-backdrop.is-closing');
|
||||
expect(css).not.toContain('.main-content.is-route-transitioning');
|
||||
expect(css).not.toContain('.route-progress');
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,33 @@ const PAGE_TARGET_SELECTOR = [
|
||||
':scope > .picture-agent-shell',
|
||||
':scope > .workspace-layout',
|
||||
':scope > .dashboard-grid',
|
||||
':scope > .dashboard-war-room',
|
||||
':scope > .dashboard-command-strip',
|
||||
':scope > .digital-human-layout',
|
||||
':scope > .money-printer-layout',
|
||||
':scope > .about-page',
|
||||
':scope .console-hero',
|
||||
':scope .page-header',
|
||||
':scope .stat-card',
|
||||
':scope .chart-container',
|
||||
':scope .dashboard-panel',
|
||||
':scope .dashboard-grid',
|
||||
':scope .dashboard-pulse-card',
|
||||
':scope .dashboard-risk-panel',
|
||||
':scope .dashboard-command-item',
|
||||
':scope .ops-monitor-log-card',
|
||||
':scope .card',
|
||||
':scope .mobile-card',
|
||||
':scope .data-table',
|
||||
':scope .workflow-shell',
|
||||
':scope .workspace-layout',
|
||||
':scope .picture-agent-shell',
|
||||
':scope .digital-human-layout',
|
||||
':scope .money-printer-layout',
|
||||
':scope .settings-card',
|
||||
':scope .settings-section-nav',
|
||||
':scope .admin-ops-toolbar',
|
||||
':scope .responsive-filter-panel',
|
||||
':scope .responsive-batch-action-bar',
|
||||
].join(', ');
|
||||
|
||||
const LOGIN_TARGET_SELECTOR = [
|
||||
@@ -35,9 +59,22 @@ const LOGIN_TARGET_SELECTOR = [
|
||||
'.login-auth-footer',
|
||||
].join(', ');
|
||||
|
||||
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant): Element[] {
|
||||
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant, limit: number): Element[] {
|
||||
const selector = variant === 'login' ? LOGIN_TARGET_SELECTOR : PAGE_TARGET_SELECTOR;
|
||||
return Array.from(root.querySelectorAll(selector)).slice(0, variant === 'login' ? 14 : 18);
|
||||
const selected: Element[] = [];
|
||||
|
||||
for (const element of Array.from(root.querySelectorAll(selector))) {
|
||||
if (selected.some((selectedElement) => selectedElement.contains(element))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(element);
|
||||
if (selected.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export default function MotionDiv({
|
||||
@@ -62,45 +99,63 @@ export default function MotionDiv({
|
||||
},
|
||||
(context) => {
|
||||
const conditions = context.conditions || {};
|
||||
const targets = selectMotionTargets(
|
||||
root,
|
||||
motionVariant,
|
||||
motionVariant === 'login' ? 14 : (conditions.isDesktop ? 26 : 16),
|
||||
);
|
||||
|
||||
if (conditions.reduceMotion) {
|
||||
gsap.set(root, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targets = selectMotionTargets(root, motionVariant);
|
||||
const distance = motionVariant === 'login' ? 16 : 10;
|
||||
const distance = motionVariant === 'login' ? 16 : (conditions.isDesktop ? 12 : 8);
|
||||
const timeline = gsap.timeline({
|
||||
defaults: {
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
const rootTween = gsap.fromTo(
|
||||
timeline.fromTo(
|
||||
root,
|
||||
{ autoAlpha: 0, y: Math.max(6, distance - 4) },
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: motionVariant === 'login' ? 0.46 : 0.34,
|
||||
ease: 'power2.out',
|
||||
duration: motionVariant === 'login' ? 0.46 : 0.32,
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
);
|
||||
|
||||
const targetTween = targets.length
|
||||
? gsap.fromTo(
|
||||
if (targets.length) {
|
||||
timeline.fromTo(
|
||||
targets,
|
||||
{ autoAlpha: 0, y: distance },
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: distance,
|
||||
scale: motionVariant === 'login' ? 0.992 : 0.996,
|
||||
},
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: motionVariant === 'login' ? 0.58 : 0.42,
|
||||
ease: 'power2.out',
|
||||
stagger: motionVariant === 'login' ? 0.045 : 0.025,
|
||||
delay: 0.04,
|
||||
scale: 1,
|
||||
duration: motionVariant === 'login' ? 0.58 : 0.38,
|
||||
stagger: {
|
||||
each: motionVariant === 'login' ? 0.045 : 0.026,
|
||||
from: 'start',
|
||||
},
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
)
|
||||
: null;
|
||||
motionVariant === 'login' ? '-=0.2' : '-=0.18',
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
rootTween.kill();
|
||||
targetTween?.kill();
|
||||
timeline.kill();
|
||||
gsap.killTweensOf([root, ...targets]);
|
||||
gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useIconCdn() {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-theme-preset'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useId, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
type CenteredModalProps = {
|
||||
@@ -28,6 +29,9 @@ export default function CenteredModal({
|
||||
showCloseButton = true,
|
||||
}: CenteredModalProps) {
|
||||
const presence = useAnimatedVisibility(open, 220);
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const canUsePortal = typeof document !== 'undefined'
|
||||
&& !!document.body
|
||||
&& typeof document.body.appendChild === 'function'
|
||||
@@ -43,9 +47,52 @@ export default function CenteredModal({
|
||||
}, [canUsePortal, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape || !canUsePortal) return;
|
||||
if (open) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
const focusDialog = () => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && dialog.contains(currentActiveElement)) return;
|
||||
dialog.focus({ preventScroll: true });
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const animationFrame = window.requestAnimationFrame(focusDialog);
|
||||
return () => {
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}
|
||||
focusDialog();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
return undefined;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canUsePortal) return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
if (event.key === 'Escape' && closeOnEscape) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, dialogRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
@@ -55,24 +102,33 @@ export default function CenteredModal({
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
style={{ maxWidth }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<div id={titleId} className="modal-title">{title}</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹框"
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from './Toast.js';
|
||||
import { commitSession } from '../authSession.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
|
||||
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const presence = useAnimatedVisibility(open, 200);
|
||||
const [oldToken, setOldToken] = useState('');
|
||||
const [newToken, setNewToken] = useState('');
|
||||
const [confirmToken, setConfirmToken] = useState('');
|
||||
@@ -14,15 +12,6 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
const [error, setError] = useState('');
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
if (!oldToken || !newToken || !confirmToken) {
|
||||
@@ -58,67 +47,66 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
}
|
||||
};
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
|
||||
<div className="modal-header">修改管理员 Token</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldToken}
|
||||
onChange={e => { setOldToken(e.target.value); setError(''); }}
|
||||
placeholder="输入当前 Token"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newToken}
|
||||
onChange={e => { setNewToken(e.target.value); setError(''); }}
|
||||
placeholder="输入新 Token (至少 6 位)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmToken}
|
||||
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
|
||||
placeholder="再次输入新 Token"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button onClick={onClose} className="btn btn-ghost">取消</button>
|
||||
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
|
||||
return (
|
||||
<CenteredModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="修改管理员 Token"
|
||||
maxWidth={420}
|
||||
closeOnBackdrop
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost">取消</button>
|
||||
<button type="button" onClick={handleSubmit} disabled={saving} className="btn btn-primary">
|
||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldToken}
|
||||
onChange={e => { setOldToken(e.target.value); setError(''); }}
|
||||
placeholder="输入当前 Token"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newToken}
|
||||
onChange={e => { setNewToken(e.target.value); setError(''); }}
|
||||
placeholder="输入新 Token (至少 6 位)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmToken}
|
||||
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
|
||||
placeholder="再次输入新 Token"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CenteredModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,11 +28,12 @@ export default function DeleteConfirmModal({
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
maxWidth={560}
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
|
||||
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
|
||||
<button type="button" onClick={onConfirm} className="btn btn-danger" disabled={loading}>
|
||||
{loading
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
|
||||
: confirmText}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { create, type ReactTestInstance } from "react-test-renderer";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import DiagnosticActionGroup from "./DiagnosticActionGroup.js";
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || [])
|
||||
.map((child) => (typeof child === "string" ? child : collectText(child)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("DiagnosticActionGroup", () => {
|
||||
it("renders router links, buttons, and muted hints as a compact action group", () => {
|
||||
const onClick = vi.fn();
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
<DiagnosticActionGroup
|
||||
actions={[
|
||||
{ label: "查看失败日志", href: "/logs?status=failed", tone: "danger" },
|
||||
{ label: "重新绑定", onClick, hint: "更新连接凭证" },
|
||||
{ label: "等待首次调用", hint: "首次代理请求后自动更新", tone: "muted" },
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const links = root.root.findAll((node) => node.type === "a");
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.props.href).toBe("/logs?status=failed");
|
||||
|
||||
const button = root.root.find((node) => node.type === "button");
|
||||
expect(collectText(button)).toBe("重新绑定");
|
||||
button.props.onClick({ stopPropagation: vi.fn() });
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(collectText(root.root)).toContain("等待首次调用");
|
||||
expect(collectText(root.root)).toContain("首次代理请求后自动更新");
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it("falls back to normal anchors outside a router", () => {
|
||||
const root = create(
|
||||
<DiagnosticActionGroup
|
||||
actions={[{ label: "日志", href: "/logs" }]}
|
||||
/>,
|
||||
);
|
||||
const link = root.root.find((node) => node.type === "a");
|
||||
expect(link.props.href).toBe("/logs");
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it("keeps nested action clicks from bubbling into parent row handlers", () => {
|
||||
const parentClick = vi.fn();
|
||||
const stopPropagation = vi.fn();
|
||||
const root = create(
|
||||
<div onClick={parentClick}>
|
||||
<MemoryRouter>
|
||||
<DiagnosticActionGroup
|
||||
actions={[{ label: "日志", href: "/logs" }]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const link = root.root.find((node) => node.type === "a");
|
||||
link.props.onClick({ stopPropagation });
|
||||
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1);
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it("renders disabled actions as inert status pills", () => {
|
||||
const onClick = vi.fn();
|
||||
const root = create(
|
||||
<DiagnosticActionGroup
|
||||
actions={[{ label: "复制验证结果", onClick, disabled: true, hint: "等待一次验证回复" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(root.root.findAll((node) => node.type === "button")).toHaveLength(0);
|
||||
const disabledAction = root.root.find((node) => (
|
||||
node.type === "span"
|
||||
&& node.props["aria-disabled"] === "true"
|
||||
));
|
||||
expect(collectText(disabledAction)).toBe("复制验证结果");
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Link, useInRouterContext } from "react-router-dom";
|
||||
|
||||
export type DiagnosticAction = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
tone?: "primary" | "danger" | "muted";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DiagnosticActionGroupProps = {
|
||||
actions: DiagnosticAction[];
|
||||
className?: string;
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
export default function DiagnosticActionGroup({
|
||||
actions,
|
||||
className = "",
|
||||
interactive = true,
|
||||
}: DiagnosticActionGroupProps) {
|
||||
const inRouter = useInRouterContext();
|
||||
const visibleActions = actions.filter(
|
||||
(action) => action.label && (action.href || action.onClick || action.hint),
|
||||
);
|
||||
if (visibleActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className={`diagnostic-action-group ${className}`.trim()}>
|
||||
{visibleActions.map((action) => {
|
||||
const tone = action.tone || "primary";
|
||||
const key = `${action.label}-${action.href || action.hint || "action"}`;
|
||||
const body = action.disabled ? (
|
||||
<span
|
||||
className="diagnostic-action is-disabled"
|
||||
data-tone={tone}
|
||||
aria-disabled="true"
|
||||
>
|
||||
{action.label}
|
||||
</span>
|
||||
) : action.href ? (
|
||||
inRouter ? (
|
||||
<Link
|
||||
className="diagnostic-action"
|
||||
data-tone={tone}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
to={action.href}
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
className="diagnostic-action"
|
||||
data-tone={tone}
|
||||
href={action.href}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
>
|
||||
{action.label}
|
||||
</a>
|
||||
)
|
||||
) : action.onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="diagnostic-action"
|
||||
data-tone={tone}
|
||||
disabled={false}
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
action.onClick?.();
|
||||
}}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="diagnostic-action is-muted" data-tone="muted">
|
||||
{action.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="diagnostic-action-item" key={key}>
|
||||
{body}
|
||||
{action.hint ? (
|
||||
<span className="diagnostic-action-hint">{action.hint}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
describe('ListPresenceMotion animation contract', () => {
|
||||
it('uses GSAP matchMedia with reduced-motion cleanup for async list transitions', () => {
|
||||
const source = readSource('src/web/components/ListPresenceMotion.tsx');
|
||||
|
||||
expect(source).toContain("import { gsap } from 'gsap';");
|
||||
expect(source).toContain('const mm = gsap.matchMedia();');
|
||||
expect(source).toContain("reduceMotion: '(prefers-reduced-motion: reduce)'");
|
||||
expect(source).toContain("isDesktop: '(min-width: 900px)'");
|
||||
expect(source).toContain('if (conditions.reduceMotion) {');
|
||||
expect(source).toContain("gsap.set(animatedNodes, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
|
||||
expect(source).toContain('timeline.fromTo(');
|
||||
expect(source).toContain('stagger: targets.length > 1');
|
||||
expect(source).toContain('timeline.kill();');
|
||||
expect(source).toContain('gsap.killTweensOf(animatedNodes);');
|
||||
expect(source).toContain('return () => mm.revert();');
|
||||
});
|
||||
|
||||
it('keeps TokenRoutes full rendering while animating the loading-to-ready list swap', () => {
|
||||
const source = readSource('src/web/pages/TokenRoutes.tsx');
|
||||
|
||||
expect(source).toContain("import ListPresenceMotion from '../components/ListPresenceMotion.js';");
|
||||
expect(source).toContain('<ListPresenceMotion');
|
||||
expect(source).toContain("targetSelector=\".route-loading-card, .route-card-grid > *, .mobile-card-list > *\"");
|
||||
expect(source).toContain('maxTargets={isMobile ? 16 : 24}');
|
||||
expect(source).toContain("motionKey={`${initialLoading ? 'loading' : 'ready'}:${routeSummaries.length}:${isMobile ? 'mobile' : 'desktop'}`}");
|
||||
expect(source).toContain('() => filteredRoutes,\n [filteredRoutes],');
|
||||
expect(source).not.toContain('visibleRouteCount');
|
||||
expect(source).not.toContain('当前已加载路由');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
interface ListPresenceMotionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
motionKey: string;
|
||||
targetSelector?: string;
|
||||
maxTargets?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TARGET_SELECTOR = ':scope > *';
|
||||
|
||||
function selectPresenceTargets(root: HTMLDivElement, selector: string, limit: number): Element[] {
|
||||
const selected: Element[] = [];
|
||||
|
||||
for (const element of Array.from(root.querySelectorAll(selector))) {
|
||||
if (selected.some((selectedElement) => selectedElement.contains(element))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(element);
|
||||
if (selected.length >= limit) break;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export default function ListPresenceMotion({
|
||||
children,
|
||||
className,
|
||||
motionKey,
|
||||
targetSelector = DEFAULT_TARGET_SELECTOR,
|
||||
maxTargets = 24,
|
||||
...props
|
||||
}: ListPresenceMotionProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return undefined;
|
||||
|
||||
const mm = gsap.matchMedia();
|
||||
mm.add(
|
||||
{
|
||||
reduceMotion: '(prefers-reduced-motion: reduce)',
|
||||
isDesktop: '(min-width: 900px)',
|
||||
},
|
||||
(context) => {
|
||||
const conditions = context.conditions || {};
|
||||
const targets = selectPresenceTargets(
|
||||
root,
|
||||
targetSelector,
|
||||
Math.max(1, maxTargets),
|
||||
);
|
||||
const animatedNodes = targets.length ? targets : [root];
|
||||
|
||||
if (conditions.reduceMotion) {
|
||||
gsap.set(animatedNodes, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeline = gsap.timeline({
|
||||
defaults: {
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
},
|
||||
});
|
||||
const distance = conditions.isDesktop ? 10 : 7;
|
||||
|
||||
timeline.fromTo(
|
||||
animatedNodes,
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: distance,
|
||||
},
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: conditions.isDesktop ? 0.32 : 0.26,
|
||||
stagger: targets.length > 1
|
||||
? { each: conditions.isDesktop ? 0.018 : 0.014, from: 'start' }
|
||||
: 0,
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
timeline.kill();
|
||||
gsap.killTweensOf(animatedNodes);
|
||||
gsap.set(animatedNodes, { clearProps: 'transform,opacity,visibility' });
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return () => mm.revert();
|
||||
}, [maxTargets, motionKey, targetSelector]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={['list-presence-motion', className].filter(Boolean).join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,10 @@ type MobileBatchBarProps = {
|
||||
|
||||
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
|
||||
return (
|
||||
<div className="mobile-actions-bar mobile-batch-bar">
|
||||
<span className="mobile-actions-info">{info}</span>
|
||||
<div
|
||||
className="mobile-actions-bar mobile-batch-bar responsive-batch-action-bar"
|
||||
>
|
||||
<span className="mobile-actions-info" role="status" aria-live="polite">{info}</span>
|
||||
<div className="mobile-actions-row">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useId, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
type MobileDrawerProps = {
|
||||
open: boolean;
|
||||
@@ -8,6 +10,7 @@ type MobileDrawerProps = {
|
||||
title?: React.ReactNode;
|
||||
closeLabel?: string;
|
||||
side?: 'left' | 'right';
|
||||
panelId?: string;
|
||||
};
|
||||
|
||||
function MobileDrawer({
|
||||
@@ -17,28 +20,15 @@ function MobileDrawer({
|
||||
title,
|
||||
closeLabel = '关闭导航',
|
||||
side = 'left',
|
||||
panelId,
|
||||
}: MobileDrawerProps) {
|
||||
const [shouldRender, setShouldRender] = useState(open);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const presence = useAnimatedVisibility(open, 280);
|
||||
const titleId = useId();
|
||||
const labelledBy = title ? titleId : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
setIsClosing(false);
|
||||
} else if (shouldRender) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsClosing(false);
|
||||
}, 280);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -51,12 +41,53 @@ function MobileDrawer({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
const focusPanel = () => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && panel.contains(currentActiveElement)) return;
|
||||
panel.focus({ preventScroll: true });
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const animationFrame = window.requestAnimationFrame(focusPanel);
|
||||
return () => {
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}
|
||||
focusPanel();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
return undefined;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, panelRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
@@ -64,23 +95,34 @@ function MobileDrawer({
|
||||
};
|
||||
}, [handleClose, open]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const drawer = (
|
||||
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
|
||||
<div className={`mobile-drawer-root ${presence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<div
|
||||
className="mobile-drawer-backdrop"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
|
||||
<div
|
||||
id={panelId}
|
||||
ref={panelRef}
|
||||
className={`mobile-drawer-panel mobile-drawer-panel-${side}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelledBy}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="mobile-drawer-toolbar">
|
||||
{title ? (
|
||||
<div className="mobile-drawer-title" id={titleId}>
|
||||
{title}
|
||||
</div>
|
||||
) : <div />}
|
||||
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
|
||||
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel} tabIndex={interactive ? 0 : -1}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ type MobileFilterSheetProps = {
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
panelId?: string;
|
||||
};
|
||||
|
||||
export default function MobileFilterSheet({
|
||||
@@ -13,9 +14,10 @@ export default function MobileFilterSheet({
|
||||
onClose,
|
||||
title = '筛选',
|
||||
children,
|
||||
panelId,
|
||||
}: MobileFilterSheetProps) {
|
||||
return (
|
||||
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选">
|
||||
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选" panelId={panelId}>
|
||||
<div className="mobile-filter-panel">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import ModelAnalysisPanel from './ModelAnalysisPanel.js';
|
||||
|
||||
@@ -17,6 +19,27 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function panelData() {
|
||||
return {
|
||||
totals: {
|
||||
spend: 2,
|
||||
calls: 10,
|
||||
tokens: 3000,
|
||||
},
|
||||
spendDistribution: [{ model: 'gpt-5', spend: 2, calls: 10 }],
|
||||
spendTrend: [{ day: '2026-06-07', spend: 2 }],
|
||||
callsDistribution: [{ model: 'gpt-5', calls: 10, share: 1 }],
|
||||
callRanking: [{ model: 'gpt-5', calls: 10, successRate: 99, avgLatencyMs: 1200, spend: 2, tokens: 3000 }],
|
||||
};
|
||||
}
|
||||
|
||||
function keyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ModelAnalysisPanel token summaries', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -66,6 +89,151 @@ describe('ModelAnalysisPanel token summaries', () => {
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('uses shrink-safe summary and legend layout classes', () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
act(() => {
|
||||
root = create(
|
||||
<ModelAnalysisPanel data={panelData()} />,
|
||||
);
|
||||
});
|
||||
|
||||
const summaryGrid = root.root.find((node) => node.props.className === 'model-analysis-summary-grid');
|
||||
expect(summaryGrid).toBeTruthy();
|
||||
|
||||
const legend = root.root.find((node) => node.props.className === 'model-analysis-legend');
|
||||
expect(legend).toBeTruthy();
|
||||
expect(root.root.findAll((node) => node.props.className === 'model-analysis-legend-item').length).toBeGreaterThan(0);
|
||||
expect(root.root.findAll((node) => node.props.className === 'model-analysis-legend-name').length).toBeGreaterThan(0);
|
||||
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('keeps model analysis CSS free of fixed three-column summary grids', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/ModelAnalysisPanel.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('className="model-analysis-summary-grid"');
|
||||
expect(source).toContain('className="model-analysis-legend"');
|
||||
expect(source).toContain('className="model-analysis-legend-name"');
|
||||
expect(source).not.toContain("gridTemplateColumns: 'repeat(3, 1fr)'");
|
||||
expect(source).not.toContain("maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'");
|
||||
|
||||
expect(css).toContain('.model-analysis-summary-grid {');
|
||||
expect(css).toContain('grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));');
|
||||
expect(css).toContain('.model-analysis-legend-item {');
|
||||
expect(css).toContain('min-width: 0;');
|
||||
expect(css).toContain('.model-analysis-legend-name {');
|
||||
expect(css).toContain('max-width: min(150px, 52vw);');
|
||||
});
|
||||
|
||||
it('exposes chart mode switches as tabs with a labelled tab panel', () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
act(() => {
|
||||
root = create(
|
||||
<ModelAnalysisPanel data={panelData()} />,
|
||||
);
|
||||
});
|
||||
|
||||
const tablist = root.root.find((node) => node.props.role === 'tablist');
|
||||
expect(tablist.props['aria-label']).toBe('模型分析视图');
|
||||
|
||||
const tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs).toHaveLength(4);
|
||||
expect(tabs[0].props['aria-selected']).toBe(true);
|
||||
expect(tabs[0].props['aria-controls']).toBe('model-analysis-panel-spend');
|
||||
expect(tabs.map((tab) => tab.props.tabIndex)).toEqual([0, -1, -1, -1]);
|
||||
|
||||
let panel = root.root.find((node) => node.props.role === 'tabpanel');
|
||||
expect(panel.props.id).toBe('model-analysis-panel-spend');
|
||||
expect(panel.props['aria-labelledby']).toBe('model-analysis-tab-spend');
|
||||
expect(panel.props.className).toBe('model-analysis-tab-panel');
|
||||
|
||||
act(() => {
|
||||
tabs[1].props.onClick();
|
||||
});
|
||||
|
||||
const updatedTabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(updatedTabs[0].props['aria-selected']).toBe(false);
|
||||
expect(updatedTabs[1].props['aria-selected']).toBe(true);
|
||||
expect(updatedTabs.map((tab) => tab.props.tabIndex)).toEqual([-1, 0, -1, -1]);
|
||||
|
||||
panel = root.root.find((node) => node.props.role === 'tabpanel');
|
||||
expect(panel.props.id).toBe('model-analysis-panel-trend');
|
||||
expect(panel.props['aria-labelledby']).toBe('model-analysis-tab-trend');
|
||||
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('supports arrow Home and End keyboard navigation across chart tabs', () => {
|
||||
const focusCalls = {
|
||||
spend: vi.fn(),
|
||||
trend: vi.fn(),
|
||||
calls: vi.fn(),
|
||||
rank: vi.fn(),
|
||||
};
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
act(() => {
|
||||
root = create(
|
||||
<ModelAnalysisPanel data={panelData()} />,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (element.type !== 'button') return null;
|
||||
const id = String(element.props.id || '');
|
||||
if (!id.startsWith('model-analysis-tab-')) return null;
|
||||
const key = id.replace('model-analysis-tab-', '') as keyof typeof focusCalls;
|
||||
return {
|
||||
focus: focusCalls[key],
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const tablist = root.root.find((node) => node.props.role === 'tablist');
|
||||
let tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
|
||||
const arrowRightEvent = keyEvent('ArrowRight');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(arrowRightEvent);
|
||||
});
|
||||
|
||||
expect(arrowRightEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(focusCalls.trend).toHaveBeenCalledWith({ preventScroll: true });
|
||||
tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([false, true, false, false]);
|
||||
|
||||
const endEvent = keyEvent('End');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(endEvent);
|
||||
});
|
||||
|
||||
expect(endEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(focusCalls.rank).toHaveBeenCalledWith({ preventScroll: true });
|
||||
tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([false, false, false, true]);
|
||||
|
||||
const homeEvent = keyEvent('Home');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(homeEvent);
|
||||
});
|
||||
|
||||
expect(homeEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(focusCalls.spend).toHaveBeenCalledWith({ preventScroll: true });
|
||||
tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([true, false, false, false]);
|
||||
|
||||
const ignoredEvent = keyEvent('PageDown');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(ignoredEvent);
|
||||
});
|
||||
|
||||
expect(ignoredEvent.preventDefault).not.toHaveBeenCalled();
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
|
||||
globalThis.document = {
|
||||
documentElement: {},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { InlineBrandIcon } from './BrandIcon.js';
|
||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||
@@ -64,7 +64,15 @@ function EmptyBlock() {
|
||||
|
||||
export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('spend');
|
||||
const tabButtonRefs = useRef<Record<TabKey, HTMLButtonElement | null>>({
|
||||
spend: null,
|
||||
trend: null,
|
||||
calls: null,
|
||||
rank: null,
|
||||
});
|
||||
const labelColor = useThemeLabelColor();
|
||||
const activeTabPanelId = `model-analysis-panel-${activeTab}`;
|
||||
const activeTabId = `model-analysis-tab-${activeTab}`;
|
||||
|
||||
const totals = {
|
||||
spend: toSafeNumber(data?.totals?.spend),
|
||||
@@ -119,10 +127,38 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
|
||||
if (!hasData) return <EmptyBlock />;
|
||||
|
||||
const activateTab = (tabKey: TabKey, options?: { focus?: boolean }) => {
|
||||
setActiveTab(tabKey);
|
||||
if (options?.focus) {
|
||||
tabButtonRefs.current[tabKey]?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const currentIndex = tabs.findIndex((tab) => tab.key === activeTab);
|
||||
if (currentIndex < 0) return;
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % tabs.length;
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0;
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = tabs.length - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
activateTab(tabs[nextIndex].key, { focus: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}>
|
||||
<div className="model-analysis-summary-grid">
|
||||
<div className="stat-summary-card stat-summary-purple">
|
||||
<div className="stat-summary-card-label">总消耗</div>
|
||||
<div className="stat-summary-card-value">{formatCurrency(totals.spend)}</div>
|
||||
@@ -139,12 +175,21 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
|
||||
{/* Pill Tabs */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="pill-tabs">
|
||||
<div className="pill-tabs" role="tablist" aria-label="模型分析视图" onKeyDown={handleTabKeyDown}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
ref={(node) => {
|
||||
tabButtonRefs.current[tab.key] = node;
|
||||
}}
|
||||
id={`model-analysis-tab-${tab.key}`}
|
||||
type="button"
|
||||
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
aria-controls={`model-analysis-panel-${tab.key}`}
|
||||
tabIndex={activeTab === tab.key ? 0 : -1}
|
||||
onClick={() => activateTab(tab.key)}
|
||||
>
|
||||
<span className="pill-tab-code">{tab.code}</span>
|
||||
{tab.label}
|
||||
@@ -154,144 +199,152 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Chart Content */}
|
||||
{activeTab === 'spend' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={spendBarSpec} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
|
||||
{spendDistribution.map(d => (
|
||||
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'trend' && (
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={trendSpec} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'calls' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={callsPieSpec} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
|
||||
{callsDistribution.map((d, idx) => {
|
||||
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
return (
|
||||
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
|
||||
<div
|
||||
key={activeTab}
|
||||
id={activeTabPanelId}
|
||||
className="model-analysis-tab-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={activeTabId}
|
||||
>
|
||||
{activeTab === 'spend' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={spendBarSpec} />
|
||||
</div>
|
||||
<div className="model-analysis-legend">
|
||||
{spendDistribution.map(d => (
|
||||
<span key={d.model} className="model-analysis-legend-item">
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
|
||||
<span className="model-analysis-legend-name">{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{activeTab === 'rank' && (
|
||||
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36, textAlign: 'center' }}>#</th>
|
||||
<th>模型</th>
|
||||
<th style={{ textAlign: 'center' }}>调用</th>
|
||||
<th style={{ textAlign: 'center' }}>成功率</th>
|
||||
<th style={{ textAlign: 'center' }}>平均延迟</th>
|
||||
<th style={{ textAlign: 'right' }}>消耗</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{callRanking.map((item, index) => {
|
||||
const latMs = item.avgLatencyMs;
|
||||
const latSec = latMs / 1000;
|
||||
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
|
||||
let latColor: string;
|
||||
let latBg: string;
|
||||
if (latSec <= 15) {
|
||||
// green gradient: 0s=#22c55e → 15s=blend towards yellow
|
||||
const t = Math.min(latSec / 15, 1);
|
||||
const r = Math.round(34 + t * (245 - 34));
|
||||
const g = Math.round(197 + t * (158 - 197));
|
||||
const b = Math.round(94 + t * (11 - 94));
|
||||
latColor = `rgb(${r},${g},${b})`;
|
||||
latBg = `rgba(${r},${g},${b},0.08)`;
|
||||
} else if (latSec <= 60) {
|
||||
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
|
||||
const t = Math.min((latSec - 15) / 45, 1);
|
||||
const r = Math.round(245 + t * (239 - 245));
|
||||
const g = Math.round(158 + t * (68 - 158));
|
||||
const b = Math.round(11 + t * (68 - 11));
|
||||
latColor = `rgb(${r},${g},${b})`;
|
||||
latBg = `rgba(${r},${g},${b},0.08)`;
|
||||
} else {
|
||||
latColor = '#ef4444';
|
||||
latBg = 'rgba(239,68,68,0.08)';
|
||||
}
|
||||
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
|
||||
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
|
||||
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
|
||||
{activeTab === 'trend' && (
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={trendSpec} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'calls' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={callsPieSpec} />
|
||||
</div>
|
||||
<div className="model-analysis-legend">
|
||||
{callsDistribution.map((d, idx) => {
|
||||
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
return (
|
||||
<tr key={item.model}>
|
||||
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
|
||||
background: index < 3
|
||||
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
|
||||
: 'var(--color-bg)',
|
||||
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<InlineBrandIcon model={item.model} size={14} />
|
||||
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
|
||||
{Math.round(item.calls).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
|
||||
background: rateBg, color: rateColor,
|
||||
}}>
|
||||
{formatPercent(item.successRate)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
|
||||
color: latColor, background: latBg,
|
||||
padding: '2px 8px', borderRadius: 4,
|
||||
}}>
|
||||
{latText}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
|
||||
{formatCurrency(item.spend)}
|
||||
</td>
|
||||
</tr>
|
||||
<span key={d.model} className="model-analysis-legend-item">
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span className="model-analysis-legend-name">{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'rank' && (
|
||||
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36, textAlign: 'center' }}>#</th>
|
||||
<th>模型</th>
|
||||
<th style={{ textAlign: 'center' }}>调用</th>
|
||||
<th style={{ textAlign: 'center' }}>成功率</th>
|
||||
<th style={{ textAlign: 'center' }}>平均延迟</th>
|
||||
<th style={{ textAlign: 'right' }}>消耗</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{callRanking.map((item, index) => {
|
||||
const latMs = item.avgLatencyMs;
|
||||
const latSec = latMs / 1000;
|
||||
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
|
||||
let latColor: string;
|
||||
let latBg: string;
|
||||
if (latSec <= 15) {
|
||||
// green gradient: 0s=#22c55e → 15s=blend towards yellow
|
||||
const t = Math.min(latSec / 15, 1);
|
||||
const r = Math.round(34 + t * (245 - 34));
|
||||
const g = Math.round(197 + t * (158 - 197));
|
||||
const b = Math.round(94 + t * (11 - 94));
|
||||
latColor = `rgb(${r},${g},${b})`;
|
||||
latBg = `rgba(${r},${g},${b},0.08)`;
|
||||
} else if (latSec <= 60) {
|
||||
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
|
||||
const t = Math.min((latSec - 15) / 45, 1);
|
||||
const r = Math.round(245 + t * (239 - 245));
|
||||
const g = Math.round(158 + t * (68 - 158));
|
||||
const b = Math.round(11 + t * (68 - 11));
|
||||
latColor = `rgb(${r},${g},${b})`;
|
||||
latBg = `rgba(${r},${g},${b},0.08)`;
|
||||
} else {
|
||||
latColor = '#ef4444';
|
||||
latBg = 'rgba(239,68,68,0.08)';
|
||||
}
|
||||
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
|
||||
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
|
||||
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
|
||||
|
||||
return (
|
||||
<tr key={item.model}>
|
||||
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
|
||||
background: index < 3
|
||||
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
|
||||
: 'var(--color-bg)',
|
||||
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<InlineBrandIcon model={item.model} size={14} />
|
||||
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
|
||||
{Math.round(item.calls).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
|
||||
background: rateBg, color: rateColor,
|
||||
}}>
|
||||
{formatPercent(item.successRate)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
|
||||
color: latColor, background: latBg,
|
||||
padding: '2px 8px', borderRadius: 4,
|
||||
}}>
|
||||
{latText}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
|
||||
{formatCurrency(item.spend)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import ModernSelect from './ModernSelect.js';
|
||||
|
||||
@@ -17,6 +17,33 @@ function collectInstanceText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findTrigger(root: ReturnType<typeof create>) {
|
||||
return root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
}
|
||||
|
||||
function findOptionButtons(root: ReturnType<typeof create>) {
|
||||
return root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
}
|
||||
|
||||
function findListbox(root: ReturnType<typeof create>) {
|
||||
return root.root.find((node) => node.props.role === 'listbox');
|
||||
}
|
||||
|
||||
function keyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ModernSelect', () => {
|
||||
it('renders icon nodes for the selected option', () => {
|
||||
const root = create(
|
||||
@@ -53,11 +80,7 @@ describe('ModernSelect', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
@@ -72,22 +95,264 @@ describe('ModernSelect', () => {
|
||||
searchInput.props.onChange({ target: { value: 'codex' } });
|
||||
});
|
||||
|
||||
let optionButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
let optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Beta Relay (codex)https://beta.example.com']);
|
||||
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'third.example.com' } });
|
||||
});
|
||||
|
||||
optionButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']);
|
||||
});
|
||||
|
||||
it('keeps closed searchable options out of the tab order', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
searchPlaceholder="筛选站点"
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'alpha', label: 'Alpha Mirror' },
|
||||
{ value: 'beta', label: 'Beta Relay', disabled: true },
|
||||
{ value: 'gamma', label: 'Gamma API' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(findListbox(root).props['aria-hidden']).toBe(true);
|
||||
expect(root.root.findByProps({ placeholder: '筛选站点' }).props.tabIndex).toBe(-1);
|
||||
expect(findOptionButtons(root).map((node) => node.props.tabIndex)).toEqual([-1, -1, -1]);
|
||||
|
||||
await act(async () => {
|
||||
findTrigger(root).props.onClick();
|
||||
});
|
||||
|
||||
expect(findListbox(root).props['aria-hidden']).toBe(false);
|
||||
expect(root.root.findByProps({ placeholder: '筛选站点' }).props.tabIndex).toBe(0);
|
||||
expect(findOptionButtons(root).map((node) => node.props.tabIndex)).toEqual([0, -1, 0]);
|
||||
});
|
||||
|
||||
it('falls back to stable text when options are missing labels', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
value="codex"
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'codex', label: undefined, description: undefined },
|
||||
{ value: 'claude', label: '', description: null },
|
||||
{ value: '', label: null, description: '' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(collectText(root.root)).toContain('codex');
|
||||
expect(collectText(root.root)).not.toContain('undefined');
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'codex',
|
||||
'claude',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats literal undefined/null option text as missing data', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
value="codex"
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'codex', label: 'undefined', description: 'null' },
|
||||
{ value: 'undefined', label: 'undefined', description: 'undefined' },
|
||||
{ value: 'null', label: 'null', description: 'null' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(collectText(root.root)).toContain('codex');
|
||||
expect(collectText(root.root)).not.toContain('undefined');
|
||||
expect(collectText(root.root)).not.toContain('null');
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'codex',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops fully empty options after normalizing polluted text', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'undefined', label: 'undefined', description: 'undefined' },
|
||||
{ value: 'null', label: 'null', description: 'null' },
|
||||
{ value: '', label: '全部 Provider', description: '' },
|
||||
{ value: 'codex', label: 'Codex', description: 'codex' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'全部 Provider',
|
||||
'Codexcodex',
|
||||
]);
|
||||
expect(collectText(root.root)).not.toContain('undefined');
|
||||
expect(collectText(root.root)).not.toContain('null');
|
||||
});
|
||||
|
||||
it('supports listbox semantics and keyboard selection while skipping disabled options', async () => {
|
||||
const onChange = vi.fn();
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
value=""
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'disabled', label: 'Disabled Provider', disabled: true },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
const openEvent = keyEvent('ArrowDown');
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(openEvent);
|
||||
});
|
||||
|
||||
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(trigger.props.role).toBe('combobox');
|
||||
expect(trigger.props['aria-haspopup']).toBe('listbox');
|
||||
expect(trigger.props['aria-expanded']).toBe(true);
|
||||
expect(trigger.props['aria-controls']).toBeTruthy();
|
||||
expect(trigger.props['aria-activedescendant']).toContain('option-0');
|
||||
|
||||
let optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => node.props.role)).toEqual(['option', 'option', 'option']);
|
||||
expect(optionButtons[0]!.props['aria-selected']).toBe(false);
|
||||
expect(String(optionButtons[0]!.props.className)).toContain('is-highlighted');
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(keyEvent('ArrowDown'));
|
||||
});
|
||||
optionButtons = findOptionButtons(root);
|
||||
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
|
||||
|
||||
const enterEvent = keyEvent('Enter');
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(enterEvent);
|
||||
});
|
||||
|
||||
expect(enterEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith('claude');
|
||||
expect(trigger.props['aria-expanded']).toBe(false);
|
||||
});
|
||||
|
||||
it('opens from the last enabled option when pressing ArrowUp from a closed trigger', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'disabled', label: 'Disabled Provider', disabled: true },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(keyEvent('ArrowUp'));
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
expect(String(optionButtons[0]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
|
||||
expect(trigger.props['aria-activedescendant']).toContain('option-2');
|
||||
});
|
||||
|
||||
it('supports keyboard selection while focus is inside the search input', async () => {
|
||||
const onChange = vi.fn();
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
searchPlaceholder="筛选 Provider"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'codex-disabled', label: 'Codex Disabled', disabled: true },
|
||||
{ value: 'codex-plus', label: 'Codex Plus' },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findTrigger(root).props.onClick();
|
||||
});
|
||||
|
||||
const searchInput = root.root.findByProps({ placeholder: '筛选 Provider' });
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'codex' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
searchInput.props.onKeyDown(keyEvent('ArrowDown'));
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'Codex',
|
||||
'Codex Disabled',
|
||||
'Codex Plus',
|
||||
]);
|
||||
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
|
||||
|
||||
const spaceEvent = keyEvent(' ');
|
||||
await act(async () => {
|
||||
searchInput.props.onKeyDown(spaceEvent);
|
||||
});
|
||||
expect(spaceEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
const enterEvent = keyEvent('Enter');
|
||||
await act(async () => {
|
||||
searchInput.props.onKeyDown(enterEvent);
|
||||
});
|
||||
|
||||
expect(enterEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith('codex-plus');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type ModernSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
disabled?: boolean;
|
||||
iconNode?: ReactNode;
|
||||
iconUrl?: string;
|
||||
iconText?: string;
|
||||
iconUrl?: string | null;
|
||||
iconText?: string | null;
|
||||
};
|
||||
|
||||
type ModernSelectProps = {
|
||||
@@ -25,6 +33,14 @@ type ModernSelectProps = {
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
|
||||
function resolveSelectText(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const text = value.trim();
|
||||
const lowerText = text.toLowerCase();
|
||||
if (lowerText === 'undefined' || lowerText === 'null') return '';
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function ModernSelect({
|
||||
value,
|
||||
onChange,
|
||||
@@ -41,18 +57,54 @@ export default function ModernSelect({
|
||||
}: ModernSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const keyboardHighlightPendingRef = useRef(false);
|
||||
const panelId = useId();
|
||||
|
||||
const normalizedOptions = useMemo(
|
||||
() => options.flatMap((item, index) => {
|
||||
const optionValue = resolveSelectText(item.value);
|
||||
const rawLabel = resolveSelectText(item.label);
|
||||
const optionDescription = resolveSelectText(item.description);
|
||||
const optionIconUrl = resolveSelectText(item.iconUrl);
|
||||
const optionIconText = resolveSelectText(item.iconText);
|
||||
const hasVisibleContent = Boolean(
|
||||
optionValue
|
||||
|| rawLabel
|
||||
|| optionDescription
|
||||
|| optionIconUrl
|
||||
|| optionIconText
|
||||
|| item.iconNode,
|
||||
);
|
||||
if (!hasVisibleContent) return [];
|
||||
|
||||
const optionLabel = rawLabel
|
||||
|| optionValue
|
||||
|| `未命名选项 ${index + 1}`;
|
||||
|
||||
return [{
|
||||
...item,
|
||||
value: optionValue,
|
||||
label: optionLabel,
|
||||
description: optionDescription || undefined,
|
||||
iconUrl: optionIconUrl || undefined,
|
||||
iconText: optionIconText || undefined,
|
||||
}];
|
||||
}),
|
||||
[options],
|
||||
);
|
||||
|
||||
const selected = useMemo(
|
||||
() => options.find((item) => item.value === value),
|
||||
[options, value],
|
||||
() => normalizedOptions.find((item) => item.value === value),
|
||||
[normalizedOptions, value],
|
||||
);
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!searchable || !query) return options;
|
||||
if (!searchable || !query) return normalizedOptions;
|
||||
|
||||
return options.filter((item) => {
|
||||
return normalizedOptions.filter((item) => {
|
||||
const haystack = [
|
||||
item.label,
|
||||
item.description,
|
||||
@@ -63,7 +115,31 @@ export default function ModernSelect({
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [options, searchQuery, searchable]);
|
||||
}, [normalizedOptions, searchQuery, searchable]);
|
||||
|
||||
const findNextEnabledIndex = (
|
||||
startIndex: number,
|
||||
direction: 1 | -1,
|
||||
): number => {
|
||||
if (visibleOptions.length === 0) return -1;
|
||||
for (let step = 0; step < visibleOptions.length; step += 1) {
|
||||
const index = (startIndex + step * direction + visibleOptions.length) % visibleOptions.length;
|
||||
if (!visibleOptions[index]?.disabled) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (keyboardHighlightPendingRef.current) {
|
||||
keyboardHighlightPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndex = visibleOptions.findIndex((item) => item.value === value && !item.disabled);
|
||||
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : findNextEnabledIndex(0, 1));
|
||||
}, [open, value, visibleOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -76,7 +152,7 @@ export default function ModernSelect({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
@@ -111,6 +187,77 @@ export default function ModernSelect({
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectOption = (index: number) => {
|
||||
const item = visibleOptions[index];
|
||||
if (!item || item.disabled) return;
|
||||
onChange(item.value);
|
||||
setHighlightedIndex(-1);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const moveHighlight = (direction: 1 | -1) => {
|
||||
keyboardHighlightPendingRef.current = true;
|
||||
setOpen(true);
|
||||
setHighlightedIndex((current) => {
|
||||
const startIndex = current >= 0
|
||||
? current + direction
|
||||
: direction === 1
|
||||
? 0
|
||||
: visibleOptions.length - 1;
|
||||
return findNextEnabledIndex(startIndex, direction);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTriggerKeyDown = (
|
||||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveHighlight(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
selectOption(highlightedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(-1);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveHighlight(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
selectOption(highlightedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(-1);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -121,9 +268,19 @@ export default function ModernSelect({
|
||||
type="button"
|
||||
className="modern-select-trigger"
|
||||
onClick={() => {
|
||||
if (!disabled) setOpen((prev) => !prev);
|
||||
if (!disabled) {
|
||||
setOpen((prev) => {
|
||||
if (prev) setHighlightedIndex(-1);
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-activedescendant={open && highlightedIndex >= 0 ? `${panelId}-option-${highlightedIndex}` : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
|
||||
@@ -148,15 +305,23 @@ export default function ModernSelect({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
|
||||
<div
|
||||
id={panelId}
|
||||
className="modern-select-panel"
|
||||
style={{ maxHeight: menuMaxHeight }}
|
||||
role="listbox"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{searchable && (
|
||||
<div className="modern-select-search-shell">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="modern-select-search-input"
|
||||
tabIndex={open ? 0 : -1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -164,19 +329,24 @@ export default function ModernSelect({
|
||||
{visibleOptions.length === 0 ? (
|
||||
<div className="modern-select-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
visibleOptions.map((item) => {
|
||||
visibleOptions.map((item, index) => {
|
||||
const active = item.value === value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
id={`${panelId}-option-${index}`}
|
||||
key={`${item.value || 'option'}-${index}`}
|
||||
type="button"
|
||||
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
|
||||
className={`modern-select-option ${active ? 'is-active' : ''} ${highlightedIndex === index ? 'is-highlighted' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
selectOption(index);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setHighlightedIndex(index);
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
tabIndex={open && !item.disabled ? 0 : -1}
|
||||
>
|
||||
<div className="modern-select-option-main">
|
||||
{renderOptionIcon(item)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { api, userApi } from '../api.js';
|
||||
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
import DiagnosticActionGroup, { type DiagnosticAction } from './DiagnosticActionGroup.js';
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: 'var(--color-info)',
|
||||
@@ -19,48 +20,220 @@ const typeLabels: Record<string, string> = {
|
||||
proxy: '代理',
|
||||
status: '状态',
|
||||
site_notice: '站点公告',
|
||||
user_announcement: '用户公告',
|
||||
};
|
||||
|
||||
type NotificationAudience = 'admin' | 'user';
|
||||
|
||||
const adminFilterTypes = ['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'];
|
||||
const userFilterTypes = ['', 'user_announcement'];
|
||||
|
||||
function normalizeUserAnnouncement(row: any) {
|
||||
return {
|
||||
id: row?.id,
|
||||
type: 'user_announcement',
|
||||
level: row?.level || 'info',
|
||||
title: row?.title || '用户公告',
|
||||
message: row?.content || '',
|
||||
read: !!row?.read,
|
||||
relatedType: 'user_announcement',
|
||||
relatedId: row?.id,
|
||||
createdAt: row?.publishedAt || row?.firstSeenAt || row?.lastSeenAt || row?.createdAt || row?.created_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserNotificationNavigationPath(event: any): string {
|
||||
if (String(event?.relatedType || '').toLowerCase() === 'user_announcement') {
|
||||
return '/user/announcements';
|
||||
}
|
||||
return '/user/announcements';
|
||||
}
|
||||
|
||||
function buildNotificationDiagnosticActions(event: any, targetPath: string): DiagnosticAction[] {
|
||||
const relatedType = String(event?.relatedType || '').toLowerCase();
|
||||
const eventType = String(event?.type || '').toLowerCase();
|
||||
const targetIsGeneric = !targetPath || targetPath === '/events';
|
||||
const tone = event?.level === 'error' || event?.level === 'warning' ? 'danger' : 'primary';
|
||||
|
||||
if (relatedType === 'proxy_log' || relatedType === 'proxy') {
|
||||
return [{
|
||||
label: '查看日志',
|
||||
href: targetPath,
|
||||
hint: '打开这条代理请求',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'proxy_debug_trace' || relatedType === 'debug_trace') {
|
||||
return [{
|
||||
label: '查看追踪',
|
||||
href: targetPath,
|
||||
hint: '定位原始下游请求',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'route') {
|
||||
return [{
|
||||
label: '诊断路由',
|
||||
href: targetPath,
|
||||
hint: '打开相关路由配置',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'site') {
|
||||
return [{
|
||||
label: '站点管理',
|
||||
href: targetPath,
|
||||
hint: '检查站点健康和代理',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'account') {
|
||||
return [{
|
||||
label: eventType === 'token' ? '重新绑定' : '账号详情',
|
||||
href: targetPath,
|
||||
hint: eventType === 'token' ? '更新连接凭证' : '检查账号状态',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'site_announcement') {
|
||||
return [{
|
||||
label: '查看公告',
|
||||
href: targetPath,
|
||||
hint: '打开站点公告详情',
|
||||
tone: 'primary',
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'user_announcement') {
|
||||
return [{
|
||||
label: '查看公告',
|
||||
href: targetPath,
|
||||
hint: '打开用户公告',
|
||||
tone: 'primary',
|
||||
}];
|
||||
}
|
||||
if (eventType === 'proxy' && !targetIsGeneric) {
|
||||
return [{
|
||||
label: '打开日志',
|
||||
href: targetPath,
|
||||
hint: '查看代理请求',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
label: '查看详情',
|
||||
href: targetIsGeneric ? '/events' : targetPath,
|
||||
hint: '打开运行事件中心',
|
||||
tone: 'muted',
|
||||
}];
|
||||
}
|
||||
|
||||
export default function NotificationPanel({
|
||||
open,
|
||||
onClose,
|
||||
anchorRef,
|
||||
onUnreadCountChange,
|
||||
panelId,
|
||||
audience = 'admin',
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
onUnreadCountChange?: (count: number) => void;
|
||||
panelId?: string;
|
||||
audience?: NotificationAudience;
|
||||
}) {
|
||||
const { t: tr } = useI18n();
|
||||
const presence = useAnimatedVisibility(open, 160);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [activeEventIndex, setActiveEventIndex] = useState(-1);
|
||||
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const eventRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const openRef = useRef(open);
|
||||
const loadSeqRef = useRef(0);
|
||||
const focusActiveEventRef = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const focusAnchor = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor || anchor.isConnected === false) return;
|
||||
anchor.focus({ preventScroll: true });
|
||||
}, [anchorRef]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const loadSeq = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = filter ? `type=${filter}` : '';
|
||||
const data = await api.getEvents(params);
|
||||
let data: any[];
|
||||
if (audience === 'user') {
|
||||
const announcements = await userApi.getAnnouncements('limit=50');
|
||||
data = Array.isArray(announcements)
|
||||
? announcements.map(normalizeUserAnnouncement)
|
||||
: [];
|
||||
} else {
|
||||
data = await api.getEvents(params);
|
||||
}
|
||||
if (!mountedRef.current || !openRef.current || loadSeqRef.current !== loadSeq) return;
|
||||
setEvents(data);
|
||||
|
||||
// Auto mark all as read on open
|
||||
const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read);
|
||||
if (hasUnread) {
|
||||
api.markAllEventsRead().catch(() => {});
|
||||
if (audience === 'user') {
|
||||
userApi.markAllAnnouncementsRead().catch(() => {});
|
||||
} else {
|
||||
api.markAllEventsRead().catch(() => {});
|
||||
}
|
||||
onUnreadCountChange?.(0);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false); }
|
||||
}, [filter, onUnreadCountChange]);
|
||||
finally {
|
||||
if (mountedRef.current && openRef.current && loadSeqRef.current === loadSeq) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [audience, filter, onUnreadCountChange]);
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
loadSeqRef.current += 1;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
openRef.current = open;
|
||||
if (!open) {
|
||||
loadSeqRef.current += 1;
|
||||
focusActiveEventRef.current = false;
|
||||
setLoading(false);
|
||||
setActiveEventIndex(-1);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
panelRef.current?.focus({ preventScroll: true });
|
||||
load();
|
||||
}, [open, load]);
|
||||
|
||||
useEffect(() => {
|
||||
focusActiveEventRef.current = false;
|
||||
setActiveEventIndex(-1);
|
||||
}, [audience, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeEventIndex < 0) return;
|
||||
const activeEvent = eventRefs.current[activeEventIndex];
|
||||
activeEvent?.scrollIntoView({ block: 'nearest' });
|
||||
if (focusActiveEventRef.current) {
|
||||
activeEvent?.focus({ preventScroll: true });
|
||||
focusActiveEventRef.current = false;
|
||||
}
|
||||
}, [activeEventIndex, activeFocusRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
@@ -74,54 +247,109 @@ export default function NotificationPanel({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open, onClose, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
onClose();
|
||||
focusAnchor();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [focusAnchor, open, onClose]);
|
||||
|
||||
const clearAll = async () => {
|
||||
await api.clearEvents();
|
||||
if (audience === 'user') {
|
||||
await userApi.markAllAnnouncementsRead();
|
||||
} else {
|
||||
await api.clearEvents();
|
||||
}
|
||||
if (!mountedRef.current || !openRef.current) return;
|
||||
setEvents([]);
|
||||
setActiveEventIndex(-1);
|
||||
onUnreadCountChange?.(0);
|
||||
};
|
||||
|
||||
const moveActiveEvent = (direction: 1 | -1) => {
|
||||
if (!events.length) return;
|
||||
focusActiveEventRef.current = true;
|
||||
setActiveEventIndex((current) => {
|
||||
if (current < 0) return direction > 0 ? 0 : events.length - 1;
|
||||
return (current + direction + events.length) % events.length;
|
||||
});
|
||||
setActiveFocusRequest((request) => request + 1);
|
||||
};
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={panelId}
|
||||
ref={panelRef}
|
||||
className={`user-dropdown ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}
|
||||
className={`user-dropdown notification-panel ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-label={tr('通知')}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
style={{ right: 0, top: '100%', padding: 0, marginTop: 4 }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveEvent(event.key === 'ArrowDown' ? 1 : -1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span>
|
||||
<button onClick={clearAll} className="btn btn-link">
|
||||
<button type="button" onClick={clearAll} className="btn btn-link" tabIndex={interactive ? 0 : -1}>
|
||||
{tr('清空')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
|
||||
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => (
|
||||
<button key={filterType} onClick={() => setFilter(filterType)}
|
||||
style={{
|
||||
fontSize: 11, padding: '3px 8px', borderRadius: 12,
|
||||
border: filter === filterType ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
|
||||
background: filter === filterType ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: filter === filterType ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')}
|
||||
</button>
|
||||
))}
|
||||
{(audience === 'user' ? userFilterTypes : adminFilterTypes).map((filterType) => {
|
||||
const filterLabel = filterType ? tr(typeLabels[filterType] || filterType) : tr('全部');
|
||||
const active = filter === filterType;
|
||||
return (
|
||||
<button
|
||||
key={filterType}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={`${tr('通知筛选')}: ${filterLabel}`}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
onClick={() => setFilter(filterType)}
|
||||
style={{
|
||||
fontSize: 11, padding: '3px 8px', borderRadius: 12,
|
||||
border: active ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
|
||||
background: active ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{filterLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Events list */}
|
||||
<div style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||
<div role="list" aria-busy={loading} aria-live="polite" className="notification-panel-list">
|
||||
{loading && <div style={{ padding: 20, textAlign: 'center' }}><span className="spinner spinner-sm" /></div>}
|
||||
{!loading && events.length === 0 && (
|
||||
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
{tr('暂无通知')}
|
||||
</div>
|
||||
)}
|
||||
{events.map((ev: any) => {
|
||||
const targetPath = buildEventNavigationPath(ev);
|
||||
{events.map((ev: any, index: number) => {
|
||||
const targetPath = audience === 'user'
|
||||
? buildUserNotificationNavigationPath(ev)
|
||||
: buildEventNavigationPath(ev);
|
||||
const diagnosticActions = buildNotificationDiagnosticActions(ev, targetPath);
|
||||
const active = activeEventIndex === index;
|
||||
const openTarget = () => {
|
||||
onClose();
|
||||
navigate(targetPath);
|
||||
@@ -129,7 +357,10 @@ export default function NotificationPanel({
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="notification-event-item"
|
||||
ref={(node) => {
|
||||
eventRefs.current[index] = node;
|
||||
}}
|
||||
className={`notification-event-item ${active ? 'is-active' : ''}`.trim()}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
@@ -140,8 +371,18 @@ export default function NotificationPanel({
|
||||
}}
|
||||
onClick={openTarget}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
onMouseEnter={() => {
|
||||
focusActiveEventRef.current = false;
|
||||
setActiveEventIndex(index);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveEvent(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
@@ -163,6 +404,11 @@ export default function NotificationPanel({
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{formatDateTimeMinuteLocal(ev.createdAt)}
|
||||
</div>
|
||||
<DiagnosticActionGroup
|
||||
actions={diagnosticActions}
|
||||
className="notification-event-actions"
|
||||
interactive={interactive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,8 +11,12 @@ describe('ResponsiveBatchActionBar', () => {
|
||||
</ResponsiveBatchActionBar>,
|
||||
);
|
||||
|
||||
const bar = root.root.find((node) => node.props.className === 'mobile-actions-bar mobile-batch-bar');
|
||||
const bar = root.root.find((node) => String(node.props.className || '').includes('mobile-batch-bar'));
|
||||
expect(bar).toBeTruthy();
|
||||
expect(bar.props.className).toContain('responsive-batch-action-bar');
|
||||
const info = root.root.find((node) => node.props.className === 'mobile-actions-info');
|
||||
expect(info.props.role).toBe('status');
|
||||
expect(info.props['aria-live']).toBe('polite');
|
||||
expect(root.root.findByType('button').children).toContain('批量启用');
|
||||
});
|
||||
|
||||
@@ -23,8 +27,11 @@ describe('ResponsiveBatchActionBar', () => {
|
||||
</ResponsiveBatchActionBar>,
|
||||
);
|
||||
|
||||
const card = root.root.find((node) => node.props.className === 'card');
|
||||
const card = root.root.find((node) => String(node.props.className || '').includes('responsive-batch-action-bar'));
|
||||
expect(card).toBeTruthy();
|
||||
expect(root.root.findAllByType('span').some((node) => node.children.includes('已选 3 项'))).toBe(true);
|
||||
expect(card.props.className).toContain('card');
|
||||
const info = root.root.find((node) => node.type === 'span' && node.children.includes('已选 3 项'));
|
||||
expect(info.props.role).toBe('status');
|
||||
expect(info.props['aria-live']).toBe('polite');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,8 +34,11 @@ export default function ResponsiveBatchActionBar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}>
|
||||
<span style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
|
||||
<div
|
||||
className="card responsive-batch-action-bar"
|
||||
style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}
|
||||
>
|
||||
<span role="status" aria-live="polite" style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,8 @@ export default function ResponsiveFilterPanel({
|
||||
mobileTriggerButtonClassName = 'btn btn-ghost',
|
||||
mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' },
|
||||
}: ResponsiveFilterPanelProps) {
|
||||
const mobilePanelId = React.useId();
|
||||
|
||||
if (!isMobile) {
|
||||
return <>{desktopContent}</>;
|
||||
}
|
||||
@@ -44,13 +46,16 @@ export default function ResponsiveFilterPanel({
|
||||
type="button"
|
||||
className={mobileTriggerButtonClassName}
|
||||
style={mobileTriggerButtonStyle}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls={mobilePanelId}
|
||||
onClick={onMobileOpen}
|
||||
>
|
||||
{mobileTriggerLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null)}
|
||||
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle}>
|
||||
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle} panelId={mobilePanelId}>
|
||||
{mobileContent}
|
||||
</MobileFilterSheet>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useId, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||
import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
interface SiteResult {
|
||||
@@ -65,25 +66,106 @@ interface SearchResult {
|
||||
models: ModelSearchResult[];
|
||||
}
|
||||
|
||||
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
type SearchResultAction = {
|
||||
key: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export default function SearchModal({
|
||||
open,
|
||||
onClose,
|
||||
dialogId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
dialogId?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const presence = useAnimatedVisibility(open, 180);
|
||||
const titleId = useId();
|
||||
const resultsId = useId();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeResultIndex, setActiveResultIndex] = useState(-1);
|
||||
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const resultRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const timerRef = useRef<number>();
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const focusTimerRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const openRef = useRef(open);
|
||||
const searchSeqRef = useRef(0);
|
||||
const focusActiveResultRef = useRef(false);
|
||||
const canLockBodyScroll = typeof document !== 'undefined' && !!document.body?.style;
|
||||
|
||||
const clearSearchTimer = useCallback(() => {
|
||||
if (timerRef.current === null) return;
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearFocusTimer = useCallback(() => {
|
||||
if (focusTimerRef.current === null) return;
|
||||
window.clearTimeout(focusTimerRef.current);
|
||||
focusTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
clearSearchTimer();
|
||||
clearFocusTimer();
|
||||
}, [clearFocusTimer, clearSearchTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
searchSeqRef.current += 1;
|
||||
focusActiveResultRef.current = false;
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
setActiveResultIndex(-1);
|
||||
clearFocusTimer();
|
||||
focusTimerRef.current = window.setTimeout(() => {
|
||||
focusTimerRef.current = null;
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
} else {
|
||||
searchSeqRef.current += 1;
|
||||
focusActiveResultRef.current = false;
|
||||
clearSearchTimer();
|
||||
clearFocusTimer();
|
||||
setLoading(false);
|
||||
setActiveResultIndex(-1);
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
}, [clearFocusTimer, clearSearchTimer, open]);
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
useEffect(() => {
|
||||
if (!open || !canLockBodyScroll) return undefined;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [canLockBodyScroll, open]);
|
||||
|
||||
const doSearch = useCallback(async (q: string, searchSeq: number) => {
|
||||
if (!q.trim()) {
|
||||
setResults(null);
|
||||
return;
|
||||
@@ -92,6 +174,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.search(q);
|
||||
if (!mountedRef.current || !openRef.current || searchSeqRef.current !== searchSeq) return;
|
||||
setResults({
|
||||
models: Array.isArray(res?.models) ? res.models : [],
|
||||
accounts: Array.isArray(res?.accounts) ? res.accounts : [],
|
||||
@@ -100,17 +183,25 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
|
||||
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
|
||||
});
|
||||
setActiveResultIndex(-1);
|
||||
focusActiveResultRef.current = false;
|
||||
} catch {
|
||||
// ignore search errors in modal
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mountedRef.current && openRef.current && searchSeqRef.current === searchSeq) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInput = (val: string) => {
|
||||
setQuery(val);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => doSearch(val), 300);
|
||||
focusActiveResultRef.current = false;
|
||||
setActiveResultIndex(-1);
|
||||
clearSearchTimer();
|
||||
const searchSeq = searchSeqRef.current;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
void doSearch(val, searchSeq);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const goTo = (path: string) => {
|
||||
@@ -119,8 +210,24 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeResultIndex < 0) return;
|
||||
const activeResult = resultRefs.current[activeResultIndex];
|
||||
activeResult?.scrollIntoView({ block: 'nearest' });
|
||||
if (focusActiveResultRef.current) {
|
||||
activeResult?.focus({ preventScroll: true });
|
||||
focusActiveResultRef.current = false;
|
||||
}
|
||||
}, [activeFocusRequest, activeResultIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) onClose();
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(e, dialogRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
@@ -128,6 +235,8 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const hasResults = results && (
|
||||
results.models.length
|
||||
|| results.accounts.length
|
||||
@@ -136,11 +245,103 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
|| results.checkinLogs.length
|
||||
|| results.proxyLogs.length
|
||||
);
|
||||
const resultActions: SearchResultAction[] = results ? [
|
||||
...results.models.map((model) => ({
|
||||
key: `model:${model.name}`,
|
||||
path: `/models?q=${encodeURIComponent(model.name)}`,
|
||||
})),
|
||||
...results.sites.map((site) => ({
|
||||
key: `site:${site.id}`,
|
||||
path: buildSiteFocusPath(site.id),
|
||||
})),
|
||||
...results.accounts.map((account) => ({
|
||||
key: `account:${account.id}`,
|
||||
path: buildAccountFocusPath(account.id, {
|
||||
openRebind: account.status === 'expired',
|
||||
segment: account.segment,
|
||||
}),
|
||||
})),
|
||||
...results.accountTokens.map((token) => ({
|
||||
key: `token:${token.id}`,
|
||||
path: buildTokenFocusPath(token.id),
|
||||
})),
|
||||
...results.checkinLogs.map((log) => ({
|
||||
key: `checkin:${log.id}`,
|
||||
path: '/checkin',
|
||||
})),
|
||||
...results.proxyLogs.map((log) => ({
|
||||
key: `proxy:${log.id}`,
|
||||
path: '/logs',
|
||||
})),
|
||||
] : [];
|
||||
const activeResultId = activeResultIndex >= 0 && activeResultIndex < resultActions.length
|
||||
? `${resultsId}-option-${activeResultIndex}`
|
||||
: undefined;
|
||||
const moveActiveResult = (direction: 1 | -1, shouldFocusResult: boolean) => {
|
||||
if (!resultActions.length) return;
|
||||
focusActiveResultRef.current = shouldFocusResult;
|
||||
setActiveResultIndex((current) => {
|
||||
if (current < 0) return direction > 0 ? 0 : resultActions.length - 1;
|
||||
return (current + direction + resultActions.length) % resultActions.length;
|
||||
});
|
||||
if (shouldFocusResult) {
|
||||
setActiveFocusRequest((request) => request + 1);
|
||||
}
|
||||
};
|
||||
const buildResultProps = (action: SearchResultAction, index: number) => ({
|
||||
id: `${resultsId}-option-${index}`,
|
||||
ref: (node: HTMLButtonElement | null) => {
|
||||
resultRefs.current[index] = node;
|
||||
},
|
||||
role: 'option',
|
||||
'aria-selected': activeResultIndex === index,
|
||||
className: `search-result-item ${activeResultIndex === index ? 'is-active' : ''}`.trim(),
|
||||
tabIndex: interactive ? 0 : -1,
|
||||
onClick: () => goTo(action.path),
|
||||
onMouseEnter: () => {
|
||||
focusActiveResultRef.current = false;
|
||||
setActiveResultIndex(index);
|
||||
},
|
||||
onKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveResult(event.key === 'ArrowDown' ? 1 : -1, true);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
goTo(action.path);
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (!resultActions.length) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveResult(event.key === 'ArrowDown' ? 1 : -1, false);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && activeResultIndex >= 0 && activeResultIndex < resultActions.length) {
|
||||
event.preventDefault();
|
||||
goTo(resultActions[activeResultIndex].path);
|
||||
}
|
||||
};
|
||||
let resultIndex = 0;
|
||||
|
||||
return (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<div
|
||||
id={dialogId}
|
||||
ref={dialogRef}
|
||||
className={`modal-content search-modal ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-hidden={!interactive}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="search-modal-header">
|
||||
<span id={titleId} className="sr-only">{t('全局搜索')}</span>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
@@ -148,6 +349,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => handleInput(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={Boolean(hasResults)}
|
||||
aria-controls={resultsId}
|
||||
aria-activedescendant={activeResultId}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
placeholder={t('搜索站点、账号、模型、日志...')}
|
||||
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
@@ -155,7 +362,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
<kbd style={{ fontSize: 11, padding: '2px 6px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-muted)' }}>ESC</kbd>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', padding: '8px 0' }}>
|
||||
<div id={resultsId} role="listbox" aria-hidden={!interactive} aria-busy={loading} aria-live="polite" className="search-modal-results">
|
||||
{query && !loading && !hasResults && (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
{t('没有找到匹配结果')}
|
||||
@@ -165,8 +372,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
{results?.models.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('模型广场')}</div>
|
||||
{results.models.map((m) => (
|
||||
<button key={m.name} className="search-result-item" onClick={() => goTo(`/models?q=${encodeURIComponent(m.name)}`)}>
|
||||
{results.models.map((m) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={m.name} {...props}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L4 12l5.75-5M14.25 7L20 12l-5.75 5M14 4l-4 16" />
|
||||
</svg>
|
||||
@@ -177,15 +388,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.sites.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
|
||||
{results.sites.map((s) => (
|
||||
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}>
|
||||
{results.sites.map((s) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={s.id} {...props}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
|
||||
</svg>
|
||||
@@ -194,21 +410,23 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{s.url}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.accounts.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
|
||||
{results.accounts.map((a) => (
|
||||
{results.accounts.map((a) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
className="search-result-item"
|
||||
onClick={() => goTo(buildAccountFocusPath(a.id, {
|
||||
openRebind: a.status === 'expired',
|
||||
segment: a.segment,
|
||||
}))}
|
||||
{...props}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
@@ -225,18 +443,23 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.accountTokens.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号令牌')}</div>
|
||||
{results.accountTokens.map((token) => (
|
||||
{results.accountTokens.map((token) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={token.id}
|
||||
className="search-result-item"
|
||||
onClick={() => goTo(buildTokenFocusPath(token.id))}
|
||||
{...props}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
@@ -251,15 +474,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.checkinLogs.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('签到记录')}</div>
|
||||
{results.checkinLogs.map((l) => (
|
||||
<button key={l.id} className="search-result-item" onClick={() => goTo('/checkin')}>
|
||||
{results.checkinLogs.map((l) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={l.id} {...props}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -270,15 +498,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.proxyLogs.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('使用日志')}</div>
|
||||
{results.proxyLogs.map((l) => (
|
||||
<button key={l.id} className="search-result-item" onClick={() => goTo('/logs')}>
|
||||
{results.proxyLogs.map((l) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={l.id} {...props}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
|
||||
</svg>
|
||||
@@ -289,12 +522,13 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-border-light)', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 12 }}>
|
||||
<div className="search-modal-footer">
|
||||
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>↑↓</kbd> {t('导航')}</span>
|
||||
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Enter</kbd> {t('打开')}</span>
|
||||
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Esc</kbd> {t('关闭')}</span>
|
||||
|
||||
@@ -74,6 +74,16 @@ describe('SiteCreatedModal', () => {
|
||||
]),
|
||||
);
|
||||
expect(choiceButtons.every((button) => !String(button.props.className || '').includes('btn-outline'))).toBe(true);
|
||||
expect(choiceButtons.every((button) => button.props.type === 'button')).toBe(true);
|
||||
expect(choiceButtons.find((button) => collectText(button) === '添加 API Key(推荐)')?.props['aria-label'])
|
||||
.toBe('站点创建推荐操作:添加 API Key(推荐)');
|
||||
expect(choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)')?.props['aria-label'])
|
||||
.toBe('站点创建后续操作:添加账号(用户名密码登录)');
|
||||
const laterButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node) === '稍后配置'
|
||||
));
|
||||
expect(laterButton.props.type).toBe('button');
|
||||
|
||||
const sessionButton = choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)');
|
||||
await act(async () => {
|
||||
@@ -101,5 +111,6 @@ describe('SiteCreatedModal', () => {
|
||||
));
|
||||
|
||||
expect(buttons.some((button) => collectText(button) === '添加 OAuth 连接')).toBe(true);
|
||||
expect(buttons.every((button) => button.props.type === 'button')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,10 +56,12 @@ export default function SiteCreatedModal({
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={() => onChoice('later')} className="btn btn-ghost">
|
||||
<button type="button" onClick={() => onChoice('later')} className="btn btn-ghost">
|
||||
稍后配置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`站点创建后续操作:${secondaryAction.label}`}
|
||||
onClick={() => onChoice(secondaryAction.choice)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
@@ -67,6 +69,8 @@ export default function SiteCreatedModal({
|
||||
{secondaryAction.label}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`站点创建推荐操作:${primaryAction.label}`}
|
||||
onClick={() => onChoice(primaryAction.choice)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { ToastProvider, useToast } from './Toast.js';
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => (
|
||||
typeof child === 'string' ? child : collectText(child)
|
||||
)).join('');
|
||||
}
|
||||
|
||||
function ToastHarness() {
|
||||
const toast = useToast();
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => toast.success('保存成功')}>success</button>
|
||||
<button type="button" onClick={() => toast.error('保存失败')}>error</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderToastHarness() {
|
||||
let root!: ReturnType<typeof create>;
|
||||
act(() => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ToastHarness />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function clickButton(root: ReturnType<typeof create>, label: string) {
|
||||
const button = root.root.find((node) => node.type === 'button' && collectText(node) === label);
|
||||
act(() => {
|
||||
button.props.onClick();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ToastProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('announces non-error toasts as polite status messages with a close control', () => {
|
||||
const root = renderToastHarness();
|
||||
|
||||
clickButton(root, 'success');
|
||||
|
||||
const toast = root.root.findByProps({ role: 'status' });
|
||||
expect(toast.props['aria-live']).toBe('polite');
|
||||
expect(toast.props['aria-atomic']).toBe('true');
|
||||
expect(toast.props.style).toMatchObject({ '--toast-duration': '3200ms' });
|
||||
expect(collectText(toast)).toContain('保存成功');
|
||||
|
||||
const dismiss = toast.findByProps({ className: 'toast-dismiss' });
|
||||
expect(dismiss.props.type).toBe('button');
|
||||
expect(dismiss.props['aria-label']).toBe('关闭通知:保存成功');
|
||||
});
|
||||
|
||||
it('announces error toasts assertively', () => {
|
||||
const root = renderToastHarness();
|
||||
|
||||
clickButton(root, 'error');
|
||||
|
||||
const toast = root.root.findByProps({ role: 'alert' });
|
||||
expect(toast.props['aria-live']).toBe('assertive');
|
||||
expect(toast.props['aria-atomic']).toBe('true');
|
||||
expect(collectText(toast)).toContain('保存失败');
|
||||
});
|
||||
|
||||
it('dismisses toasts through the close control and keeps the exit animation state', () => {
|
||||
const root = renderToastHarness();
|
||||
|
||||
clickButton(root, 'success');
|
||||
|
||||
const toast = root.root.findByProps({ role: 'status' });
|
||||
const dismiss = toast.findByProps({ className: 'toast-dismiss' });
|
||||
act(() => {
|
||||
dismiss.props.onClick();
|
||||
});
|
||||
|
||||
const exitingToast = root.root.findByProps({ role: 'status' });
|
||||
expect(exitingToast.props.className).toContain('toast-exit');
|
||||
expect(exitingToast.props['aria-hidden']).toBe('true');
|
||||
expect(exitingToast.findByProps({ className: 'toast-dismiss' }).props.tabIndex).toBe(-1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(root.root.findAllByProps({ role: 'status' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clears pending toast timers when the provider unmounts', () => {
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
|
||||
const root = renderToastHarness();
|
||||
|
||||
clickButton(root, 'success');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
const TOAST_DURATION_MS = 3200;
|
||||
const TOAST_EXIT_DURATION_MS = 250;
|
||||
type ToastTimer = Parameters<typeof clearTimeout>[0];
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
@@ -33,19 +36,42 @@ const icons: Record<ToastType, React.ReactNode> = {
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const idRef = useRef(0);
|
||||
const timersRef = useRef(new Map<number, ToastTimer[]>());
|
||||
|
||||
const trackTimer = useCallback((id: number, timer: ToastTimer) => {
|
||||
const timers = timersRef.current.get(id) || [];
|
||||
timers.push(timer);
|
||||
timersRef.current.set(id, timers);
|
||||
}, []);
|
||||
|
||||
const clearToastTimers = useCallback((id: number) => {
|
||||
const timers = timersRef.current.get(id) || [];
|
||||
timers.forEach((timer) => globalThis.clearTimeout(timer));
|
||||
timersRef.current.delete(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
timersRef.current.forEach((timers) => {
|
||||
timers.forEach((timer) => globalThis.clearTimeout(timer));
|
||||
});
|
||||
timersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
|
||||
setTimeout(() => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 250);
|
||||
}, []);
|
||||
clearToastTimers(id);
|
||||
}, TOAST_EXIT_DURATION_MS) as ToastTimer;
|
||||
trackTimer(id, timer);
|
||||
}, [clearToastTimers, trackTimer]);
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string) => {
|
||||
const id = ++idRef.current;
|
||||
setToasts(prev => [...prev, { id, type, message }]);
|
||||
setTimeout(() => removeToast(id), 3200);
|
||||
}, [removeToast]);
|
||||
const timer = globalThis.setTimeout(() => removeToast(id), TOAST_DURATION_MS) as ToastTimer;
|
||||
trackTimer(id, timer);
|
||||
}, [removeToast, trackTimer]);
|
||||
|
||||
const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
|
||||
const error = useCallback((msg: string) => addToast('error', msg), [addToast]);
|
||||
@@ -68,11 +94,27 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`}
|
||||
onClick={() => removeToast(t.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
role={t.type === 'error' ? 'alert' : 'status'}
|
||||
aria-live={t.type === 'error' ? 'assertive' : 'polite'}
|
||||
aria-atomic="true"
|
||||
aria-hidden={t.exiting ? 'true' : undefined}
|
||||
style={{ '--toast-duration': `${TOAST_DURATION_MS}ms` } as React.CSSProperties}
|
||||
>
|
||||
<span style={{ flexShrink: 0, marginTop: 1 }}>{icons[t.type]}</span>
|
||||
<span style={{ fontSize: 13, lineHeight: 1.5 }}>{t.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="toast-dismiss"
|
||||
aria-label={`关闭通知:${t.message}`}
|
||||
tabIndex={t.exiting ? -1 : 0}
|
||||
onClick={() => removeToast(t.id)}
|
||||
>
|
||||
<span className="toast-icon" aria-hidden="true">{icons[t.type]}</span>
|
||||
<span className="toast-message">{t.message}</span>
|
||||
<span className="toast-close" aria-hidden="true">
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.25} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div className="toast-progress" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { create } from 'react-test-renderer';
|
||||
import UserAvatar, {
|
||||
getUserAvatarInitials,
|
||||
getUserAvatarStyle,
|
||||
userAvatarRoleLabel,
|
||||
userAvatarStatusLabel,
|
||||
} from './UserAvatar.js';
|
||||
|
||||
describe('UserAvatar', () => {
|
||||
it('builds readable initials from user names and email fallbacks', () => {
|
||||
expect(getUserAvatarInitials({ id: 1, username: 'auto_user', email: 'auto@example.com' })).toBe('AU');
|
||||
expect(getUserAvatarInitials({ id: 2, username: 'ops lead', email: 'ops@example.com' })).toBe('OL');
|
||||
expect(getUserAvatarInitials({ id: 3, username: '', email: 'billing.owner@example.com' })).toBe('BO');
|
||||
expect(getUserAvatarInitials({ id: 4, username: '', email: '' })).toBe('U4');
|
||||
});
|
||||
|
||||
it('renders role status markers and stable hue variables', () => {
|
||||
const root = create(
|
||||
<UserAvatar
|
||||
id={7}
|
||||
username="ops lead"
|
||||
email="ops@example.com"
|
||||
role="admin"
|
||||
status="disabled"
|
||||
size="lg"
|
||||
className="custom-avatar"
|
||||
/>,
|
||||
);
|
||||
|
||||
const avatar = root.root.findByProps({ role: 'img' });
|
||||
expect(avatar.props.className).toContain('user-avatar');
|
||||
expect(avatar.props.className).toContain('user-avatar-lg');
|
||||
expect(avatar.props.className).toContain('is-admin');
|
||||
expect(avatar.props.className).toContain('is-disabled');
|
||||
expect(avatar.props.className).toContain('custom-avatar');
|
||||
expect(avatar.props['data-role']).toBe('admin');
|
||||
expect(avatar.props['data-status']).toBe('disabled');
|
||||
expect(avatar.props['aria-label']).toContain('管理员');
|
||||
expect(avatar.props['aria-label']).toContain('已禁用');
|
||||
expect(avatar.findByProps({ className: 'user-avatar-initials' }).children.join('')).toBe('OL');
|
||||
expect(avatar.findByProps({ className: 'user-avatar-role-mark' }).children.join('')).toBe('A');
|
||||
expect(avatar.findByProps({ className: 'user-avatar-status' }).props['aria-hidden']).toBe('true');
|
||||
expect(avatar.props.style['--user-avatar-hue']).toMatch(/^\d+$/);
|
||||
expect(avatar.props.style['--user-avatar-hue-2']).toMatch(/^\d+$/);
|
||||
expect(getUserAvatarStyle({ id: 7, username: 'ops lead', email: 'ops@example.com' })).toEqual(avatar.props.style);
|
||||
});
|
||||
|
||||
it('normalizes unknown roles and statuses to the user-facing defaults', () => {
|
||||
const root = create(<UserAvatar id={8} email="reader@example.com" role="reader" status="pending" />);
|
||||
const avatar = root.root.findByProps({ role: 'img' });
|
||||
|
||||
expect(userAvatarRoleLabel('reader')).toBe('普通用户');
|
||||
expect(userAvatarStatusLabel('pending')).toBe('正常');
|
||||
expect(avatar.props['data-role']).toBe('user');
|
||||
expect(avatar.props['data-status']).toBe('active');
|
||||
expect(avatar.findByProps({ className: 'user-avatar-role-mark' }).children.join('')).toBe('U');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
|
||||
export type UserAvatarRole = 'admin' | 'user' | string;
|
||||
export type UserAvatarStatus = 'active' | 'disabled' | string;
|
||||
|
||||
export interface UserAvatarIdentity {
|
||||
id?: number | string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
role?: UserAvatarRole | null;
|
||||
status?: UserAvatarStatus | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface UserAvatarProps extends UserAvatarIdentity {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
styleOverride?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function hashString(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function userAvatarRoleLabel(role?: UserAvatarRole | null): string {
|
||||
return role === 'admin' ? '管理员' : '普通用户';
|
||||
}
|
||||
|
||||
export function userAvatarStatusLabel(status?: UserAvatarStatus | null): string {
|
||||
return status === 'disabled' ? '已禁用' : '正常';
|
||||
}
|
||||
|
||||
export function getUserAvatarInitials(identity: UserAvatarIdentity): string {
|
||||
const emailName = (identity.email || '').split('@')[0] || '';
|
||||
const source = (identity.username || emailName || `user-${identity.id || ''}`).trim();
|
||||
const segments = source
|
||||
.replace(/[_+.-]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const raw = segments.length >= 2
|
||||
? `${Array.from(segments[0])[0] || ''}${Array.from(segments[1])[0] || ''}`
|
||||
: Array.from(source).slice(0, 2).join('');
|
||||
return (raw || 'U').toUpperCase();
|
||||
}
|
||||
|
||||
export function getUserAvatarStyle(identity: UserAvatarIdentity): React.CSSProperties {
|
||||
const seed = `${identity.id || 'user'}:${identity.username || ''}:${identity.email || ''}`;
|
||||
const hue = hashString(seed) % 360;
|
||||
const secondHue = (hue + 42 + (hashString(identity.email || seed) % 80)) % 360;
|
||||
return {
|
||||
'--user-avatar-hue': String(hue),
|
||||
'--user-avatar-hue-2': String(secondHue),
|
||||
} as React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function UserAvatar({
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
role = 'user',
|
||||
status = 'active',
|
||||
avatarUrl,
|
||||
className = '',
|
||||
size = 'md',
|
||||
styleOverride,
|
||||
}: UserAvatarProps) {
|
||||
const [imageBroken, setImageBroken] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
setImageBroken(false);
|
||||
}, [avatarUrl]);
|
||||
|
||||
const isAdmin = role === 'admin';
|
||||
const isDisabled = status === 'disabled';
|
||||
const hasAvatarImage = Boolean(avatarUrl && !imageBroken);
|
||||
const roleMark = isAdmin ? 'A' : 'U';
|
||||
const labelTarget = username || email || id || '用户';
|
||||
const rootClassName = [
|
||||
'user-avatar',
|
||||
`user-avatar-${size}`,
|
||||
isAdmin ? 'is-admin' : '',
|
||||
isDisabled ? 'is-disabled' : '',
|
||||
hasAvatarImage ? 'has-image' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rootClassName}
|
||||
style={{ ...getUserAvatarStyle({ id, username, email }), ...styleOverride }}
|
||||
data-role={isAdmin ? 'admin' : 'user'}
|
||||
data-status={isDisabled ? 'disabled' : 'active'}
|
||||
role="img"
|
||||
aria-label={`用户头像:${labelTarget},${userAvatarRoleLabel(role)},${userAvatarStatusLabel(status)}`}
|
||||
>
|
||||
{hasAvatarImage ? (
|
||||
<img src={avatarUrl || ''} alt="" onError={() => setImageBroken(true)} />
|
||||
) : (
|
||||
<span className="user-avatar-initials">{getUserAvatarInitials({ id, username, email })}</span>
|
||||
)}
|
||||
<span className="user-avatar-role-mark" aria-hidden="true">{roleMark}</span>
|
||||
<span className="user-avatar-status" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
USER_RECHARGE_CONFIG,
|
||||
isUserRechargeOptionPayable,
|
||||
type UserRechargeOption,
|
||||
type UserRechargeCheckoutOption,
|
||||
} from '../userRecharge.js';
|
||||
|
||||
type UserRechargeOptionsProps = {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
onPay?: (option: UserRechargeCheckoutOption) => void | Promise<void>;
|
||||
pendingOptionId?: string | null;
|
||||
internalOnly?: boolean;
|
||||
};
|
||||
|
||||
function rechargeAriaLabel(option: UserRechargeOption) {
|
||||
return `通过 ${USER_RECHARGE_CONFIG.providerLabel} 充值 ${option.label}`;
|
||||
}
|
||||
|
||||
function unavailableAriaLabel(option: UserRechargeOption) {
|
||||
return `${option.label} 充值暂未配置`;
|
||||
}
|
||||
|
||||
export default function UserRechargeOptions({
|
||||
className = '',
|
||||
compact = false,
|
||||
onPay,
|
||||
pendingOptionId = null,
|
||||
internalOnly = false,
|
||||
}: UserRechargeOptionsProps) {
|
||||
const options = USER_RECHARGE_CONFIG.options.length > 0
|
||||
? USER_RECHARGE_CONFIG.options
|
||||
: [USER_RECHARGE_CONFIG.defaultOption];
|
||||
|
||||
return (
|
||||
<div className={['user-recharge-options', compact ? 'is-compact' : '', className].filter(Boolean).join(' ')}>
|
||||
{options.map((option) => {
|
||||
const className = `user-recharge-option${option.recommended ? ' is-recommended' : ''}${isUserRechargeOptionPayable(option) ? '' : ' is-disabled'}`;
|
||||
|
||||
if (!isUserRechargeOptionPayable(option)) {
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={className}
|
||||
aria-label={unavailableAriaLabel(option)}
|
||||
title={option.unavailableReason || '该额度支付链接未配置'}
|
||||
disabled
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
<em>待配置</em>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (internalOnly || !onPay) {
|
||||
return (
|
||||
<a
|
||||
key={option.id}
|
||||
href="/user/recharge"
|
||||
className={className}
|
||||
aria-label={`前往系统内充值页确认 ${option.label}`}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const pending = pendingOptionId === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={className}
|
||||
aria-label={rechargeAriaLabel(option)}
|
||||
onClick={() => void onPay(option)}
|
||||
disabled={pending}
|
||||
data-checkout-option-id={option.id}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
{pending ? <em>创建记录中</em> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { extname, join, relative, sep } from 'node:path';
|
||||
|
||||
const webRoot = join(process.cwd(), 'src/web');
|
||||
const centeredModalSource = join(webRoot, 'components/CenteredModal.tsx');
|
||||
|
||||
function listSourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) return listSourceFiles(path);
|
||||
if (!entry.isFile()) return [];
|
||||
if (path === centeredModalSource) return [];
|
||||
if (!['.ts', '.tsx'].includes(extname(path))) return [];
|
||||
if (path.includes('.test.') || path.includes('.spec.')) return [];
|
||||
|
||||
return [path];
|
||||
});
|
||||
}
|
||||
|
||||
function toProjectPath(path: string): string {
|
||||
return relative(process.cwd(), path).split(sep).join('/');
|
||||
}
|
||||
|
||||
function findCenteredModalBlocks(source: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < source.length) {
|
||||
const start = source.indexOf('<CenteredModal', cursor);
|
||||
if (start === -1) break;
|
||||
|
||||
const closingTag = source.indexOf('</CenteredModal>', start);
|
||||
const end = closingTag === -1
|
||||
? source.indexOf('>', start)
|
||||
: closingTag + '</CenteredModal>'.length;
|
||||
|
||||
blocks.push(source.slice(start, end === -1 ? source.length : end));
|
||||
cursor = end === -1 ? source.length : end;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
describe('CenteredModal source usage', () => {
|
||||
it('keeps Escape closing explicit on every production CenteredModal call site', () => {
|
||||
const missingCloseOnEscape = listSourceFiles(webRoot).flatMap((path) => {
|
||||
const source = readFileSync(path, 'utf8');
|
||||
|
||||
return findCenteredModalBlocks(source)
|
||||
.map((block, index) => ({ block, index }))
|
||||
.filter(({ block }) => !/\bcloseOnEscape\b/.test(block))
|
||||
.map(({ index }) => `${toProjectPath(path)}#${index + 1}`);
|
||||
});
|
||||
|
||||
expect(missingCloseOnEscape).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,12 @@ import CenteredModal from './CenteredModal.js';
|
||||
|
||||
describe('CenteredModal component', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalHTMLElement = globalThis.HTMLElement;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.HTMLElement = originalHTMLElement;
|
||||
});
|
||||
|
||||
it('does not close on backdrop click by default and exposes an explicit close button', async () => {
|
||||
@@ -47,13 +50,123 @@ describe('CenteredModal component', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders as a labelled dialog and restores focus when it closes', async () => {
|
||||
class MockHTMLElement {
|
||||
isConnected = true;
|
||||
focus = vi.fn();
|
||||
}
|
||||
const activeElement = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalThis.document = {
|
||||
activeElement,
|
||||
body: { style: {} },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as Document;
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<CenteredModal open onClose={() => {}} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(dialog.props['aria-hidden']).toBe(false);
|
||||
expect(typeof dialog.props['aria-labelledby']).toBe('string');
|
||||
expect(dialog.props.tabIndex).toBe(-1);
|
||||
|
||||
const title = root.root.find((node) => (
|
||||
node.props.id === dialog.props['aria-labelledby']
|
||||
));
|
||||
expect(title.props.className).toBe('modal-title');
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<CenteredModal open={false} onClose={() => {}} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(activeElement.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('hides dialog semantics and close button tab stop during the closing animation', async () => {
|
||||
vi.useFakeTimers();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<CenteredModal open onClose={() => {}} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<CenteredModal open={false} onClose={() => {}} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-hidden']).toBe(true);
|
||||
expect(String(dialog.props.className)).toContain('is-closing');
|
||||
const closeButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '关闭弹框'
|
||||
));
|
||||
expect(closeButton.props.tabIndex).toBe(-1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(220);
|
||||
});
|
||||
|
||||
expect(root.toJSON()).toBeNull();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the shared centered modal shell pattern', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('modal-backdrop');
|
||||
expect(source).toContain('modal-content');
|
||||
expect(source).toContain('role="dialog"');
|
||||
expect(source).toContain('aria-modal="true"');
|
||||
expect(source).toContain('aria-labelledby={titleId}');
|
||||
expect(source).toContain('const dialogRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(source).toContain('ref={dialogRef}');
|
||||
expect(source).toContain('tabIndex={-1}');
|
||||
expect(source).toContain('dialog.focus({ preventScroll: true });');
|
||||
expect(source).toContain('lastActiveElementRef.current?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('useAnimatedVisibility');
|
||||
expect(source).toContain('createPortal');
|
||||
expect(source).toContain("import { trapFocusInContainer } from './focusTrap.js';");
|
||||
expect(source).toContain('trapFocusInContainer(event, dialogRef.current);');
|
||||
});
|
||||
|
||||
it('bounds shared modal height with dynamic mobile viewports', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.modal-content {\n background: var(--color-bg-card);');
|
||||
expect(css).toContain('max-height: calc(100dvh - 32px);');
|
||||
expect(css).not.toContain('max-height: calc(100vh - 32px);');
|
||||
expect(css).toContain('.modal-content {\n margin: 12px;');
|
||||
expect(css).toContain('max-height: calc(100dvh - 24px);');
|
||||
});
|
||||
|
||||
it('renders safely without touching document.body or listeners when no usable body exists', async () => {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import ChangeKeyModal from './ChangeKeyModal.js';
|
||||
|
||||
const { apiMock, authSessionMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
changeAuthToken: vi.fn(),
|
||||
},
|
||||
authSessionMock: {
|
||||
commitSession: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../authSession.js', () => ({
|
||||
commitSession: authSessionMock.commitSession,
|
||||
}));
|
||||
|
||||
vi.mock('./Toast.js', () => ({
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findButton(root: ReactTestInstance, label: string): ReactTestInstance {
|
||||
return root.find((node) => node.type === 'button' && collectText(node).includes(label));
|
||||
}
|
||||
|
||||
function findInput(root: ReactTestInstance, placeholder: string): ReactTestInstance {
|
||||
return root.find((node) => node.type === 'input' && node.props.placeholder === placeholder);
|
||||
}
|
||||
|
||||
describe('ChangeKeyModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses the shared centered dialog shell', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<ChangeKeyModal open onClose={() => {}} />);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(collectText(dialog)).toContain('修改管理员 Token');
|
||||
expect(findButton(root.root, '取消').props.type).toBe('button');
|
||||
expect(findButton(root.root, '确认修改').props.type).toBe('button');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates fields before submitting token changes', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<ChangeKeyModal open onClose={() => {}} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(root.root, '确认修改').props.onClick();
|
||||
});
|
||||
|
||||
expect(apiMock.changeAuthToken).not.toHaveBeenCalled();
|
||||
expect(collectText(root.root)).toContain('请填写所有字段');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('submits valid token changes and commits the new admin session', async () => {
|
||||
apiMock.changeAuthToken.mockResolvedValue({ success: true });
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<ChangeKeyModal open onClose={onClose} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findInput(root.root, '输入当前 Token').props.onChange({ target: { value: 'old-token' } });
|
||||
findInput(root.root, '输入新 Token (至少 6 位)').props.onChange({ target: { value: 'new-token' } });
|
||||
findInput(root.root, '再次输入新 Token').props.onChange({ target: { value: 'new-token' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(root.root, '确认修改').props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.changeAuthToken).toHaveBeenCalledWith('old-token', 'new-token');
|
||||
expect(authSessionMock.commitSession).toHaveBeenCalledWith('admin', 'new-token', undefined, localStorage);
|
||||
expect(toastMock.success).toHaveBeenCalledWith('Token 已更新,请使用新 Token 重新登录');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import DownstreamKeyTrendChart from './DownstreamKeyTrendChart.js';
|
||||
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
|
||||
|
||||
@@ -24,6 +24,13 @@ function formatExpectedAxisLabel(raw: string, bucketSeconds: number): string {
|
||||
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
|
||||
}
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('DownstreamKeyTrendChart', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -125,4 +132,77 @@ describe('DownstreamKeyTrendChart', () => {
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('exposes metric toggles as pressed non-submit buttons', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DownstreamKeyTrendChart
|
||||
buckets={[
|
||||
{
|
||||
startUtc: '2026-04-05T15:00:00.000Z',
|
||||
totalRequests: 2,
|
||||
totalTokens: 2128425,
|
||||
totalCost: 2.128425,
|
||||
successRate: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const metricGroup = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '下游 Key 趋势指标');
|
||||
const buttons = renderer.root.findAll((node) => node.type === 'button' && node.parent === metricGroup);
|
||||
expect(buttons.map((button) => button.props.type)).toEqual(['button', 'button', 'button']);
|
||||
expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([true, false, false]);
|
||||
expect(buttons.map((button) => button.props['aria-label'])).toEqual([
|
||||
'切换下游 Key 趋势指标:Tokens',
|
||||
'切换下游 Key 趋势指标:请求数',
|
||||
'切换下游 Key 趋势指标:成本',
|
||||
]);
|
||||
|
||||
const costButton = buttons.find((button) => collectText(button) === '成本');
|
||||
await act(async () => {
|
||||
costButton!.props.onClick();
|
||||
});
|
||||
|
||||
const updatedButtons = renderer.root.findAll((node) => node.type === 'button' && node.parent?.props['aria-label'] === '下游 Key 趋势指标');
|
||||
expect(updatedButtons.map((button) => button.props['aria-pressed'])).toEqual([false, false, true]);
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('shows a composed empty state instead of rendering a zero-only chart', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DownstreamKeyTrendChart
|
||||
buckets={[
|
||||
{
|
||||
startUtc: '2026-04-05T15:00:00.000Z',
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
successRate: null,
|
||||
},
|
||||
{
|
||||
startUtc: '2026-04-05T16:00:00.000Z',
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
successRate: null,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(vChartSpy).not.toHaveBeenCalled();
|
||||
expect(collectText(renderer.root)).toContain('暂无Tokens趋势');
|
||||
expect(collectText(renderer.root)).toContain('所选时间范围内该指标为 0');
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { useThemeColor, useThemeLabelColor } from '../useThemeLabelColor.js';
|
||||
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
|
||||
|
||||
type Metric = 'tokens' | 'requests' | 'cost';
|
||||
@@ -31,6 +32,29 @@ function formatTrendAxisLabel(raw: string, bucketSeconds: number): string {
|
||||
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
|
||||
}
|
||||
|
||||
function formatMetricValue(metric: Metric, value: number): string {
|
||||
if (!Number.isFinite(value)) return metric === 'cost' ? '$0.000000' : '0';
|
||||
if (metric === 'cost') return `$${value.toFixed(value >= 1 ? 3 : 6)}`;
|
||||
return Math.trunc(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatMetricAxisValue(metric: Metric, value: string): string {
|
||||
const numberValue = Number(value || 0);
|
||||
if (!Number.isFinite(numberValue)) return String(value || '');
|
||||
if (metric === 'cost') {
|
||||
if (numberValue >= 1) return `$${numberValue.toFixed(1)}`;
|
||||
if (numberValue > 0) return `$${numberValue.toFixed(4)}`;
|
||||
return '$0';
|
||||
}
|
||||
if (numberValue >= 1_000_000) return `${(numberValue / 1_000_000).toFixed(1)}M`;
|
||||
if (numberValue >= 1_000) return `${(numberValue / 1_000).toFixed(1)}K`;
|
||||
return Math.round(numberValue).toLocaleString();
|
||||
}
|
||||
|
||||
function getMetricLabel(metric: Metric): string {
|
||||
return METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value';
|
||||
}
|
||||
|
||||
export default function DownstreamKeyTrendChart({
|
||||
buckets,
|
||||
bucketSeconds = 3600,
|
||||
@@ -43,6 +67,9 @@ export default function DownstreamKeyTrendChart({
|
||||
height?: number;
|
||||
}) {
|
||||
const [metric, setMetric] = useState<Metric>('tokens');
|
||||
const labelColor = useThemeLabelColor('#7d8b82');
|
||||
const axisColor = useThemeColor('--color-border', '#dce6dd');
|
||||
const primaryColor = useThemeColor('--color-primary', '#0f766e');
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
if (!Array.isArray(buckets) || buckets.length === 0) return [];
|
||||
@@ -62,27 +89,38 @@ export default function DownstreamKeyTrendChart({
|
||||
})
|
||||
.filter((row) => row.date.length > 0);
|
||||
}, [buckets, metric]);
|
||||
const metricTotal = useMemo(
|
||||
() => flatData.reduce((sum, row) => sum + Number(row.value || 0), 0),
|
||||
[flatData],
|
||||
);
|
||||
const metricPeak = useMemo(
|
||||
() => flatData.reduce((peak, row) => Math.max(peak, Number(row.value || 0)), 0),
|
||||
[flatData],
|
||||
);
|
||||
const hasPositiveValue = flatData.some((row) => Number(row.value || 0) > 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div className="downstream-key-trend-chart">
|
||||
<div className="downstream-key-trend-chart-head">
|
||||
<div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} />
|
||||
<div className="skeleton" style={{ width: 96, height: 30, borderRadius: 'var(--radius-sm)' }} />
|
||||
</div>
|
||||
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
|
||||
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-md)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!flatData || flatData.length === 0) {
|
||||
if (!flatData || flatData.length === 0 || !hasPositiveValue) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div className="downstream-key-trend-chart">
|
||||
<div className="downstream-key-trend-chart-head">
|
||||
<MetricToggle metric={metric} onChange={setMetric} />
|
||||
<MetricSummary metric={metric} total={metricTotal} peak={metricPeak} />
|
||||
</div>
|
||||
<div className="empty-state" style={{ padding: 32 }}>
|
||||
<div className="empty-state-title">暂无趋势数据</div>
|
||||
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div>
|
||||
<div className="downstream-key-chart-empty" style={{ minHeight: height }}>
|
||||
<div className="downstream-key-chart-empty-title">暂无{getMetricLabel(metric)}趋势</div>
|
||||
<div className="downstream-key-chart-empty-desc">所选时间范围内该指标为 0</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -110,16 +148,19 @@ export default function DownstreamKeyTrendChart({
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
|
||||
style: { fontSize: 11, fill: labelColor },
|
||||
formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds),
|
||||
},
|
||||
domainLine: { style: { stroke: 'var(--color-border-light)' } },
|
||||
tick: { style: { stroke: 'var(--color-border-light)' } },
|
||||
domainLine: { style: { stroke: axisColor, lineWidth: 1 } },
|
||||
tick: { style: { stroke: axisColor, lineWidth: 1 } },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
|
||||
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
|
||||
label: {
|
||||
style: { fontSize: 11, fill: labelColor },
|
||||
formatMethod: (value: string) => formatMetricAxisValue(metric, value),
|
||||
},
|
||||
grid: { style: { stroke: axisColor, strokeOpacity: 0.74, lineDash: [3, 6], lineWidth: 1 } },
|
||||
domainLine: { visible: false },
|
||||
},
|
||||
],
|
||||
@@ -128,37 +169,54 @@ export default function DownstreamKeyTrendChart({
|
||||
title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') },
|
||||
content: [
|
||||
{
|
||||
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
|
||||
key: () => getMetricLabel(metric),
|
||||
value: (datum: Record<string, unknown>) => {
|
||||
const value = Number(datum?.value ?? 0);
|
||||
if (metric === 'cost') return `$${value.toFixed(6)}`;
|
||||
return value.toLocaleString();
|
||||
return formatMetricValue(metric, value);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
color: ['var(--color-primary)'],
|
||||
color: [primaryColor],
|
||||
background: 'transparent',
|
||||
animationAppear: {
|
||||
area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' },
|
||||
line: { type: 'clipIn', duration: 700, easing: 'cubicOut' },
|
||||
},
|
||||
padding: { left: 8, right: 16, top: 8, bottom: 8 },
|
||||
padding: { left: metric === 'cost' ? 58 : 50, right: 16, top: 12, bottom: 18 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div className="downstream-key-trend-chart">
|
||||
<div className="downstream-key-trend-chart-head">
|
||||
<MetricToggle metric={metric} onChange={setMetric} />
|
||||
<MetricSummary metric={metric} total={metricTotal} peak={metricPeak} />
|
||||
</div>
|
||||
<div style={{ width: '100%', height }}>
|
||||
<div className="downstream-key-trend-chart-stage" style={{ height }}>
|
||||
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricSummary({
|
||||
metric,
|
||||
total,
|
||||
peak,
|
||||
}: {
|
||||
metric: Metric;
|
||||
total: number;
|
||||
peak: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="downstream-key-chart-summary" aria-label={`${getMetricLabel(metric)}趋势摘要`}>
|
||||
<span>合计 {formatMetricValue(metric, total)}</span>
|
||||
<span>峰值 {formatMetricValue(metric, peak)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricToggle({
|
||||
metric,
|
||||
onChange,
|
||||
@@ -167,15 +225,15 @@ function MetricToggle({
|
||||
onChange: (m: Metric) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleGroupStyle}>
|
||||
<div className="downstream-key-metric-toggle" role="group" aria-label="下游 Key 趋势指标">
|
||||
{METRIC_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
aria-pressed={metric === opt.key}
|
||||
aria-label={`切换下游 Key 趋势指标:${opt.label}`}
|
||||
onClick={() => onChange(opt.key)}
|
||||
style={{
|
||||
...toggleBtnBase,
|
||||
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
|
||||
}}
|
||||
className={metric === opt.key ? 'is-active' : ''}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -183,46 +241,3 @@ function MetricToggle({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-card)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
padding: 16,
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 10,
|
||||
};
|
||||
|
||||
const toggleGroupStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
gap: 0,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const toggleBtnBase: React.CSSProperties = {
|
||||
padding: '6px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontFamily: 'inherit',
|
||||
};
|
||||
|
||||
const toggleBtnActive: React.CSSProperties = {
|
||||
background: 'var(--color-primary)',
|
||||
color: '#ffffff',
|
||||
};
|
||||
|
||||
const toggleBtnInactive: React.CSSProperties = {
|
||||
background: 'var(--color-bg-card)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
};
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import SiteDistributionChart from './SiteDistributionChart.js';
|
||||
|
||||
vi.mock('@visactor/react-vchart', () => ({
|
||||
VChart: () => null,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('SiteDistributionChart', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -48,4 +55,43 @@ describe('SiteDistributionChart', () => {
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('exposes distribution view toggles as pressed non-submit buttons', async () => {
|
||||
let renderer!: WebTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<SiteDistributionChart
|
||||
data={[
|
||||
{
|
||||
siteName: 'Demo Site',
|
||||
platform: 'demo',
|
||||
totalBalance: 12.34,
|
||||
totalSpend: 1.23,
|
||||
accountCount: 2,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const group = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '站点分布视图');
|
||||
const buttons = group.findAllByType('button');
|
||||
expect(buttons.map((button) => button.props.type)).toEqual(['button', 'button']);
|
||||
expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([true, false]);
|
||||
expect(buttons.map((button) => button.props['aria-label'])).toEqual([
|
||||
'切换站点分布视图:余额分布',
|
||||
'切换站点分布视图:消耗分布',
|
||||
]);
|
||||
|
||||
const spendButton = buttons.find((button) => collectText(button) === '消耗分布');
|
||||
await act(async () => {
|
||||
spendButton!.props.onClick();
|
||||
});
|
||||
|
||||
const updatedButtons = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '站点分布视图').findAllByType('button');
|
||||
expect(updatedButtons.map((button) => button.props['aria-pressed'])).toEqual([false, true]);
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,6 +239,8 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
|
||||
{/* Toggle buttons */}
|
||||
<div
|
||||
role="group"
|
||||
aria-label="站点分布视图"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 0,
|
||||
@@ -249,6 +251,9 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'balance'}
|
||||
aria-label="切换站点分布视图:余额分布"
|
||||
onClick={() => setViewMode('balance')}
|
||||
style={{
|
||||
padding: '5px 14px',
|
||||
@@ -266,6 +271,9 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
余额分布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'spend'}
|
||||
aria-label="切换站点分布视图:消耗分布"
|
||||
onClick={() => setViewMode('spend')}
|
||||
style={{
|
||||
padding: '5px 14px',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import SiteTrendChart from './SiteTrendChart.js';
|
||||
|
||||
vi.mock('@visactor/react-vchart', () => ({
|
||||
VChart: () => null,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('SiteTrendChart', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
const originalMutationObserver = globalThis.MutationObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.document = {
|
||||
documentElement: {
|
||||
getAttribute: vi.fn(),
|
||||
},
|
||||
} as unknown as Document;
|
||||
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
|
||||
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.getComputedStyle = originalGetComputedStyle;
|
||||
globalThis.MutationObserver = originalMutationObserver;
|
||||
});
|
||||
|
||||
it('exposes trend metric toggles as pressed non-submit buttons', async () => {
|
||||
let renderer!: WebTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<SiteTrendChart
|
||||
data={[
|
||||
{
|
||||
date: '2026-06-07',
|
||||
spend: 1.2,
|
||||
calls: 3,
|
||||
tokens: 400,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const group = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '站点趋势指标');
|
||||
const buttons = renderer.root.findAll((node) => node.type === 'button' && node.parent === group);
|
||||
expect(buttons.map((button) => button.props.type)).toEqual(['button', 'button']);
|
||||
expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([true, false]);
|
||||
expect(buttons.map((button) => button.props['aria-label'])).toEqual([
|
||||
'切换站点趋势指标:消耗趋势',
|
||||
'切换站点趋势指标:调用趋势',
|
||||
]);
|
||||
|
||||
const callsButton = buttons.find((button) => collectText(button) === '调用趋势');
|
||||
await act(async () => {
|
||||
callsButton!.props.onClick();
|
||||
});
|
||||
|
||||
const updatedButtons = renderer.root.findAll((node) => node.type === 'button' && node.parent?.props['aria-label'] === '站点趋势指标');
|
||||
expect(updatedButtons.map((button) => button.props['aria-pressed'])).toEqual([false, true]);
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
@@ -214,10 +214,13 @@ function MetricToggle({
|
||||
onChange: (m: Metric) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleGroupStyle}>
|
||||
<div style={toggleGroupStyle} role="group" aria-label="站点趋势指标">
|
||||
{METRIC_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
aria-pressed={metric === opt.key}
|
||||
aria-label={`切换站点趋势指标:${opt.label}`}
|
||||
onClick={() => onChange(opt.key)}
|
||||
style={{
|
||||
...toggleBtnBase,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
import DeleteConfirmModal from './DeleteConfirmModal.js';
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('DeleteConfirmModal', () => {
|
||||
it('uses the shared labelled modal shell and enables Escape dismissal', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<DeleteConfirmModal
|
||||
open
|
||||
title="确认删除下游密钥"
|
||||
description={<span>确定要删除密钥 smoke-key 吗?</span>}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(collectText(dialog)).toContain('确认删除下游密钥');
|
||||
expect(collectText(dialog)).toContain('此操作不可撤销');
|
||||
expect(collectText(dialog)).toContain('smoke-key');
|
||||
|
||||
const centeredDialog = root.root.findByType(CenteredModal);
|
||||
expect(centeredDialog.props.closeOnEscape).toBe(true);
|
||||
expect(centeredDialog.props.maxWidth).toBe(560);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables destructive actions while loading', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<DeleteConfirmModal
|
||||
open
|
||||
description="确定删除?"
|
||||
loading
|
||||
onClose={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = root.root.findAllByType('button');
|
||||
const cancelButton = buttons.find((button) => collectText(button) === '取消');
|
||||
const confirmButton = buttons.find((button) => collectText(button).includes('删除中...'));
|
||||
expect(cancelButton?.props.type).toBe('button');
|
||||
expect(confirmButton?.props.type).toBe('button');
|
||||
expect(cancelButton?.props.disabled).toBe(true);
|
||||
expect(confirmButton?.props.disabled).toBe(true);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
|
||||
function keyboardEvent(key: string, shiftKey = false) {
|
||||
return {
|
||||
key,
|
||||
shiftKey,
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function focusableElement(tabIndex = 0) {
|
||||
return {
|
||||
tabIndex,
|
||||
focus: vi.fn(),
|
||||
hasAttribute: vi.fn(() => false),
|
||||
getAttribute: vi.fn(() => null),
|
||||
} as unknown as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function containerElement(elements: HTMLElement[], containsActive = true) {
|
||||
return {
|
||||
focus: vi.fn(),
|
||||
contains: vi.fn(() => containsActive),
|
||||
querySelectorAll: vi.fn(() => elements),
|
||||
} as unknown as HTMLElement & {
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
contains: ReturnType<typeof vi.fn>;
|
||||
querySelectorAll: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('focus trap helper', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubHTMLElement() {
|
||||
class MockHTMLElement {}
|
||||
vi.stubGlobal('HTMLElement', MockHTMLElement);
|
||||
return MockHTMLElement;
|
||||
}
|
||||
|
||||
function stubDocumentActiveElement(activeElement: Element | null) {
|
||||
vi.stubGlobal('document', {
|
||||
activeElement,
|
||||
});
|
||||
}
|
||||
|
||||
it('wraps Tab from the last focusable element to the first', () => {
|
||||
const MockHTMLElement = stubHTMLElement();
|
||||
const first = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const last = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const container = containerElement([first, last]);
|
||||
stubDocumentActiveElement(last);
|
||||
|
||||
const event = keyboardEvent('Tab');
|
||||
trapFocusInContainer(event, container);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(first.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it('wraps Shift+Tab from the first focusable element to the last', () => {
|
||||
const MockHTMLElement = stubHTMLElement();
|
||||
const first = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const last = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const container = containerElement([first, last]);
|
||||
stubDocumentActiveElement(first);
|
||||
|
||||
const event = keyboardEvent('Tab', true);
|
||||
trapFocusInContainer(event, container);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(last.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it('returns focus to the container when no child control is focusable', () => {
|
||||
stubHTMLElement();
|
||||
const container = containerElement([]);
|
||||
|
||||
const event = keyboardEvent('Tab');
|
||||
trapFocusInContainer(event, container);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(container.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it('ignores non-Tab keyboard events', () => {
|
||||
const MockHTMLElement = stubHTMLElement();
|
||||
const first = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const container = containerElement([first]);
|
||||
|
||||
const event = keyboardEvent('Escape');
|
||||
trapFocusInContainer(event, container);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(first.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
function isFocusableElement(element: Element): element is HTMLElement {
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
if (element.hasAttribute('disabled')) return false;
|
||||
if (element.getAttribute('aria-hidden') === 'true') return false;
|
||||
return element.tabIndex >= 0;
|
||||
}
|
||||
|
||||
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR))
|
||||
.filter(isFocusableElement);
|
||||
}
|
||||
|
||||
export function trapFocusInContainer(
|
||||
event: KeyboardEvent,
|
||||
container: HTMLElement | null,
|
||||
) {
|
||||
if (event.key !== 'Tab' || !container) return;
|
||||
|
||||
const focusableElements = getFocusableElements(container);
|
||||
if (focusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
container.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === firstElement || !container.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
lastElement.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeElement === lastElement || !container.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
firstElement.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ describe('MobileBatchBar', () => {
|
||||
|
||||
expect(text).toContain('已选 2 项');
|
||||
expect(text).toContain('批量删除');
|
||||
expect(root.root.find((node) => node.props?.className === 'mobile-actions-bar mobile-batch-bar')).toBeTruthy();
|
||||
const bar = root.root.find((node) => String(node.props?.className || '').includes('mobile-batch-bar'));
|
||||
expect(bar.props.className).toContain('responsive-batch-action-bar');
|
||||
const info = root.root.find((node) => node.props.className === 'mobile-actions-info');
|
||||
expect(info.props.role).toBe('status');
|
||||
expect(info.props['aria-live']).toBe('polite');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
@@ -9,10 +9,28 @@ vi.mock('react-dom', () => ({
|
||||
}));
|
||||
|
||||
describe('MobileDrawer', () => {
|
||||
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
class MockHTMLElement {
|
||||
isConnected = true;
|
||||
focus = vi.fn();
|
||||
}
|
||||
activeElementMock = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
vi.stubGlobal('HTMLElement', MockHTMLElement);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders content, locks body scroll, and exposes explicit close affordances', async () => {
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: activeElementMock,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
@@ -40,6 +58,11 @@ describe('MobileDrawer', () => {
|
||||
expect(text).toContain('导航菜单');
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(dialog.props['aria-hidden']).toBe(false);
|
||||
expect(dialog.props.tabIndex).toBe(-1);
|
||||
|
||||
const backdrop = root.root.find((node) => node.props.className === 'mobile-drawer-backdrop');
|
||||
expect(backdrop.type).toBe('div');
|
||||
|
||||
@@ -59,10 +82,108 @@ describe('MobileDrawer', () => {
|
||||
});
|
||||
}
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
vi.unstubAllGlobals();
|
||||
expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows callers to provide a stable dialog id for trigger ownership', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: activeElementMock,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
},
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MobileDrawer open onClose={() => {}} title="筛选条件" panelId="filters-dialog">
|
||||
<div>FilterContent</div>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props.id).toBe('filters-dialog');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the drawer mounted during the closing animation', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: activeElementMock,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
},
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MobileDrawer open onClose={() => {}} title="导航菜单">
|
||||
<div>DrawerContent</div>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MobileDrawer open={false} onClose={() => {}} title="导航菜单">
|
||||
<div>DrawerContent</div>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(root.root.findByProps({ className: 'mobile-drawer-root is-closing' })).toBeTruthy();
|
||||
expect(root.root.findByProps({ className: 'mobile-drawer-backdrop' })).toBeTruthy();
|
||||
const closingDialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(closingDialog.props['aria-hidden']).toBe(true);
|
||||
const closeButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props.className === 'mobile-drawer-close'
|
||||
));
|
||||
expect(closeButton.props.tabIndex).toBe(-1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(279);
|
||||
});
|
||||
expect(root.root.findByProps({ className: 'mobile-drawer-root is-closing' })).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(root.toJSON()).toBeNull();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the shared animated visibility primitive for drawer presence', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/MobileDrawer.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import { useAnimatedVisibility } from './useAnimatedVisibility.js';");
|
||||
expect(source).toContain('const presence = useAnimatedVisibility(open, 280);');
|
||||
expect(source).toContain("presence.isVisible ? '' : 'is-closing'");
|
||||
expect(source).toContain('const panelRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(source).toContain('ref={panelRef}');
|
||||
expect(source).toContain('tabIndex={-1}');
|
||||
expect(source).toContain('panel.focus({ preventScroll: true });');
|
||||
expect(source).toContain('lastActiveElementRef.current?.focus({ preventScroll: true });');
|
||||
expect(source).toContain("import { trapFocusInContainer } from './focusTrap.js';");
|
||||
expect(source).toContain('trapFocusInContainer(event, panelRef.current);');
|
||||
});
|
||||
|
||||
it('defines independent right-side drawer animations in shared css', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('MobileFilterSheet', () => {
|
||||
|
||||
try {
|
||||
const root = create(
|
||||
<MobileFilterSheet open onClose={() => {}} title="筛选条件">
|
||||
<MobileFilterSheet open onClose={() => {}} title="筛选条件" panelId="mobile-filters">
|
||||
<div>FilterContent</div>
|
||||
</MobileFilterSheet>,
|
||||
);
|
||||
@@ -33,6 +33,7 @@ describe('MobileFilterSheet', () => {
|
||||
expect(text).toContain('筛选条件');
|
||||
expect(text).toContain('FilterContent');
|
||||
expect(root.root.find((node) => node.props?.className === 'mobile-filter-panel')).toBeTruthy();
|
||||
expect(root.root.find((node) => node.props?.role === 'dialog').props.id).toBe('mobile-filters');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isValidElement } from 'react';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import NotificationPanel from './NotificationPanel.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getEvents: vi.fn(),
|
||||
markAllEventsRead: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
getAnnouncements: vi.fn(),
|
||||
markAllAnnouncementsRead: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: {
|
||||
getEvents: apiMock.getEvents,
|
||||
markAllEventsRead: apiMock.markAllEventsRead,
|
||||
clearEvents: apiMock.clearEvents,
|
||||
},
|
||||
userApi: {
|
||||
getAnnouncements: apiMock.getAnnouncements,
|
||||
markAllAnnouncementsRead: apiMock.markAllAnnouncementsRead,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../i18n.js', () => ({
|
||||
useI18n: () => ({
|
||||
t: (value: string) => value,
|
||||
}),
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function collectReactNodeText(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(collectReactNodeText).join('');
|
||||
}
|
||||
if (isValidElement<{ children?: unknown }>(value)) {
|
||||
return collectReactNodeText(value.props.children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
|
||||
}
|
||||
|
||||
function findEventList(root: ReactTestInstance): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.props['aria-live'] === 'polite'
|
||||
&& Object.prototype.hasOwnProperty.call(node.props, 'aria-busy')
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('NotificationPanel', () => {
|
||||
let listeners!: Map<string, EventListener[]>;
|
||||
let anchorRef!: { current: HTMLButtonElement | null };
|
||||
let focusedNodes!: Array<{ text: string; options?: FocusOptions }>;
|
||||
let scrolledNodes!: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
focusedNodes = [];
|
||||
scrolledNodes = [];
|
||||
listeners = new Map();
|
||||
vi.stubGlobal('document', {
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
listeners.set(type, [...(listeners.get(type) || []), listener]);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
listeners.set(type, (listeners.get(type) || []).filter((item) => item !== listener));
|
||||
}),
|
||||
});
|
||||
anchorRef = { current: null };
|
||||
apiMock.markAllEventsRead.mockResolvedValue({ success: true });
|
||||
apiMock.clearEvents.mockResolvedValue({ success: true });
|
||||
apiMock.getAnnouncements.mockResolvedValue([]);
|
||||
apiMock.markAllAnnouncementsRead.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders as a labelled dialog when opened', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([]);
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
panelId="notification-panel-test-id"
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props.id).toBe('notification-panel-test-id');
|
||||
expect(dialog.props['aria-label']).toBe('通知');
|
||||
expect(dialog.props['aria-hidden']).toBe(false);
|
||||
expect(dialog.props.tabIndex).toBe(-1);
|
||||
const eventList = findEventList(root.root);
|
||||
expect(eventList.props.role).toBe('list');
|
||||
expect(eventList.props['aria-busy']).toBe(false);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes the active notification filter as a pressed button', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([]);
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const allFilter = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 全部'
|
||||
));
|
||||
const proxyFilter = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 代理'
|
||||
));
|
||||
expect(allFilter.props['aria-pressed']).toBe(true);
|
||||
expect(proxyFilter.props['aria-pressed']).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
proxyFilter.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const pressedProxyFilter = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 代理'
|
||||
));
|
||||
const releasedAllFilter = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 全部'
|
||||
));
|
||||
expect(pressedProxyFilter.props['aria-pressed']).toBe(true);
|
||||
expect(releasedAllFilter.props['aria-pressed']).toBe(false);
|
||||
expect(apiMock.getEvents).toHaveBeenLastCalledWith('type=proxy');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('marks unread events as read and navigates when an event is opened', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUnreadCountChange = vi.fn();
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 11,
|
||||
type: 'proxy',
|
||||
level: 'warning',
|
||||
title: 'Slow proxy request',
|
||||
message: 'Latency spike',
|
||||
createdAt: '2026-06-07T00:00:00Z',
|
||||
read: false,
|
||||
relatedType: 'proxy_log',
|
||||
relatedId: 101,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={onClose}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getEvents).toHaveBeenCalledWith('');
|
||||
expect(apiMock.markAllEventsRead).toHaveBeenCalledTimes(1);
|
||||
expect(onUnreadCountChange).toHaveBeenCalledWith(0);
|
||||
expect(collectText(root.root)).toContain('Slow proxy request');
|
||||
expect(collectText(root.root)).toContain('查看日志');
|
||||
expect(collectText(root.root)).toContain('打开这条代理请求');
|
||||
expect(findEventList(root.root).props['aria-busy']).toBe(false);
|
||||
|
||||
const item = root.root.find((node) => (
|
||||
String(node.props.className || '').includes('notification-event-item')
|
||||
));
|
||||
expect(item.props.role).toBe('button');
|
||||
expect(item.props.tabIndex).toBe(0);
|
||||
const preventDefault = vi.fn();
|
||||
await act(async () => {
|
||||
item.props.onKeyDown({
|
||||
key: ' ',
|
||||
preventDefault,
|
||||
});
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
const locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterClick)).toBe('/logs?logId=101');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('uses user announcements instead of admin events for user notifications', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUnreadCountChange = vi.fn();
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 500,
|
||||
type: 'runtime',
|
||||
level: 'info',
|
||||
title: 'Admin runtime event',
|
||||
message: 'Must not leak to users',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
apiMock.getAnnouncements.mockResolvedValue([
|
||||
{
|
||||
id: 7,
|
||||
title: '普通用户公告',
|
||||
content: '只显示用户公告',
|
||||
level: 'warning',
|
||||
publishedAt: '2026-06-09T08:00:00Z',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/dashboard']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={onClose}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
audience="user"
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getAnnouncements).toHaveBeenCalledWith('limit=50');
|
||||
expect(apiMock.getEvents).not.toHaveBeenCalled();
|
||||
expect(apiMock.markAllAnnouncementsRead).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.markAllEventsRead).not.toHaveBeenCalled();
|
||||
expect(onUnreadCountChange).toHaveBeenCalledWith(0);
|
||||
expect(collectText(root.root)).toContain('普通用户公告');
|
||||
expect(collectText(root.root)).toContain('只显示用户公告');
|
||||
expect(collectText(root.root)).not.toContain('Admin runtime event');
|
||||
expect(root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 代理'
|
||||
))).toHaveLength(0);
|
||||
expect(root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 用户公告'
|
||||
)).props['aria-pressed']).toBe(false);
|
||||
|
||||
const item = root.root.find((node) => (
|
||||
String(node.props.className || '').includes('notification-event-item')
|
||||
));
|
||||
await act(async () => {
|
||||
item.props.onClick();
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
const locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterClick)).toBe('/user/announcements');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('shows diagnostic actions for notification targets without triggering the row open handler', async () => {
|
||||
const onClose = vi.fn();
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 14,
|
||||
type: 'proxy',
|
||||
level: 'warning',
|
||||
title: 'Proxy failed',
|
||||
message: 'Open the exact log',
|
||||
createdAt: '2026-06-07T00:00:00Z',
|
||||
read: true,
|
||||
relatedType: 'proxy_log',
|
||||
relatedId: 101,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={onClose}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const logAction = root.root.find((node) => (
|
||||
node.type === 'a'
|
||||
&& collectText(node).trim() === '查看日志'
|
||||
));
|
||||
expect(logAction.props.href).toBe('/logs?logId=101');
|
||||
expect(logAction.props.tabIndex).toBeUndefined();
|
||||
|
||||
const stopPropagation = vi.fn();
|
||||
await act(async () => {
|
||||
logAction.props.onClick({ stopPropagation });
|
||||
});
|
||||
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
const locationAfterAction = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterAction)).toBe('/');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('supports arrow key navigation between notification items', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 31,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'First event',
|
||||
message: 'First message',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: 32,
|
||||
type: 'proxy',
|
||||
level: 'warning',
|
||||
title: 'Second event',
|
||||
message: 'Second message',
|
||||
read: true,
|
||||
relatedType: 'route',
|
||||
relatedId: 77,
|
||||
},
|
||||
]);
|
||||
const onClose = vi.fn();
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel open onClose={onClose} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (
|
||||
typeof element.type === 'string'
|
||||
&& String(element.props.className || '').includes('notification-event-item')
|
||||
) {
|
||||
return {
|
||||
focus: (options?: FocusOptions) => {
|
||||
focusedNodes.push({ text: collectReactNodeText(element.props.children), options });
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
scrolledNodes.push(collectReactNodeText(element.props.children));
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
await act(async () => {
|
||||
dialog.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
let items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(String(items[0]?.props.className)).toContain('is-active');
|
||||
expect(items[0]?.props['aria-current']).toBe('true');
|
||||
expect(scrolledNodes.at(-1)).toContain('First event');
|
||||
expect(focusedNodes.at(-1)).toEqual({
|
||||
text: expect.stringContaining('First event'),
|
||||
options: { preventScroll: true },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
dialog.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(String(items[1]?.props.className)).toContain('is-active');
|
||||
expect(items[1]?.props['aria-current']).toBe('true');
|
||||
expect(scrolledNodes.at(-1)).toContain('Second event');
|
||||
expect(focusedNodes.at(-1)).toEqual({
|
||||
text: expect.stringContaining('Second event'),
|
||||
options: { preventScroll: true },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
items[1]!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
const locationAfterEnter = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterEnter)).toBe('/routes?routeId=77');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps active notification keyboard affordances wired in source', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/NotificationPanel.tsx'), 'utf8');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(source).toContain('const activeEvent = eventRefs.current[activeEventIndex];');
|
||||
expect(source).toContain('activeEvent?.scrollIntoView({ block: \'nearest\' });');
|
||||
expect(source).toContain('activeEvent?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('focusActiveEventRef.current = true;');
|
||||
expect(source).toContain('focusActiveEventRef.current = false;');
|
||||
expect(source).toContain('tabIndex={-1}');
|
||||
expect(source).toContain("className={`notification-event-item ${active ? 'is-active' : ''}`.trim()}");
|
||||
expect(source).toContain("aria-current={active ? 'true' : undefined}");
|
||||
expect(css).toContain('.notification-event-item.is-active');
|
||||
});
|
||||
|
||||
it('keeps the notification flyout bounded on narrow mobile viewports', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/NotificationPanel.tsx'), 'utf8');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(source).toContain('className={`user-dropdown notification-panel ${presence.isVisible ? \'\' : \'is-closing\'}`.trim()}');
|
||||
expect(source).toContain('className="notification-panel-list"');
|
||||
expect(source).not.toContain('width: 360');
|
||||
expect(source).not.toContain('maxHeight: 480');
|
||||
expect(source).not.toContain('maxHeight: 360');
|
||||
expect(css).toContain('.notification-panel {\n width: min(360px, calc(100vw - 24px));');
|
||||
expect(css).toContain('max-height: min(480px, calc(100dvh - var(--topbar-height) - 18px));');
|
||||
expect(css).toContain('.notification-panel-list {\n max-height: min(360px, calc(100dvh - var(--topbar-height) - 156px));');
|
||||
expect(css).toContain('overscroll-behavior: contain;');
|
||||
expect(css).toContain('.notification-panel {\n right: -8px !important;');
|
||||
expect(css).toContain('max-height: calc(100dvh - var(--topbar-height) - 14px);');
|
||||
});
|
||||
|
||||
it('opens trace and route related events at their focused destinations', async () => {
|
||||
const onClose = vi.fn();
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 21,
|
||||
type: 'proxy',
|
||||
level: 'warning',
|
||||
title: 'Trace captured',
|
||||
message: 'Debug trace detail',
|
||||
read: true,
|
||||
relatedType: 'proxy_debug_trace',
|
||||
relatedId: 701,
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
type: 'proxy',
|
||||
level: 'info',
|
||||
title: 'Route selected',
|
||||
message: 'Route detail',
|
||||
read: true,
|
||||
relatedType: 'route',
|
||||
relatedId: 42,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel open onClose={onClose} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(items).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
items[0]!.props.onClick();
|
||||
});
|
||||
let locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterClick)).toBe('/logs?traceId=701');
|
||||
|
||||
await act(async () => {
|
||||
items[1]!.props.onClick();
|
||||
});
|
||||
locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterClick)).toBe('/routes?routeId=42');
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('closes on Escape and removes key listeners on close', async () => {
|
||||
const onClose = vi.fn();
|
||||
const anchorFocus = vi.fn();
|
||||
anchorRef.current = {
|
||||
isConnected: true,
|
||||
focus: anchorFocus,
|
||||
contains: () => false,
|
||||
} as unknown as HTMLButtonElement;
|
||||
apiMock.getEvents.mockResolvedValue([]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open onClose={onClose} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const keydownListeners = listeners.get('keydown') || [];
|
||||
expect(keydownListeners.length).toBeGreaterThan(0);
|
||||
keydownListeners.at(-1)?.({ key: 'Escape' } as KeyboardEvent);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(anchorFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open={false} onClose={onClose} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(listeners.get('keydown') || []).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('removes interactive children from the tab order while closing', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 41,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Closing event',
|
||||
message: 'Should leave tab order',
|
||||
read: true,
|
||||
relatedType: 'proxy_log',
|
||||
relatedId: 101,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open onClose={() => {}} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open={false} onClose={() => {}} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-hidden']).toBe(true);
|
||||
expect(String(dialog.props.className)).toContain('is-closing');
|
||||
|
||||
const clearButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).trim() === '清空'
|
||||
));
|
||||
expect(clearButton.props.tabIndex).toBe(-1);
|
||||
|
||||
const filterButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props['aria-label'] === 'string'
|
||||
&& node.props['aria-label'].startsWith('通知筛选:')
|
||||
));
|
||||
expect(filterButtons.length).toBeGreaterThan(0);
|
||||
expect(filterButtons.every((button) => button.props.tabIndex === -1)).toBe(true);
|
||||
|
||||
const items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].props.tabIndex).toBe(-1);
|
||||
|
||||
const diagnosticLinks = root.root.findAll((node) => (
|
||||
node.type === 'a'
|
||||
&& String(node.props.className || '').includes('diagnostic-action')
|
||||
));
|
||||
expect(diagnosticLinks.length).toBeGreaterThan(0);
|
||||
expect(diagnosticLinks.every((link) => link.props.tabIndex === -1)).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(160);
|
||||
});
|
||||
|
||||
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores in-flight event loads after the panel closes', async () => {
|
||||
let resolveEvents!: (value: unknown) => void;
|
||||
apiMock.getEvents.mockReturnValue(new Promise((resolve) => {
|
||||
resolveEvents = resolve;
|
||||
}));
|
||||
const onUnreadCountChange = vi.fn();
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
expect(apiMock.getEvents).toHaveBeenCalledWith('');
|
||||
expect(findEventList(root.root).props['aria-busy']).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveEvents([
|
||||
{
|
||||
id: 12,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Late event',
|
||||
message: 'Should not render',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(JSON.stringify(root.toJSON())).not.toContain('Late event');
|
||||
expect(apiMock.markAllEventsRead).not.toHaveBeenCalled();
|
||||
expect(onUnreadCountChange).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not rewrite closed panel state after clearing events resolves late', async () => {
|
||||
let resolveClear!: (value: unknown) => void;
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 13,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Clear me',
|
||||
message: 'Will be cleared',
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
apiMock.clearEvents.mockReturnValue(new Promise((resolve) => {
|
||||
resolveClear = resolve;
|
||||
}));
|
||||
const onUnreadCountChange = vi.fn();
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const clearButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).trim() === '清空'
|
||||
));
|
||||
expect(clearButton.props.type).toBe('button');
|
||||
await act(async () => {
|
||||
clearButton.props.onClick();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveClear({ success: true });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onUnreadCountChange).not.toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not steal focus when pointer hover only previews a notification item', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 51,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Hover event',
|
||||
message: 'Preview without focus',
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open onClose={() => {}} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (
|
||||
typeof element.type === 'string'
|
||||
&& String(element.props.className || '').includes('notification-event-item')
|
||||
) {
|
||||
return {
|
||||
focus: (options?: FocusOptions) => {
|
||||
focusedNodes.push({ text: collectReactNodeText(element.props.children), options });
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
scrolledNodes.push(collectReactNodeText(element.props.children));
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const item = root.root.find((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
await act(async () => {
|
||||
item.props.onMouseEnter();
|
||||
});
|
||||
|
||||
const hoveredItem = root.root.find((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(String(hoveredItem.props.className)).toContain('is-active');
|
||||
expect(scrolledNodes.at(-1)).toContain('Hover event');
|
||||
expect(focusedNodes).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('moves focus when keyboard navigation confirms an item already previewed by hover', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 61,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Only event',
|
||||
message: 'Hover then keyboard',
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open onClose={() => {}} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (
|
||||
typeof element.type === 'string'
|
||||
&& String(element.props.className || '').includes('notification-event-item')
|
||||
) {
|
||||
return {
|
||||
focus: (options?: FocusOptions) => {
|
||||
focusedNodes.push({ text: collectReactNodeText(element.props.children), options });
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
scrolledNodes.push(collectReactNodeText(element.props.children));
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const item = root.root.find((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
await act(async () => {
|
||||
item.props.onMouseEnter();
|
||||
});
|
||||
expect(focusedNodes).toHaveLength(0);
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
await act(async () => {
|
||||
dialog.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
expect(focusedNodes.at(-1)).toEqual({
|
||||
text: expect.stringContaining('Only event'),
|
||||
options: { preventScroll: true },
|
||||
});
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { create } from 'react-test-renderer';
|
||||
import ResponsiveFilterPanel from './ResponsiveFilterPanel.js';
|
||||
|
||||
vi.mock('react-dom', () => ({
|
||||
createPortal: (node: unknown) => node,
|
||||
}));
|
||||
|
||||
describe('ResponsiveFilterPanel', () => {
|
||||
it('links the mobile filter trigger to the sheet dialog', () => {
|
||||
const onOpen = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: null,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
},
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
const root = create(
|
||||
<ResponsiveFilterPanel
|
||||
isMobile
|
||||
mobileOpen
|
||||
onMobileOpen={onOpen}
|
||||
onMobileClose={onClose}
|
||||
mobileTitle="筛选条件"
|
||||
mobileContent={<div>MobileFilters</div>}
|
||||
desktopContent={<div>DesktopFilters</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = root.root.find((node) => node.type === 'button' && node.children.includes('筛选'));
|
||||
expect(trigger.props['aria-haspopup']).toBe('dialog');
|
||||
expect(trigger.props['aria-expanded']).toBe(true);
|
||||
expect(trigger.props['aria-controls']).toBeTruthy();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props.id).toBe(trigger.props['aria-controls']);
|
||||
expect(root.root.findAllByProps({ children: 'DesktopFilters' })).toHaveLength(0);
|
||||
|
||||
trigger.props.onClick();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders only the desktop content outside mobile layouts', () => {
|
||||
const root = create(
|
||||
<ResponsiveFilterPanel
|
||||
isMobile={false}
|
||||
mobileOpen={false}
|
||||
onMobileClose={() => {}}
|
||||
mobileTitle="筛选条件"
|
||||
mobileContent={<div>MobileFilters</div>}
|
||||
desktopContent={<div>DesktopFilters</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = root.root.findAll(() => true)
|
||||
.flatMap((instance) => instance.children)
|
||||
.filter((child): child is string => typeof child === 'string')
|
||||
.join('');
|
||||
|
||||
expect(text).toContain('DesktopFilters');
|
||||
expect(text).not.toContain('MobileFilters');
|
||||
expect(root.root.findAll((node) => node.type === 'button')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isValidElement } from 'react';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import SearchModal from './SearchModal.js';
|
||||
@@ -27,11 +30,31 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function collectReactNodeText(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(collectReactNodeText).join('');
|
||||
}
|
||||
if (isValidElement<{ children?: unknown }>(value)) {
|
||||
return collectReactNodeText(value.props.children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
|
||||
}
|
||||
|
||||
function findResultsRegion(root: ReactTestInstance): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.props['aria-live'] === 'polite'
|
||||
&& Object.prototype.hasOwnProperty.call(node.props, 'aria-busy')
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -40,16 +63,35 @@ async function flushMicrotasks() {
|
||||
}
|
||||
|
||||
describe('SearchModal results', () => {
|
||||
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
let documentBodyStyle!: { overflow: string };
|
||||
let focusedResults!: Array<{ text: string; options?: FocusOptions }>;
|
||||
let scrolledResults!: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
focusedResults = [];
|
||||
scrolledResults = [];
|
||||
class MockHTMLElement {
|
||||
focus = vi.fn();
|
||||
}
|
||||
activeElementMock = new MockHTMLElement() as unknown as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: MockHTMLElement,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: globalThis,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
documentBodyStyle = { overflow: 'auto' };
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: {
|
||||
activeElement: activeElementMock,
|
||||
body: { style: documentBodyStyle },
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
},
|
||||
@@ -64,6 +106,122 @@ describe('SearchModal results', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders as a labelled dialog and restores focus after closing', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} dialogId="global-search-dialog-test" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props.id).toBe('global-search-dialog-test');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(typeof dialog.props['aria-labelledby']).toBe('string');
|
||||
expect(dialog.props['aria-hidden']).toBe(false);
|
||||
expect(collectText(dialog)).toContain('全局搜索');
|
||||
const resultsRegion = findResultsRegion(root.root);
|
||||
expect(resultsRegion.props.role).toBe('listbox');
|
||||
expect(resultsRegion.props['aria-hidden']).toBe(false);
|
||||
expect(resultsRegion.props['aria-busy']).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('locks body scrolling while open and restores it after close', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(documentBodyStyle.overflow).toBe('hidden');
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(documentBodyStyle.overflow).toBe('auto');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders safely when document.body is unavailable', async () => {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: {
|
||||
activeElement: activeElementMock,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(root.toJSON()).toBeTruthy();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders account token results and navigates API key accounts to the apikey segment', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [],
|
||||
@@ -119,6 +277,13 @@ describe('SearchModal results', () => {
|
||||
const buttons = root.root.findAll((node) => node.type === 'button');
|
||||
const accountButton = buttons.find((node) => collectText(node).includes('API Key 连接'));
|
||||
expect(accountButton).toBeTruthy();
|
||||
expect(accountButton!.props.type).toBe('button');
|
||||
|
||||
const resultButtons = buttons.filter((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(resultButtons.length).toBeGreaterThan(0);
|
||||
expect(resultButtons.every((node) => node.props.type === 'button')).toBe(true);
|
||||
expect(resultButtons.every((node) => node.props.tabIndex === 0)).toBe(true);
|
||||
expect(findResultsRegion(root.root).props['aria-busy']).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
accountButton!.props.onClick();
|
||||
@@ -129,4 +294,357 @@ describe('SearchModal results', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports arrow key navigation and Enter activation from the search field', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'gpt-search', accountCount: 1, tokenCount: 2, siteCount: 3 },
|
||||
],
|
||||
sites: [
|
||||
{ id: 21, name: 'Search Site', url: 'https://search.example.com' },
|
||||
],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<SearchModal open onClose={onClose} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
let input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'gpt' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
input = root.root.findByType('input');
|
||||
expect(input.props.role).toBe('combobox');
|
||||
expect(input.props['aria-expanded']).toBe(true);
|
||||
expect(input.props['aria-controls']).toBeTruthy();
|
||||
expect(input.props['aria-activedescendant']).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
input = root.root.findByType('input');
|
||||
const firstActiveId = input.props['aria-activedescendant'];
|
||||
expect(firstActiveId).toBeTruthy();
|
||||
let resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(resultButtons[0]?.props.id).toBe(firstActiveId);
|
||||
expect(resultButtons[0]?.props.role).toBe('option');
|
||||
expect(resultButtons[0]?.props['aria-selected']).toBe(true);
|
||||
expect(String(resultButtons[0]?.props.className)).toContain('is-active');
|
||||
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
input = root.root.findByType('input');
|
||||
const secondActiveId = input.props['aria-activedescendant'];
|
||||
resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(resultButtons[1]?.props.id).toBe(secondActiveId);
|
||||
expect(String(resultButtons[1]?.props.className)).toContain('is-active');
|
||||
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
const locationAfterEnter = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterEnter)).toBe('/sites?focusSiteId=21');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports keyboard navigation and activation after focus moves into result items', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'keyboard-model', accountCount: 1, tokenCount: 2, siteCount: 3 },
|
||||
],
|
||||
sites: [
|
||||
{ id: 31, name: 'Keyboard Site', url: 'https://keyboard.example.com' },
|
||||
],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<SearchModal open onClose={onClose} />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (
|
||||
typeof element.type === 'string'
|
||||
&& String(element.props.className || '').includes('search-result-item')
|
||||
) {
|
||||
return {
|
||||
focus: (options?: FocusOptions) => {
|
||||
focusedResults.push({ text: collectReactNodeText(element.props.children), options });
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
scrolledResults.push(collectReactNodeText(element.props.children));
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'keyboard' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
let resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(resultButtons).toHaveLength(2);
|
||||
expect(resultButtons.every((node) => typeof node.props.onKeyDown === 'function')).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resultButtons[0]!.props.onMouseEnter();
|
||||
});
|
||||
expect(scrolledResults.at(-1)).toContain('keyboard-model');
|
||||
expect(focusedResults).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
resultButtons[0]!.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(String(resultButtons[1]?.props.className)).toContain('is-active');
|
||||
expect(resultButtons[1]?.props['aria-selected']).toBe(true);
|
||||
expect(scrolledResults.at(-1)).toContain('Keyboard Site');
|
||||
expect(focusedResults.at(-1)).toEqual({
|
||||
text: expect.stringContaining('Keyboard Site'),
|
||||
options: { preventScroll: true },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resultButtons[1]!.props.onKeyDown({ key: ' ', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
const locationAfterSpace = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterSpace)).toBe('/sites?focusSiteId=31');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps keyboard navigation wired to active result scrolling', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/SearchModal.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('const activeResult = resultRefs.current[activeResultIndex];');
|
||||
expect(source).toContain("activeResult?.scrollIntoView({ block: 'nearest' });");
|
||||
expect(source).toContain('activeResult?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('const moveActiveResult = (direction: 1 | -1, shouldFocusResult: boolean) => {');
|
||||
expect(source).toContain('moveActiveResult(event.key === \'ArrowDown\' ? 1 : -1, true);');
|
||||
expect(source).toContain('moveActiveResult(event.key === \'ArrowDown\' ? 1 : -1, false);');
|
||||
expect(source).toContain('role="combobox"');
|
||||
expect(source).toContain('role="listbox"');
|
||||
expect(source).toContain("import { trapFocusInContainer } from './focusTrap.js';");
|
||||
expect(source).toContain('const dialogRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(source).toContain('ref={dialogRef}');
|
||||
expect(source).toContain("if (typeof document === 'undefined') return undefined;");
|
||||
expect(source).toContain('trapFocusInContainer(e, dialogRef.current);');
|
||||
expect(source).toContain("role: 'option'");
|
||||
expect(source).toContain("'aria-selected': activeResultIndex === index");
|
||||
});
|
||||
|
||||
it('keeps the global search dialog bounded on mobile viewports', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/SearchModal.tsx'), 'utf8');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(source).toContain('className={`modal-content search-modal ${presence.isVisible ? \'\' : \'is-closing\'}`.trim()}');
|
||||
expect(source).toContain('className="search-modal-header"');
|
||||
expect(source).toContain('className="search-modal-results"');
|
||||
expect(source).toContain('className="search-modal-footer"');
|
||||
expect(source).not.toContain('style={{ maxWidth: 560, padding: 0 }}');
|
||||
expect(source).not.toContain('maxHeight: 400');
|
||||
expect(css).toContain('.search-modal {\n width: min(92vw, 560px);');
|
||||
expect(css).toContain('.search-modal-results {\n max-height: min(400px, calc(100dvh - var(--topbar-height) - 220px));');
|
||||
expect(css).toContain('overscroll-behavior: contain;');
|
||||
expect(css).toContain('.search-modal {\n width: calc(100vw - 24px);');
|
||||
expect(css).toContain('.search-modal-results {\n max-height: calc(100dvh - 190px);');
|
||||
expect(css).toContain('.search-modal-footer {\n flex-wrap: wrap;');
|
||||
});
|
||||
|
||||
it('cancels pending debounced searches when the modal closes', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [{ name: 'gpt-hidden', accountCount: 0, tokenCount: 0, siteCount: 0 }],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'hidden' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.search).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(root.toJSON())).not.toContain('gpt-hidden');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes search input and results from the tab order while closing', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [{ name: 'gpt-closing', accountCount: 1, tokenCount: 1, siteCount: 1 }],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'closing' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'))).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const closingDialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(closingDialog.props['aria-hidden']).toBe(true);
|
||||
expect(String(closingDialog.props.className)).toContain('is-closing');
|
||||
expect(root.root.findByType('input').props.tabIndex).toBe(-1);
|
||||
expect(findResultsRegion(root.root).props['aria-hidden']).toBe(true);
|
||||
const closingResultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(closingResultButtons).toHaveLength(1);
|
||||
expect(closingResultButtons[0].props.tabIndex).toBe(-1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(180);
|
||||
});
|
||||
|
||||
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores in-flight search results after the modal closes', async () => {
|
||||
let resolveSearch!: (value: unknown) => void;
|
||||
apiMock.search.mockReturnValue(new Promise((resolve) => {
|
||||
resolveSearch = resolve;
|
||||
}));
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'late' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.search).toHaveBeenCalledWith('late');
|
||||
expect(findResultsRegion(root.root).props['aria-busy']).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveSearch({
|
||||
models: [{ name: 'late-model', accountCount: 0, tokenCount: 0, siteCount: 0 }],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(JSON.stringify(root.toJSON())).not.toContain('late-model');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Toast accessibility architecture', () => {
|
||||
it('keeps toast announcements semantic and dismissible without relying on a div click target', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/Toast.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('const TOAST_DURATION_MS = 3200;');
|
||||
expect(source).toContain("role={t.type === 'error' ? 'alert' : 'status'}");
|
||||
expect(source).toContain("aria-live={t.type === 'error' ? 'assertive' : 'polite'}");
|
||||
expect(source).toContain('aria-atomic="true"');
|
||||
expect(source).toContain('className="toast-dismiss"');
|
||||
expect(source).toContain('aria-label={`关闭通知:${t.message}`}');
|
||||
expect(css).toContain('.toast-dismiss:focus-visible {');
|
||||
expect(css).toContain('animation: progress-bar var(--toast-duration, 3200ms) linear both;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('topbar overlay keyboard focus styles', () => {
|
||||
it('keeps focus-visible feedback for searchable and notification rows', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.search-result-item:hover,\n.search-result-item.is-active,\n.search-result-item:focus-visible');
|
||||
expect(css).toContain('.search-result-item:focus-visible,\n.notification-event-item:focus-visible');
|
||||
expect(css).toContain('.notification-event-item:hover,\n.notification-event-item.is-active,\n.notification-event-item:focus-visible');
|
||||
});
|
||||
|
||||
it('keeps focus-visible feedback for keyboard-expandable route cards', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.route-card-collapsed:hover,\n.route-card-collapsed:focus-visible');
|
||||
expect(css).toContain('.route-card-collapsed:focus-visible');
|
||||
});
|
||||
|
||||
it('keeps focus-visible feedback for keyboard-selectable table rows', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.data-table tbody tr.row-selectable:hover,\n.data-table tbody tr.row-selectable:focus-visible');
|
||||
expect(css).toContain('[data-theme="dark"] .data-table tbody tr.row-selectable:hover,\n[data-theme="dark"] .data-table tbody tr.row-selectable:focus-visible');
|
||||
expect(css).toContain('.data-table tbody tr.row-selectable:focus-visible');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
function PresenceProbe({ visible, durationMs = 220 }: { visible: boolean; durationMs?: number }) {
|
||||
const presence = useAnimatedVisibility(visible, durationMs);
|
||||
return (
|
||||
<output>
|
||||
{presence.shouldRender ? 'rendered' : 'unmounted'}
|
||||
{'/'}
|
||||
{presence.isVisible ? 'visible' : 'hidden'}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
function installWindowMock(reducedMotion: boolean) {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: vi.fn((query: string) => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)' ? reducedMotion : false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
onchange: null,
|
||||
})),
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
},
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function renderedText(root: WebTestRenderer): string {
|
||||
return root.root.findByType('output').children.join('');
|
||||
}
|
||||
|
||||
describe('useAnimatedVisibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps content mounted until the exit animation duration completes', async () => {
|
||||
installWindowMock(false);
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<PresenceProbe visible durationMs={180} />);
|
||||
});
|
||||
expect(renderedText(root)).toBe('rendered/visible');
|
||||
|
||||
await act(async () => {
|
||||
root.update(<PresenceProbe visible={false} durationMs={180} />);
|
||||
});
|
||||
expect(renderedText(root)).toBe('rendered/hidden');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(179);
|
||||
});
|
||||
expect(renderedText(root)).toBe('rendered/hidden');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(renderedText(root)).toBe('unmounted/hidden');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips delayed exit presence when reduced motion is requested', async () => {
|
||||
installWindowMock(true);
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<PresenceProbe visible durationMs={280} />);
|
||||
});
|
||||
expect(renderedText(root)).toBe('rendered/visible');
|
||||
|
||||
await act(async () => {
|
||||
root.update(<PresenceProbe visible={false} durationMs={280} />);
|
||||
});
|
||||
expect(renderedText(root)).toBe('unmounted/hidden');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(280);
|
||||
});
|
||||
expect(renderedText(root)).toBe('unmounted/hidden');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,18 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
|
||||
const [isVisible, setIsVisible] = useState(visible);
|
||||
|
||||
useEffect(() => {
|
||||
const reducedMotion = (
|
||||
typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
if (reducedMotion) {
|
||||
setIsVisible(true);
|
||||
return undefined;
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const rafId = window.requestAnimationFrame(() => setIsVisible(true));
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
@@ -16,7 +26,7 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
|
||||
}
|
||||
|
||||
setIsVisible(false);
|
||||
if (durationMs <= 0) {
|
||||
if (reducedMotion || durationMs <= 0) {
|
||||
setShouldRender(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function readThemeLabelColor(): string | null {
|
||||
function readThemeColor(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
const root = document.documentElement;
|
||||
if (!root || typeof globalThis.getComputedStyle !== 'function') return null;
|
||||
|
||||
const color = globalThis.getComputedStyle(root).getPropertyValue('--color-text-secondary').trim();
|
||||
const color = globalThis.getComputedStyle(root).getPropertyValue(name).trim();
|
||||
return color || null;
|
||||
}
|
||||
|
||||
export function useThemeLabelColor(fallback = '#9ca3af'): string {
|
||||
const [labelColor, setLabelColor] = useState(fallback);
|
||||
export function useThemeColor(name: string, fallback = '#9ca3af'): string {
|
||||
const [themeColor, setThemeColor] = useState(fallback);
|
||||
|
||||
useEffect(() => {
|
||||
const root = typeof document !== 'undefined' ? document.documentElement : null;
|
||||
const read = () => {
|
||||
const color = readThemeLabelColor();
|
||||
if (color) setLabelColor(color);
|
||||
const color = readThemeColor(name);
|
||||
if (color) setThemeColor(color);
|
||||
};
|
||||
|
||||
read();
|
||||
@@ -27,9 +27,13 @@ export function useThemeLabelColor(fallback = '#9ca3af'): string {
|
||||
}
|
||||
|
||||
const observer = new globalThis.MutationObserver(read);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-theme-preset'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [name]);
|
||||
|
||||
return labelColor;
|
||||
return themeColor;
|
||||
}
|
||||
|
||||
export function useThemeLabelColor(fallback = '#9ca3af'): string {
|
||||
return useThemeColor('--color-text-secondary', fallback);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,18 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'成功': 'success',
|
||||
'成功 (${countBy(\'success\')})': 'Success (${countBy(\'success\')})',
|
||||
'成功率': 'success rate',
|
||||
'程序日志': 'Program log',
|
||||
'程序日志': 'System Logs',
|
||||
'加载运行日志失败': 'Failed to load runtime logs',
|
||||
'筛选运行日志': 'Filter runtime logs',
|
||||
'仅看未读运行日志': 'Unread runtime logs only',
|
||||
'当前筛选条件下没有运行日志。': 'No runtime logs match the current filters.',
|
||||
'使用日志、运行日志、通知渠道和运行事件集中留痕。': 'Usage logs, runtime logs, notification channels, and runtime events are recorded centrally.',
|
||||
'清理运行日志': 'Clean runtime logs',
|
||||
'请求日志、运行日志和任务排障集中在这里': 'Request logs, runtime logs, and task triage are centralized here',
|
||||
'聚合请求吞吐、路由健康、站点风险、后台任务和运行日志,作为值班排障的第一屏。': 'Aggregate request throughput, route health, site risk, background tasks, and runtime logs as the first on-call triage screen.',
|
||||
'任务失败,需要进入对应业务页重试或查看运行日志。': 'Task failed. Retry in the related app page or inspect runtime logs.',
|
||||
'查看运行日志': 'View runtime logs',
|
||||
'暂无运行日志': 'No runtime logs',
|
||||
'触发 Cloudflare 验证': 'Trigger Cloudflare verification',
|
||||
'触发测试通知失败': 'Trigger test notification failed',
|
||||
'触发签到失败': 'Failed to trigger sign-in',
|
||||
@@ -229,7 +240,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'集中管理 New API / One API / OneHub / DoneHub / Veloera 等': 'Centralized management New API / One API / OneHub / DoneHub / Veloera, etc.',
|
||||
'计算中...': 'Calculating...',
|
||||
'技术栈': 'technology stack',
|
||||
'加载程序日志失败': 'Loader log failed',
|
||||
'加载程序日志失败': 'Failed to load system logs',
|
||||
'加载更多': 'load more',
|
||||
'加载监控配置失败': 'Failed to load monitoring configuration',
|
||||
'加载令牌失败': 'Failed to load token',
|
||||
@@ -480,8 +491,6 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'收到流式 [DONE] 信号。': 'Streaming [DONE] signal received.',
|
||||
'收件人地址': 'Recipient address',
|
||||
'收起': 'close',
|
||||
'收起侧边栏': 'Collapse sidebar',
|
||||
'展开侧边栏': 'Expand sidebar',
|
||||
'收起高级参数': 'Close advanced parameters',
|
||||
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.',
|
||||
'手动增改路由': 'Manually add and modify routes',
|
||||
|
||||
+129
-2
@@ -1,5 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { translateText } from './i18n.js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { translateDomSubtree, translateText } from './i18n.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
class MockTextNode {
|
||||
readonly nodeType = 3;
|
||||
nodeValue: string;
|
||||
parentElement: MockElementNode | null = null;
|
||||
|
||||
constructor(value: string) {
|
||||
this.nodeValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
class MockElementNode {
|
||||
readonly nodeType = 1;
|
||||
readonly childNodes: Array<MockElementNode | MockTextNode>;
|
||||
readonly isContentEditable = false;
|
||||
parentElement: MockElementNode | null = null;
|
||||
private readonly attrs = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
attrs: Record<string, string> = {},
|
||||
childNodes: Array<MockElementNode | MockTextNode> = [],
|
||||
) {
|
||||
this.childNodes = childNodes;
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
this.attrs.set(key, value);
|
||||
}
|
||||
for (const child of childNodes) {
|
||||
child.parentElement = this;
|
||||
}
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.childNodes
|
||||
.map((child) => child instanceof MockTextNode ? child.nodeValue : child.textContent)
|
||||
.join('');
|
||||
}
|
||||
|
||||
matches() {
|
||||
return false;
|
||||
}
|
||||
|
||||
closest() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getAttribute(attr: string) {
|
||||
return this.attrs.get(attr) ?? null;
|
||||
}
|
||||
|
||||
setAttribute(attr: string, value: string) {
|
||||
this.attrs.set(attr, value);
|
||||
}
|
||||
}
|
||||
|
||||
describe('translateText', () => {
|
||||
it('keeps zh text unchanged in zh mode', () => {
|
||||
@@ -36,6 +93,8 @@ describe('translateText', () => {
|
||||
'签到任务执行中,请稍后查看签到日志',
|
||||
'下游访问令牌至少 6 位(含 sk-)',
|
||||
'路由重建任务执行中,请稍后查看程序日志',
|
||||
'运行日志',
|
||||
'加载运行日志失败',
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
@@ -43,5 +102,73 @@ describe('translateText', () => {
|
||||
expect(translated).not.toBe('Untranslated');
|
||||
expect(translated).not.toMatch(/[\u3400-\u9fff]/);
|
||||
}
|
||||
|
||||
expect(translateText('运行日志', 'en')).toBe('Runtime log');
|
||||
expect(translateText('加载运行日志失败', 'en')).toBe('Failed to load runtime logs');
|
||||
});
|
||||
|
||||
it('uses concrete english translations for dashboard operations text', () => {
|
||||
const samples = [
|
||||
'下午好,管理员',
|
||||
'加载失败',
|
||||
'关键运行信号',
|
||||
'实时运营摘要',
|
||||
'运营战情室',
|
||||
'日志中心',
|
||||
'值班入口',
|
||||
'站点可用性观测',
|
||||
'模型数据分析',
|
||||
'请求日志、运行日志和任务排障集中在这里',
|
||||
'使用日志、运行日志、通知渠道和运行事件集中留痕。',
|
||||
'平台存在明显异常,建议立即进入运维监控检查路由、站点和请求异常。',
|
||||
'站点可用性、平均延迟和失败数都处于可接受范围。',
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
const translated = translateText(sample, 'en');
|
||||
expect(translated).not.toBe('Untranslated');
|
||||
expect(translated).not.toMatch(/[\u3400-\u9fff]/);
|
||||
expect(translated).not.toMatch(/[,。:;!?()【】“”‘’、]/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateDomSubtree', () => {
|
||||
it('restores dashboard text and attributes when switching from English back to Chinese', () => {
|
||||
vi.stubGlobal('Node', { TEXT_NODE: 3, ELEMENT_NODE: 1 });
|
||||
|
||||
const title = new MockElementNode({}, [new MockTextNode('下午好,管理员')]);
|
||||
const button = new MockElementNode(
|
||||
{ title: '查看日志', 'aria-label': '刷新' },
|
||||
[new MockTextNode('刷新')],
|
||||
);
|
||||
const section = new MockElementNode({ 'aria-label': '日志中心' }, [
|
||||
button,
|
||||
]);
|
||||
const root = new MockElementNode({}, [title, section]);
|
||||
|
||||
translateDomSubtree(root as unknown as Node, 'en');
|
||||
|
||||
expect(title.textContent).toBe('Good afternoon, Admin');
|
||||
expect(section.getAttribute('aria-label')).toBe('Log center');
|
||||
expect(button.getAttribute('title')).toBe('View logs');
|
||||
expect(button.getAttribute('aria-label')).toBe('Refresh');
|
||||
expect(button.textContent).toBe('Refresh');
|
||||
|
||||
translateDomSubtree(root as unknown as Node, 'zh');
|
||||
|
||||
expect(title.textContent).toBe('下午好,管理员');
|
||||
expect(section.getAttribute('aria-label')).toBe('日志中心');
|
||||
expect(button.getAttribute('title')).toBe('查看日志');
|
||||
expect(button.getAttribute('aria-label')).toBe('刷新');
|
||||
expect(button.textContent).toBe('刷新');
|
||||
|
||||
translateDomSubtree(root as unknown as Node, 'en');
|
||||
|
||||
expect(title.textContent).toBe('Good afternoon, Admin');
|
||||
expect(section.getAttribute('aria-label')).toBe('Log center');
|
||||
expect(button.getAttribute('title')).toBe('View logs');
|
||||
expect(button.getAttribute('aria-label')).toBe('Refresh');
|
||||
expect(button.textContent).toBe('Refresh');
|
||||
});
|
||||
});
|
||||
|
||||
+403
-84
@@ -19,6 +19,15 @@ const zhToEn: Record<string, string> = {
|
||||
'管理员入口': 'Admin Access',
|
||||
'部署文档': 'Deployment Docs',
|
||||
'管理员登录后继续。': 'Continue with admin sign-in.',
|
||||
'仅允许使用管理员 API Token 进入控制台。': 'Only an admin API token can enter the console.',
|
||||
'管理员身份用于管理站点、模型路由、用户、计费与系统日志。': 'Admin access manages sites, model routes, users, billing, and system logs.',
|
||||
'管理员后台不接受账号密码或用户 API Key 登录。': 'The admin console does not accept account passwords or user API Keys.',
|
||||
'进入控制台': 'Enter Console',
|
||||
'密码': 'Password',
|
||||
'登录响应缺少访问令牌。': 'The login response is missing an access token.',
|
||||
'管理员请使用管理员 API Token 登录。': 'Admins must sign in with the admin API token.',
|
||||
'识别已绑定普通用户的 API Key,进入用户控制台。': 'Recognizes API Keys bound to regular users and opens the user console.',
|
||||
'仅支持普通用户 API Key;管理员请使用管理员 API Token。': 'Only regular user API Keys are supported; admins must use the admin API token.',
|
||||
'仅校验本地服务访问权限,不会把令牌发送到第三方。': 'Only checks local service access and never sends the token to a third party.',
|
||||
'验证中...': 'Verifying...',
|
||||
'登录': 'Sign In',
|
||||
@@ -59,13 +68,24 @@ const zhToEn: Record<string, string> = {
|
||||
'保存': 'Save',
|
||||
'关闭': 'Close',
|
||||
'打开': 'Open',
|
||||
'收起侧边栏': 'Collapse sidebar',
|
||||
'展开侧边栏': 'Expand sidebar',
|
||||
'导航': 'Navigate',
|
||||
'主内容': 'Main content',
|
||||
'系统日志': 'System Logs',
|
||||
'跳到主内容': 'Skip to main content',
|
||||
'重置': 'Reset',
|
||||
'全部': 'All',
|
||||
'清空': 'Clear',
|
||||
'全部已读': 'Mark All Read',
|
||||
'控制台': 'Console',
|
||||
'仪表盘': 'Dashboard',
|
||||
'运维监控': 'Ops Monitor',
|
||||
'运营管理': 'Operations',
|
||||
'接入管理': 'Access Management',
|
||||
'路由管理': 'Route Management',
|
||||
'IP 管理': 'IP Management',
|
||||
'基础设置': 'Basic Settings',
|
||||
'站点': 'Sites',
|
||||
'账号': 'Accounts',
|
||||
'连接管理': 'Connection Management',
|
||||
@@ -94,6 +114,10 @@ const zhToEn: Record<string, string> = {
|
||||
'系统': 'System',
|
||||
'设置': 'Settings',
|
||||
'程序日志': 'System Logs',
|
||||
'加载运行日志失败': 'Failed to load runtime logs',
|
||||
'筛选运行日志': 'Filter runtime logs',
|
||||
'仅看未读运行日志': 'Unread runtime logs only',
|
||||
'当前筛选条件下没有运行日志。': 'No runtime logs match the current filters.',
|
||||
'导入/导出': 'Import/Export',
|
||||
'通知设置': 'Notification Settings',
|
||||
'概览': 'Overview',
|
||||
@@ -138,7 +162,8 @@ const zhToEn: Record<string, string> = {
|
||||
'用户体系': 'User system',
|
||||
'普通用户拥有独立 API Key、使用日志、工作空间和可见模型范围。': 'Regular users have independent API keys, usage logs, workspaces, and visible model scopes.',
|
||||
'审计告警': 'Audit and alerts',
|
||||
'使用日志、程序日志、通知渠道和运行事件集中留痕。': 'Usage logs, program logs, notification channels, and runtime events are recorded centrally.',
|
||||
'使用日志、运行日志、通知渠道和运行事件集中留痕。': 'Usage logs, runtime logs, notification channels, and runtime events are recorded centrally.',
|
||||
'清理运行日志': 'Clean runtime logs',
|
||||
'只归档最终产出,按用户隔离图片、视频、音频和工作流资产。': 'Archive only final outputs, with images, videos, audio, and workflow assets isolated by user.',
|
||||
'任务编排': 'Task orchestration',
|
||||
'后台生成、取消、查询、归档和失败原因追踪保持一致体验。': 'Background generation, cancellation, queries, archiving, and failure tracking stay consistent.',
|
||||
@@ -181,9 +206,15 @@ const zhToEn: Record<string, string> = {
|
||||
'跟随系统': 'Follow System',
|
||||
'浅色模式': 'Light Mode',
|
||||
'深色模式': 'Dark Mode',
|
||||
'色彩模式': 'Color Mode',
|
||||
'主题风格': 'Theme Style',
|
||||
'清透运维': 'ClearOps',
|
||||
'深色终端': 'Terminal',
|
||||
'云端实验室': 'Cloud Studio',
|
||||
'低噪音控制台': 'Low-noise console',
|
||||
'高对比数据模式': 'High-contrast data mode',
|
||||
'轻创作视觉': 'Light creative visual',
|
||||
'退出登录': 'Sign Out',
|
||||
'收起侧边栏': 'Collapse Sidebar',
|
||||
'展开侧边栏': 'Expand Sidebar',
|
||||
'系统设置': 'System Settings',
|
||||
'站点管理': 'Site Management',
|
||||
'账号管理': 'Connection Management',
|
||||
@@ -428,7 +459,40 @@ const zhToEn: Record<string, string> = {
|
||||
'你的昵称': 'Your nickname',
|
||||
'用户中心': 'User Portal',
|
||||
'API 密钥': 'API Keys',
|
||||
'API Key 与计费': 'API Key & Billing',
|
||||
'新建密钥': 'New Key',
|
||||
'充值': 'Recharge',
|
||||
'去充值': 'Recharge',
|
||||
'账户充值': 'Account Recharge',
|
||||
'账户余额': 'Account Balance',
|
||||
'打开支付页': 'Open Payment Page',
|
||||
'为模型和 Comfy API 添加积分': 'Add credits for models and Comfy API',
|
||||
'选择一个额度后会打开支付页': 'Choose an amount to open the payment page',
|
||||
'选择已配置额度后会打开支付页': 'Choose a configured amount to open the payment page',
|
||||
'该额度支付链接未配置': 'Payment link is not configured for this amount',
|
||||
'已配置支付页': 'Configured payment page',
|
||||
'付款方式': 'Payment method',
|
||||
'外部 Checkout': 'External Checkout',
|
||||
'支付链接在新窗口打开,BossAI 不在前端收集银行卡或支付凭证。': 'The payment link opens in a new window. BossAI does not collect card or payment credentials in the frontend.',
|
||||
'额度同步': 'Credit Sync',
|
||||
'付款后返回系统': 'Return after payment',
|
||||
'如果余额没有立刻变化,刷新页面或联系管理员核对付款记录。': 'If the balance does not update immediately, refresh the page or contact an admin to verify the payment record.',
|
||||
'使用范围': 'Usage Scope',
|
||||
'模型与创作任务': 'Models and Creative Tasks',
|
||||
'充值额度': 'Recharge amount',
|
||||
'充值说明': 'Recharge Notes',
|
||||
'返回 API Key 与计费': 'Back to API Key & Billing',
|
||||
'轻量试用': 'Light trial',
|
||||
'日常调用': 'Daily use',
|
||||
'创作加量': 'Creator boost',
|
||||
'团队高频': 'Team scale',
|
||||
'添加积分后继续调用模型与 Comfy API': 'Add credits to keep using models and the Comfy API',
|
||||
'进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。': 'Confirm the amount on the in-system recharge page, then continue to Comfy Checkout to complete payment.',
|
||||
'先在系统内确认充值入口,再跳转到 Comfy Checkout 完成付款。付款完成后回到 BossAI 继续使用,如额度未立即变化请刷新或联系管理员确认。': 'Confirm the recharge entry inside the system first, then continue to Comfy Checkout to complete payment. After payment, return to BossAI to continue. If credits do not update immediately, refresh or contact an admin.',
|
||||
'充值额度用于继续调用模型、短视频工厂、数字人和 Comfy API 工作流。': 'Credits are used for model calls, Short Video Factory, Digital Human, and Comfy API workflows.',
|
||||
'支付页会在新窗口打开。付款完成后回到系统继续使用,如额度未立即变化请刷新或联系管理员确认。': 'The payment page opens in a new window. After payment, return here to continue. If credits do not update immediately, refresh or contact an admin.',
|
||||
'需要更多额度时,可跳转到 Comfy Checkout 添加积分。': 'When you need more quota, open Comfy Checkout to add credits.',
|
||||
'查看当前账号的密钥、额度、最近请求和累计消费。': 'View this account\'s keys, quota, recent requests, and total spend.',
|
||||
'当前密码': 'Current Password',
|
||||
'新密码': 'New Password',
|
||||
'确认新密码': 'Confirm New Password',
|
||||
@@ -437,7 +501,7 @@ const zhToEn: Record<string, string> = {
|
||||
'密钥名称': 'Key Name',
|
||||
'暂无 API 密钥': 'No API Keys',
|
||||
'注册时间': 'Registered',
|
||||
'正常': 'Active',
|
||||
'正常': 'Normal',
|
||||
'已禁用': 'Disabled',
|
||||
'普通用户': 'User',
|
||||
'创建': 'Create',
|
||||
@@ -446,6 +510,235 @@ const zhToEn: Record<string, string> = {
|
||||
'用户管理': 'User Management',
|
||||
'个用户': ' users',
|
||||
'总用户': 'Total Users',
|
||||
|
||||
// Dashboard operations surface
|
||||
'夜深了': "It's late",
|
||||
'早上好': 'Good morning',
|
||||
'中午好': 'Good noon',
|
||||
'下午好': 'Good afternoon',
|
||||
'晚上好': 'Good evening',
|
||||
'加载仪表盘失败': 'Failed to load dashboard',
|
||||
'加载失败': 'Load failed',
|
||||
'重试': 'Retry',
|
||||
'稳定': 'Stable',
|
||||
'需关注': 'Needs attention',
|
||||
'暂无流量': 'No traffic',
|
||||
'平台运行平稳,可以继续观察吞吐和成本变化。': 'The platform is stable. Keep watching throughput and cost changes.',
|
||||
'部分指标需要留意,建议优先查看风险站点、路由和最近日志。': 'Some metrics need attention. Check risky sites, routes, and recent logs first.',
|
||||
'部分指标需要留意,建议优先查看风险站点、路由和运维监控。': 'Some metrics need attention. Check risky sites, routes, and Ops Monitor first.',
|
||||
'平台存在明显异常,建议立即进入运维监控检查路由、站点和请求异常。': 'The platform has clear issues. Open Ops Monitor to check routes, sites, and request issues immediately.',
|
||||
'关键运行信号': 'Key runtime signals',
|
||||
'实时运营摘要': 'Live operations summary',
|
||||
'运营战情室': 'Operations room',
|
||||
'风险雷达': 'Risk radar',
|
||||
'优先检查这些站点': 'Check these sites first',
|
||||
'暂无明显站点风险': 'No obvious site risk',
|
||||
'站点可用性、平均延迟和失败数都处于可接受范围。': 'Site availability, average latency, and failure count are within acceptable ranges.',
|
||||
'排障队列': 'Triage queue',
|
||||
'下一步该看哪里': 'Where to look next',
|
||||
'打开日志台': 'Open logs',
|
||||
'运行健康处理队列': 'Health action queue',
|
||||
'个事项需要处理': 'items need attention',
|
||||
'当前没有高优先级事项': 'No high-priority items',
|
||||
'复制交接': 'Copy handoff',
|
||||
'已复制运行健康交接摘要': 'Health handoff summary copied',
|
||||
'复制运行健康交接摘要失败': 'Failed to copy health handoff summary',
|
||||
'日志台': 'Logs',
|
||||
'值班入口': 'On-call entry',
|
||||
'正在整理平台运行数据。': 'Organizing platform runtime data.',
|
||||
'刷新:': 'Refreshed: ',
|
||||
'刷新中...': 'Refreshing...',
|
||||
'聚合请求吞吐、路由健康、站点风险、后台任务和运行日志,作为值班排障的第一屏。': 'Aggregate request throughput, route health, site risk, background tasks, and runtime logs as the first on-call triage screen.',
|
||||
'核心指标': 'Core metrics',
|
||||
'运行状态': 'Runtime status',
|
||||
'实时吞吐': 'Live throughput',
|
||||
'路由风险': 'Route risk',
|
||||
'条路由快照': 'route snapshots',
|
||||
'个活跃站点': 'active sites',
|
||||
'均值': 'avg',
|
||||
'个数据源暂时不可用,已展示可获取的数据': 'data sources are temporarily unavailable; showing available data',
|
||||
'日志中心': 'Log center',
|
||||
'请求日志、运行日志和任务排障集中在这里': 'Request logs, runtime logs, and task triage are centralized here.',
|
||||
'失败请求': 'Failed requests',
|
||||
'全部请求': 'All requests',
|
||||
'条成功': 'successful',
|
||||
'个失败任务': 'failed tasks',
|
||||
'任务运行': 'Task runtime',
|
||||
'请求错误': 'Request errors',
|
||||
'最近 24h 存在失败请求': 'Failed requests in the last 24h',
|
||||
'条': 'items',
|
||||
'先确认错误是否集中在单个站点、模型或账号。': 'Check whether errors are concentrated on one site, model, or account first.',
|
||||
'路由候选需要确认。': 'Route candidates need review.',
|
||||
'打开路由': 'Open route',
|
||||
'暂无可用率': 'No availability',
|
||||
'次失败': 'failures',
|
||||
'查看站点日志': 'View site logs',
|
||||
'后台任务': 'Background tasks',
|
||||
'任务失败,需要进入对应业务页重试或查看运行日志。': 'Task failed. Retry in the related app page or inspect runtime logs.',
|
||||
'查看运行日志': 'View runtime logs',
|
||||
'值班队列': 'On-call queue',
|
||||
'暂无高优先级事项': 'No high-priority items',
|
||||
'请求、路由、站点和后台任务没有触发高风险阈值。': 'Requests, routes, sites, and background tasks have not crossed high-risk thresholds.',
|
||||
'继续观察': 'Keep watching',
|
||||
'优先队列': 'Priority queue',
|
||||
'当前平稳': 'Currently steady',
|
||||
'按风险收敛': 'Prioritized by risk',
|
||||
'值班台': 'On-call desk',
|
||||
'路由与站点': 'Routes and sites',
|
||||
'最需要看的对象': 'Objects to inspect first',
|
||||
'站点暂无风险项': 'No site risk items',
|
||||
'个进行中': 'in progress',
|
||||
'日志': 'Logs',
|
||||
'暂无后台任务': 'No background tasks',
|
||||
'条需关注': 'need attention',
|
||||
'运行日志': 'Runtime log',
|
||||
'暂无运行日志': 'No runtime logs',
|
||||
'已取消': 'Cancelled',
|
||||
'运行中': 'Running',
|
||||
'等待中': 'Pending',
|
||||
'注意': 'Attention',
|
||||
';': '; ',
|
||||
'路由已禁用': 'Route disabled',
|
||||
'路由启用': 'Route enabled',
|
||||
'没有通道': 'No channels',
|
||||
'候选全部不可用': 'All candidates unavailable',
|
||||
'暂无选择概率快照': 'No selection probability snapshot',
|
||||
'通道可用': 'channels available',
|
||||
'候选可选': 'candidates eligible',
|
||||
'个候选近期失败或被避让': 'candidates recently failed or avoided',
|
||||
'最高选择概率': 'Highest selection probability',
|
||||
'运营快捷入口': 'Operations shortcuts',
|
||||
'整体状态': 'Overall status',
|
||||
'24h 失败': '24h failures',
|
||||
'账户启用': 'Account enabled',
|
||||
'活跃站点': 'active sites',
|
||||
'奖励': 'Reward',
|
||||
'站点风险': 'Site risk',
|
||||
'今日成本': 'Today cost',
|
||||
'当前吞吐': 'Current throughput',
|
||||
'路由健康': 'Route health',
|
||||
'需处理路由': 'Routes need attention',
|
||||
'配置正常': 'Config normal',
|
||||
'等待路由': 'Waiting for routes',
|
||||
'24h 请求': '24h requests',
|
||||
'最近 24h 请求': 'Last 24h requests',
|
||||
'查看该站点 24h 请求': 'View this site\'s 24h requests',
|
||||
'需关注站点': 'Sites need attention',
|
||||
'风险站点': 'Risk sites',
|
||||
'账户启用率': 'Account enabled rate',
|
||||
'路由配置': 'Route config',
|
||||
'诊断路由': 'Diagnose route',
|
||||
'查看命中链路与候选通道': 'View matched path and candidate channels',
|
||||
'测试器验证': 'Verify in tester',
|
||||
'发起最小化验证': 'Start minimal verification',
|
||||
'路由通道可用': 'Route channels available',
|
||||
'路由等待启用': 'Routes waiting to be enabled',
|
||||
'已配置路由未发现通道可用性或候选失败风险。': 'Configured routes show no channel availability or candidate failure risk.',
|
||||
'当前路由多为禁用状态,启用前建议先检查通道和候选快照。': 'Most routes are disabled. Check channels and candidate snapshots before enabling them.',
|
||||
'路由页': 'Routes page',
|
||||
'查看路由表和通道': 'View route table and channels',
|
||||
'模型测试器': 'Model tester',
|
||||
'手动验证模型': 'Manually verify model',
|
||||
'错误日志': 'Error logs',
|
||||
'先查看最近失败请求': 'Check recent failed requests first',
|
||||
'从失败日志定位上游、路由或账号问题。': 'Use failed logs to locate upstream, route, or account issues.',
|
||||
'查看失败日志': 'View failed logs',
|
||||
'按失败状态筛选最近请求': 'Filter recent requests by failed status',
|
||||
'查看完整请求流': 'View full request stream',
|
||||
'最近 24h 没有失败请求': 'No failed requests in the last 24h',
|
||||
'日志侧暂无明显错误,可以继续观察吞吐、成本和站点响应。': 'No obvious log errors. Continue watching throughput, cost, and site responses.',
|
||||
'查看全部日志': 'View all logs',
|
||||
'继续观察请求趋势': 'Continue watching request trends',
|
||||
'看站点日志': 'View site logs',
|
||||
'聚焦该站点 24h 请求': 'Focus on this site\'s 24h requests',
|
||||
'检查代理和禁用模型': 'Check proxy and disabled models',
|
||||
'站点观测正常': 'Site observation normal',
|
||||
'查看站点': 'View sites',
|
||||
'维护站点和模型配置': 'Maintain sites and model config',
|
||||
'检查代理和模型配置': 'Check proxy and model config',
|
||||
'模型调用暂无异常': 'No model call issues',
|
||||
'等待流量': 'Waiting for traffic',
|
||||
'排行榜中暂未发现低成功率或高延迟模型。': 'No low-success or high-latency models found in the ranking.',
|
||||
'有模型调用后会在这里出现诊断入口。': 'Diagnostic entries appear here after model calls.',
|
||||
'查看路由表': 'View route table',
|
||||
'先收敛最近失败请求': 'Triage recent failed requests first',
|
||||
'直接过滤失败请求': 'Filter failed requests directly',
|
||||
'复现失败模型': 'Reproduce failed model',
|
||||
'失败请求正常': 'Failed requests normal',
|
||||
'最近 24h 未发现失败请求,日志侧可以作为低优先级观察项。': 'No failed requests in the last 24h. Logs can be a low-priority watch item.',
|
||||
'查看完整请求': 'View full requests',
|
||||
'查看命中链路': 'View matched path',
|
||||
'最小化验证': 'Minimal verification',
|
||||
'路由候选正常': 'Route candidates normal',
|
||||
'暂无路由快照': 'No route snapshot',
|
||||
'等待数据': 'Waiting for data',
|
||||
'当前没有 danger/warning 路由,继续观察候选通道即可。': 'No danger or warning routes. Keep watching candidate channels.',
|
||||
'路由摘要尚未返回,进入路由页可手动刷新。': 'Route summary has not returned yet. Open routes to refresh manually.',
|
||||
'站点健康': 'Site health',
|
||||
'优先确认该站点代理、密钥和禁用模型配置。': 'Confirm this site\'s proxy, key, and disabled model config first.',
|
||||
'检查配置': 'Check config',
|
||||
'站点风险正常': 'Site risk normal',
|
||||
'活跃': 'active',
|
||||
'活跃站点没有触发可用性、延迟或失败阈值。': 'Active sites did not trigger availability, latency, or failure thresholds.',
|
||||
'维护站点': 'Maintain sites',
|
||||
'模型风险': 'Model risk',
|
||||
'推演候选': 'Inspect candidates',
|
||||
'发起验证': 'Start verification',
|
||||
'模型调用正常': 'Model calls normal',
|
||||
'等待模型流量': 'Waiting for model traffic',
|
||||
'模型排行榜未发现低成功率或高延迟异常。': 'Model ranking shows no low-success or high-latency issues.',
|
||||
'有模型调用后会自动形成风险队列项。': 'Risk queue items are created automatically after model calls.',
|
||||
'统一观察模型路由、站点健康、账户余额、请求吞吐和计费成本。这里是整个平台的运营控制台。': 'Monitor model routes, site health, account balances, request throughput, and billing costs from one operations console.',
|
||||
'账户数据': 'Account data',
|
||||
'当前余额': 'Current balance',
|
||||
'累计消耗': 'Total spend',
|
||||
'今日': 'Today',
|
||||
'使用统计': 'Usage stats',
|
||||
'成功请求': 'Successful requests',
|
||||
'资源消耗': 'Resource usage',
|
||||
'活跃账户': 'Active accounts',
|
||||
'签到状态': 'Check-in status',
|
||||
'今日签到': 'Today check-ins',
|
||||
'性能指标': 'Performance',
|
||||
'最近': 'Last',
|
||||
'秒请求': 'sec requests',
|
||||
'秒 Tokens': 'sec Tokens',
|
||||
'站点分析': 'Site analysis',
|
||||
'站点趋势时间范围': 'Site trend range',
|
||||
'天': 'd',
|
||||
'站点可用性观测': 'Site availability',
|
||||
'最近 24 小时 · 每色块 = 1h · 按使用量排序': 'Last 24 hours · each block = 1h · sorted by usage',
|
||||
'低': 'Low',
|
||||
'高': 'High',
|
||||
'隐藏未使用': 'Hide unused',
|
||||
'显示未使用': 'Show unused',
|
||||
'查看运维监控': 'View Ops Monitor',
|
||||
'查看日志': 'View logs',
|
||||
'暂无站点观测数据': 'No site observability data',
|
||||
'有代理请求后,这里会自动生成每个站点的可用性条和平均响应速度。': 'After proxy requests arrive, each site gets an availability strip and average response speed here.',
|
||||
'暂无延迟': 'No latency',
|
||||
'测速': 'Speed test',
|
||||
'超时': 'Timeout',
|
||||
'全部测速完成': 'All speed tests complete',
|
||||
'测速失败': 'Speed test failed',
|
||||
'模型数据分析': 'Model data analysis',
|
||||
'一键测速': 'Test all speeds',
|
||||
'站点信息': 'Site info',
|
||||
'跳转': 'Open',
|
||||
'代理端点可用': 'Proxy endpoint available',
|
||||
'使用': 'Use',
|
||||
'访问': 'Visit',
|
||||
'24h 活跃调用': '24h active calls',
|
||||
'可用性': 'Availability',
|
||||
'无请求': 'No requests',
|
||||
'请求': 'Requests',
|
||||
'成功/失败': 'Success/Failed',
|
||||
'平均响应': 'Avg response',
|
||||
'暂无': 'N/A',
|
||||
'次': 'times',
|
||||
'账号健康': 'Account health',
|
||||
'检查': 'Check',
|
||||
'代理与出口': 'Proxy and egress',
|
||||
};
|
||||
|
||||
for (const [source, target] of Object.entries(zhToEnSupplemental)) {
|
||||
@@ -481,8 +774,12 @@ const CJK_PUNCT_TO_ASCII: Record<string, string> = {
|
||||
'、': ', ',
|
||||
};
|
||||
|
||||
function normalizeCjkPunctuation(text: string): string {
|
||||
return text.replace(/[,。:;!?()【】“”‘’、]/g, (ch) => CJK_PUNCT_TO_ASCII[ch] ?? ch);
|
||||
}
|
||||
|
||||
function enforceStrictEnglish(text: string): string {
|
||||
const normalizedPunctuation = text.replace(/[,。:;!?()【】“”‘’、]/g, (ch) => CJK_PUNCT_TO_ASCII[ch] ?? ch);
|
||||
const normalizedPunctuation = normalizeCjkPunctuation(text);
|
||||
const strippedHan = normalizedPunctuation.replace(HAN_BLOCK_RE, ' ');
|
||||
const compacted = strippedHan.replace(/\s+/g, ' ').trim();
|
||||
if (!compacted) return 'Untranslated';
|
||||
@@ -512,13 +809,98 @@ export function translateText(text: string, language: Language): string {
|
||||
translated = translated.split(source).join(target);
|
||||
}
|
||||
if (HAS_HAN_RE.test(translated)) return enforceStrictEnglish(translated);
|
||||
return translated;
|
||||
return normalizeCjkPunctuation(translated);
|
||||
}
|
||||
|
||||
export function tr(text: string): string {
|
||||
return translateText(text, runtimeLanguage);
|
||||
}
|
||||
|
||||
export function translateDomSubtree(root: Node, language: Language) {
|
||||
const shouldTranslateTextNode = (node: Text): boolean => {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return false;
|
||||
if (parent.closest(SKIP_PARENT_SELECTOR)) return false;
|
||||
if (parent.isContentEditable) return false;
|
||||
const value = node.nodeValue || '';
|
||||
if (!value.trim()) return false;
|
||||
if (!HAS_HAN_RE.test(value) && language !== 'zh') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const processTextNode = (node: Text) => {
|
||||
if (!shouldTranslateTextNode(node)) return;
|
||||
const current = node.nodeValue || '';
|
||||
const stored = textNodeOriginalMap.get(node);
|
||||
if (!stored) {
|
||||
textNodeOriginalMap.set(node, current);
|
||||
} else {
|
||||
const expectedEnglish = translateText(stored, 'en');
|
||||
if (
|
||||
current !== expectedEnglish
|
||||
&& current !== stored
|
||||
&& HAS_HAN_RE.test(current)
|
||||
) {
|
||||
textNodeOriginalMap.set(node, current);
|
||||
}
|
||||
}
|
||||
const source = textNodeOriginalMap.get(node) || current;
|
||||
const next = translateText(source, language);
|
||||
if (next !== current) {
|
||||
node.nodeValue = next;
|
||||
}
|
||||
};
|
||||
|
||||
const processElementAttrs = (el: Element) => {
|
||||
if (el.matches(SKIP_PARENT_SELECTOR)) return;
|
||||
let attrMap = elementAttrOriginalMap.get(el);
|
||||
if (!attrMap) {
|
||||
attrMap = new Map<string, string>();
|
||||
elementAttrOriginalMap.set(el, attrMap);
|
||||
}
|
||||
|
||||
for (const attr of TRANSLATABLE_ATTRS) {
|
||||
const current = el.getAttribute(attr);
|
||||
if (!current || !current.trim()) continue;
|
||||
const stored = attrMap.get(attr);
|
||||
if (!stored) {
|
||||
attrMap.set(attr, current);
|
||||
} else {
|
||||
const expectedEnglish = translateText(stored, 'en');
|
||||
if (
|
||||
current !== expectedEnglish
|
||||
&& current !== stored
|
||||
&& HAS_HAN_RE.test(current)
|
||||
) {
|
||||
attrMap.set(attr, current);
|
||||
}
|
||||
}
|
||||
|
||||
const source = attrMap.get(attr) || current;
|
||||
const next = translateText(source, language);
|
||||
if (next !== current) {
|
||||
el.setAttribute(attr, next);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
processTextNode(node as Text);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
const el = node as Element;
|
||||
processElementAttrs(el);
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
}
|
||||
|
||||
type I18nContextValue = {
|
||||
language: Language;
|
||||
setLanguage: (next: Language) => void;
|
||||
@@ -543,81 +925,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const root = document.body;
|
||||
if (!root) return;
|
||||
|
||||
const shouldTranslateTextNode = (node: Text): boolean => {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return false;
|
||||
if (parent.closest(SKIP_PARENT_SELECTOR)) return false;
|
||||
if (parent.isContentEditable) return false;
|
||||
const value = node.nodeValue || '';
|
||||
if (!value.trim()) return false;
|
||||
if (!HAS_HAN_RE.test(value) && language !== 'zh') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const processTextNode = (node: Text) => {
|
||||
if (!shouldTranslateTextNode(node)) return;
|
||||
const current = node.nodeValue || '';
|
||||
const stored = textNodeOriginalMap.get(node);
|
||||
if (!stored) {
|
||||
textNodeOriginalMap.set(node, current);
|
||||
} else {
|
||||
const expected = translateText(stored, language);
|
||||
if (current !== expected && current !== stored) {
|
||||
textNodeOriginalMap.set(node, current);
|
||||
}
|
||||
}
|
||||
const source = textNodeOriginalMap.get(node) || current;
|
||||
const next = translateText(source, language);
|
||||
if (next !== current) {
|
||||
node.nodeValue = next;
|
||||
}
|
||||
};
|
||||
|
||||
const processElementAttrs = (el: Element) => {
|
||||
if (el.matches(SKIP_PARENT_SELECTOR)) return;
|
||||
let attrMap = elementAttrOriginalMap.get(el);
|
||||
if (!attrMap) {
|
||||
attrMap = new Map<string, string>();
|
||||
elementAttrOriginalMap.set(el, attrMap);
|
||||
}
|
||||
|
||||
for (const attr of TRANSLATABLE_ATTRS) {
|
||||
const current = el.getAttribute(attr);
|
||||
if (!current || !current.trim()) continue;
|
||||
const stored = attrMap.get(attr);
|
||||
if (!stored) {
|
||||
attrMap.set(attr, current);
|
||||
} else {
|
||||
const expected = translateText(stored, language);
|
||||
if (current !== expected && current !== stored) {
|
||||
attrMap.set(attr, current);
|
||||
}
|
||||
}
|
||||
|
||||
const source = attrMap.get(attr) || current;
|
||||
const next = translateText(source, language);
|
||||
if (next !== current) {
|
||||
el.setAttribute(attr, next);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
processTextNode(node as Text);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
const el = node as Element;
|
||||
processElementAttrs(el);
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
translateDomSubtree(root, language);
|
||||
if (language !== 'en') {
|
||||
return;
|
||||
}
|
||||
@@ -625,18 +933,18 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
if (record.type === 'characterData') {
|
||||
processTextNode(record.target as Text);
|
||||
translateDomSubtree(record.target, language);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.type === 'attributes') {
|
||||
processElementAttrs(record.target as Element);
|
||||
translateDomSubtree(record.target, language);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.type === 'childList') {
|
||||
for (const node of Array.from(record.addedNodes)) {
|
||||
walk(node);
|
||||
translateDomSubtree(node, language);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -685,3 +993,14 @@ export function useI18n() {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useOptionalI18n() {
|
||||
const value = useContext(I18nContext);
|
||||
const fallbackT = useCallback((text: string) => translateText(text, runtimeLanguage), []);
|
||||
return value ?? {
|
||||
language: runtimeLanguage,
|
||||
setLanguage: () => undefined,
|
||||
toggleLanguage: () => undefined,
|
||||
t: fallbackT,
|
||||
};
|
||||
}
|
||||
|
||||
+8654
-313
File diff suppressed because it is too large
Load Diff
+15
-4
@@ -10,11 +10,22 @@
|
||||
<body class="min-h-screen">
|
||||
<script>
|
||||
(function(){
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var mode = localStorage.getItem('theme_mode');
|
||||
var legacy = localStorage.getItem('theme');
|
||||
var preset = localStorage.getItem('theme_preset');
|
||||
var normalizedMode = mode === 'system' || mode === 'light' || mode === 'dark'
|
||||
? mode
|
||||
: (legacy === 'light' || legacy === 'dark' ? legacy : 'system');
|
||||
var normalizedPreset = preset === 'terminal' || preset === 'studio' ? preset : 'clearops';
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var resolved = normalizedMode === 'system' ? (prefersDark ? 'dark' : 'light') : normalizedMode;
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
document.documentElement.setAttribute('data-theme-mode', normalizedMode);
|
||||
document.documentElement.setAttribute('data-theme-preset', normalizedPreset);
|
||||
if (normalizedPreset === 'terminal') {
|
||||
document.documentElement.setAttribute('data-theme-tone', 'dark');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.documentElement.setAttribute('data-theme-tone', resolved);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -27,6 +27,9 @@ export function resolveLoginErrorMessage(
|
||||
if (status === 403 && normalizedReason.includes('ip not allowed')) {
|
||||
return '当前 IP 不在管理白名单中';
|
||||
}
|
||||
if (status === 403 && normalizedReason.includes('admin api token')) {
|
||||
return '管理员请使用管理员 API Token 登录。';
|
||||
}
|
||||
if (status === 401 || (status === 403 && normalizedReason.includes('invalid token'))) {
|
||||
return '登录令牌无效';
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const OPERATIONS = [
|
||||
{ title: '站点与账号', desc: '维护上游站点、账号、OAuth、API Key、余额和连接状态。' },
|
||||
{ title: '模型广场', desc: '聚合模型可用性、价格、账号覆盖、绑定令牌和路由健康度。' },
|
||||
{ title: '用户体系', desc: '普通用户拥有独立 API Key、使用日志、工作空间和可见模型范围。' },
|
||||
{ title: '审计告警', desc: '使用日志、程序日志、通知渠道和运行事件集中留痕。' },
|
||||
{ title: '审计告警', desc: '使用日志、运行日志、通知渠道和运行事件集中留痕。' },
|
||||
{ title: '工作空间', desc: '只归档最终产出,按用户隔离图片、视频、音频和工作流资产。' },
|
||||
{ title: '任务编排', desc: '后台生成、取消、查询、归档和失败原因追踪保持一致体验。' },
|
||||
];
|
||||
|
||||
+216
-106
@@ -2,6 +2,9 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { api } from "../api.js";
|
||||
import CenteredModal from "../components/CenteredModal.js";
|
||||
import DiagnosticActionGroup, {
|
||||
type DiagnosticAction,
|
||||
} from "../components/DiagnosticActionGroup.js";
|
||||
import ResponsiveFilterPanel from "../components/ResponsiveFilterPanel.js";
|
||||
import ResponsiveFormGrid from "../components/ResponsiveFormGrid.js";
|
||||
import ResponsiveBatchActionBar from "../components/ResponsiveBatchActionBar.js";
|
||||
@@ -33,10 +36,15 @@ import {
|
||||
sortItemsForDisplay,
|
||||
type SortMode,
|
||||
} from "./helpers/listSorting.js";
|
||||
import { shouldIgnoreRowSelectionClick } from "./helpers/rowSelection.js";
|
||||
import {
|
||||
shouldHandleRowSelectionKeyDown,
|
||||
shouldIgnoreRowSelectionClick,
|
||||
} from "./helpers/rowSelection.js";
|
||||
import { SITE_DOCS_URL } from "../docsLink.js";
|
||||
import { getSiteInitializationPreset } from "../../shared/siteInitializationPresets.js";
|
||||
import { parseBatchApiKeys } from "../../shared/apiKeyBatch.js";
|
||||
import { formatDateTimeMinuteLocal } from "./helpers/checkinLogTime.js";
|
||||
import { buildRouteDiagnosticHref } from "./helpers/routeDiagnosticLink.js";
|
||||
|
||||
type ConnectionsSegment = "session" | "apikey" | "tokens";
|
||||
|
||||
@@ -867,7 +875,7 @@ export default function Accounts() {
|
||||
degraded: {
|
||||
label: "降级",
|
||||
cls: "badge-warning",
|
||||
dotClass: "status-dot-pending",
|
||||
dotClass: "status-dot-warning",
|
||||
pulse: true,
|
||||
},
|
||||
disabled: {
|
||||
@@ -877,9 +885,9 @@ export default function Accounts() {
|
||||
pulse: false,
|
||||
},
|
||||
unknown: {
|
||||
label: "未知",
|
||||
label: "待检测",
|
||||
cls: "badge-muted",
|
||||
dotClass: "status-dot-pending",
|
||||
dotClass: "status-dot-muted",
|
||||
pulse: false,
|
||||
},
|
||||
};
|
||||
@@ -888,8 +896,18 @@ export default function Accounts() {
|
||||
if (account.status === "expired") {
|
||||
return {
|
||||
...runtimeHealthMap.unhealthy,
|
||||
state: "unhealthy",
|
||||
label: "已过期",
|
||||
reason: account.runtimeHealth?.reason || "连接凭证已过期,请更新凭证",
|
||||
checkedAt: account.runtimeHealth?.checkedAt,
|
||||
latencyMs: account.runtimeHealth?.latencyMs,
|
||||
httpStatus: account.runtimeHealth?.httpStatus,
|
||||
recentModel: account.runtimeHealth?.recentModel,
|
||||
modelRequested: account.runtimeHealth?.modelRequested,
|
||||
modelActual: account.runtimeHealth?.modelActual,
|
||||
routeId: account.runtimeHealth?.routeId,
|
||||
channelId: account.runtimeHealth?.channelId,
|
||||
logId: account.runtimeHealth?.logId,
|
||||
};
|
||||
}
|
||||
const capabilities = resolveAccountCapabilities(account);
|
||||
@@ -907,8 +925,23 @@ export default function Accounts() {
|
||||
? "账号或站点已禁用"
|
||||
: state === "unhealthy"
|
||||
? "最近健康检查失败"
|
||||
: state === "degraded"
|
||||
? "最近代理请求成功但质量偏低"
|
||||
: "尚未获取运行健康信息");
|
||||
return { state, reason, ...cfg };
|
||||
return {
|
||||
state,
|
||||
reason,
|
||||
checkedAt: account.runtimeHealth?.checkedAt,
|
||||
latencyMs: account.runtimeHealth?.latencyMs,
|
||||
httpStatus: account.runtimeHealth?.httpStatus,
|
||||
recentModel: account.runtimeHealth?.recentModel,
|
||||
modelRequested: account.runtimeHealth?.modelRequested,
|
||||
modelActual: account.runtimeHealth?.modelActual,
|
||||
routeId: account.runtimeHealth?.routeId,
|
||||
channelId: account.runtimeHealth?.channelId,
|
||||
logId: account.runtimeHealth?.logId,
|
||||
...cfg,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveAccountCapabilities = (account: any) => {
|
||||
@@ -930,6 +963,116 @@ export default function Accounts() {
|
||||
};
|
||||
};
|
||||
|
||||
const buildAccountLogsRoute = (account: any, health?: any) => {
|
||||
const params = new URLSearchParams();
|
||||
if (health?.state !== "degraded") {
|
||||
params.set("status", "failed");
|
||||
}
|
||||
const healthModel = String(
|
||||
health?.recentModel || health?.modelActual || health?.modelRequested || "",
|
||||
).trim();
|
||||
const accountName = resolveAccountDisplayName(account);
|
||||
const query =
|
||||
healthModel ||
|
||||
(accountName !== "未命名" ? accountName : account?.site?.name || "");
|
||||
if (query.trim()) params.set("q", query.trim());
|
||||
return `/logs?${params.toString()}`;
|
||||
};
|
||||
|
||||
const resolveRuntimeHealthActions = (account: any, health: any): DiagnosticAction[] => {
|
||||
const capabilities = resolveAccountCapabilities(account);
|
||||
if (account.status === "expired") {
|
||||
return capabilities.proxyOnly
|
||||
? [{
|
||||
label: "更新 API Key",
|
||||
hint: "保存新 Key 后再刷新状态",
|
||||
onClick: () => openEditPanel(account),
|
||||
}]
|
||||
: [{
|
||||
label: "重新绑定",
|
||||
hint: "更新连接凭证后会重新验证",
|
||||
onClick: () => openRebindPanel(account),
|
||||
}];
|
||||
}
|
||||
if (health.state === "unhealthy" || health.state === "degraded") {
|
||||
const model = String(health.recentModel || health.modelActual || health.modelRequested || "").trim();
|
||||
const actions: DiagnosticAction[] = [{
|
||||
label: health.state === "degraded" ? "查看相关日志" : "查看失败日志",
|
||||
hint: health.state === "degraded"
|
||||
? "从最近请求定位慢通道、路由或上游拥塞"
|
||||
: "从最近失败请求定位上游、路由或代理问题",
|
||||
href: buildAccountLogsRoute(account, health),
|
||||
tone: health.state === "degraded" ? "primary" : "danger",
|
||||
}];
|
||||
if (model || health.routeId) {
|
||||
actions.push({
|
||||
label: "诊断路由",
|
||||
hint: model ? `按 ${model} 推演命中链路` : "打开最近命中路由",
|
||||
href: buildRouteDiagnosticHref(model, health.routeId),
|
||||
tone: health.state === "degraded" ? "primary" : "danger",
|
||||
});
|
||||
}
|
||||
if (model) {
|
||||
actions.push({
|
||||
label: "模型测试器",
|
||||
hint: "用同模型发起最小化验证",
|
||||
href: `/model-tester?model=${encodeURIComponent(model)}`,
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
if (health.state === "disabled") {
|
||||
return [{
|
||||
label: "启用后检测",
|
||||
hint: "账号或站点恢复启用后再刷新状态",
|
||||
tone: "muted",
|
||||
}];
|
||||
}
|
||||
if (health.state === "unknown") {
|
||||
return [{
|
||||
label: "等待首次调用",
|
||||
hint: "首次代理请求后自动更新,也可手动刷新账户状态",
|
||||
tone: "muted",
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
label: "最近可用",
|
||||
hint: "继续观察延迟和失败率",
|
||||
tone: "muted",
|
||||
}];
|
||||
};
|
||||
|
||||
const renderRuntimeHealth = (account: any, options?: { compact?: boolean }) => {
|
||||
const health = resolveRuntimeHealth(account);
|
||||
const actions = resolveRuntimeHealthActions(account, health);
|
||||
const metaParts = [
|
||||
health.latencyMs != null ? `${health.latencyMs}ms` : "",
|
||||
health.httpStatus != null ? `HTTP ${health.httpStatus}` : "",
|
||||
health.checkedAt ? formatDateTimeMinuteLocal(health.checkedAt) : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`account-runtime-health ${options?.compact ? "is-compact" : ""}`}
|
||||
data-state={health.state}
|
||||
>
|
||||
<span className={`badge ${health.cls} account-runtime-health-badge`}>
|
||||
<span
|
||||
className={`status-dot ${health.dotClass} ${health.pulse ? "animate-pulse-dot" : ""}`}
|
||||
/>
|
||||
{health.label}
|
||||
</span>
|
||||
<span className="account-runtime-health-reason" data-tooltip={health.reason}>
|
||||
{health.reason}
|
||||
</span>
|
||||
{metaParts.length > 0 ? (
|
||||
<span className="account-runtime-health-meta">{metaParts.join(" · ")}</span>
|
||||
) : null}
|
||||
<DiagnosticActionGroup actions={actions} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRefreshRuntimeHealth = async () => {
|
||||
setActionLoading((s) => ({ ...s, "health-refresh": true }));
|
||||
try {
|
||||
@@ -1173,7 +1316,16 @@ export default function Accounts() {
|
||||
accountId: number,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => {
|
||||
if (shouldIgnoreRowSelectionClick(event.target)) return;
|
||||
if (shouldIgnoreRowSelectionClick(event.target, event.currentTarget)) return;
|
||||
const isSelected = selectedAccountIds.includes(accountId);
|
||||
toggleAccountSelection(accountId, !isSelected);
|
||||
};
|
||||
|
||||
const handleAccountRowKeyDown = (
|
||||
accountId: number,
|
||||
event: React.KeyboardEvent<HTMLTableRowElement>,
|
||||
) => {
|
||||
if (!shouldHandleRowSelectionKeyDown(event)) return;
|
||||
const isSelected = selectedAccountIds.includes(accountId);
|
||||
toggleAccountSelection(accountId, !isSelected);
|
||||
};
|
||||
@@ -1337,6 +1489,15 @@ export default function Accounts() {
|
||||
canAddVerifiedConnection ||
|
||||
!!tokenForm.skipModelFetch
|
||||
: canAddVerifiedConnection;
|
||||
const accountsListBusy =
|
||||
!loaded ||
|
||||
batchActionLoading ||
|
||||
saving ||
|
||||
savingEdit ||
|
||||
rebindSaving ||
|
||||
verifying ||
|
||||
rebindVerifying ||
|
||||
Object.values(actionLoading).some(Boolean);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in admin-ops-page accounts-ops-page">
|
||||
@@ -1370,7 +1531,7 @@ export default function Accounts() {
|
||||
<>
|
||||
<div
|
||||
className="accounts-sort-select"
|
||||
style={{ minWidth: 156, position: "relative", zIndex: 20 }}
|
||||
style={{ position: "relative", zIndex: 20 }}
|
||||
>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
@@ -1386,6 +1547,7 @@ export default function Accounts() {
|
||||
</div>
|
||||
{activeSegment === "session" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
withLoading(
|
||||
"checkin-all",
|
||||
@@ -1407,6 +1569,7 @@ export default function Accounts() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefreshRuntimeHealth}
|
||||
disabled={actionLoading["health-refresh"]}
|
||||
className="btn btn-soft-primary"
|
||||
@@ -1423,6 +1586,7 @@ export default function Accounts() {
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextOpen = !showAdd;
|
||||
if (!nextOpen) {
|
||||
@@ -1467,6 +1631,7 @@ export default function Accounts() {
|
||||
</div>
|
||||
{activeSegment === "session" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowMobileTools(false);
|
||||
await withLoading(
|
||||
@@ -1490,6 +1655,7 @@ export default function Accounts() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowMobileTools(false);
|
||||
await handleRefreshRuntimeHealth();
|
||||
@@ -1591,6 +1757,7 @@ export default function Accounts() {
|
||||
desktopStyle={{ marginBottom: 12 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="accounts-batch-refresh-balance"
|
||||
onClick={() => runBatchAccountAction("refreshBalance")}
|
||||
disabled={batchActionLoading}
|
||||
@@ -1600,6 +1767,7 @@ export default function Accounts() {
|
||||
批量刷新余额
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBatchAccountAction("enable")}
|
||||
disabled={batchActionLoading}
|
||||
className="btn btn-ghost"
|
||||
@@ -1608,6 +1776,7 @@ export default function Accounts() {
|
||||
批量启用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBatchAccountAction("disable")}
|
||||
disabled={batchActionLoading}
|
||||
className="btn btn-ghost"
|
||||
@@ -1616,6 +1785,7 @@ export default function Accounts() {
|
||||
批量禁用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBatchAccountAction("delete")}
|
||||
disabled={batchActionLoading}
|
||||
className="btn btn-link btn-link-danger"
|
||||
@@ -1643,6 +1813,7 @@ export default function Accounts() {
|
||||
: "添加 Session 连接"
|
||||
}
|
||||
maxWidth={860}
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||
footer={
|
||||
<button onClick={closeAddPanel} className="btn btn-ghost">
|
||||
@@ -2475,6 +2646,7 @@ export default function Accounts() {
|
||||
onClose={closeRebindPanel}
|
||||
title="重新绑定 Session Token"
|
||||
maxWidth={820}
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||
footer={
|
||||
<button onClick={closeRebindPanel} className="btn btn-ghost">
|
||||
@@ -2496,14 +2668,7 @@ export default function Accounts() {
|
||||
Token,验证成功后再绑定。
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) 220px",
|
||||
gap: 10,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<div className="accounts-rebind-grid accounts-rebind-grid-main">
|
||||
<textarea
|
||||
placeholder="粘贴新的 Session Token"
|
||||
value={rebindForm.accessToken}
|
||||
@@ -2536,14 +2701,7 @@ export default function Accounts() {
|
||||
</div>
|
||||
{isRebindSub2Api && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) 220px",
|
||||
gap: 10,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<div className="accounts-rebind-grid accounts-rebind-grid-sub2api">
|
||||
<input
|
||||
placeholder="Sub2API refresh_token(可选)"
|
||||
value={rebindForm.refreshToken}
|
||||
@@ -2669,6 +2827,7 @@ export default function Accounts() {
|
||||
onClose={closeEditPanel}
|
||||
title="编辑账号"
|
||||
maxWidth={860}
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||
footer={
|
||||
<>
|
||||
@@ -2828,7 +2987,11 @@ export default function Accounts() {
|
||||
) : null}
|
||||
</CenteredModal>
|
||||
|
||||
<div className="card">
|
||||
<div
|
||||
className="card"
|
||||
aria-busy={accountsListBusy}
|
||||
aria-live="polite"
|
||||
>
|
||||
{visibleAccounts.length > 0 ? (
|
||||
isMobile ? (
|
||||
<div className="mobile-card-list">
|
||||
@@ -2888,16 +3051,20 @@ export default function Accounts() {
|
||||
type="button"
|
||||
onClick={() => toggleAccountDetails(a.id)}
|
||||
className="btn btn-link"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`account-mobile-details-${a.id}`}
|
||||
>
|
||||
{isExpanded ? "收起" : "详情"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditPanel(a)}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModelModal(a)}
|
||||
disabled={actionLoading[`models-${a.id}`]}
|
||||
className="btn btn-link btn-link-info"
|
||||
@@ -2909,45 +3076,7 @@ export default function Accounts() {
|
||||
>
|
||||
<MobileField
|
||||
label="运行健康状态"
|
||||
value={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`badge ${health.cls}`}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`status-dot ${health.dotClass} ${health.pulse ? "animate-pulse-dot" : ""}`}
|
||||
style={{ marginRight: 0 }}
|
||||
/>
|
||||
{health.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
maxWidth: 240,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
data-tooltip={health.reason}
|
||||
>
|
||||
{health.reason}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
value={renderRuntimeHealth(a, { compact: true })}
|
||||
/>
|
||||
<MobileField
|
||||
label="余额"
|
||||
@@ -2997,7 +3126,7 @@ export default function Accounts() {
|
||||
}
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
<div id={`account-mobile-details-${a.id}`} className="mobile-card-extra">
|
||||
<MobileField
|
||||
label="站点"
|
||||
value={
|
||||
@@ -3063,6 +3192,7 @@ export default function Accounts() {
|
||||
/>
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePin(a)}
|
||||
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
|
||||
className={`btn btn-link ${a.isPinned ? "btn-link-warning" : "btn-link-primary"}`}
|
||||
@@ -3078,6 +3208,7 @@ export default function Accounts() {
|
||||
{sortMode === "custom" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleMoveCustomOrder(a, "up")
|
||||
}
|
||||
@@ -3089,6 +3220,7 @@ export default function Accounts() {
|
||||
↑ 上移
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleMoveCustomOrder(a, "down")
|
||||
}
|
||||
@@ -3103,6 +3235,7 @@ export default function Accounts() {
|
||||
)}
|
||||
{capabilities.canRefreshBalance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
withLoading(
|
||||
`refresh-${a.id}`,
|
||||
@@ -3122,6 +3255,7 @@ export default function Accounts() {
|
||||
)}
|
||||
{capabilities.canCheckin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
withLoading(
|
||||
`checkin-${a.id}`,
|
||||
@@ -3142,6 +3276,7 @@ export default function Accounts() {
|
||||
{a.status === "expired" &&
|
||||
!capabilities.proxyOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openRebindPanel(a)}
|
||||
className="btn btn-link btn-link-warning"
|
||||
>
|
||||
@@ -3149,6 +3284,7 @@ export default function Accounts() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDeleteConfirm({
|
||||
mode: "single",
|
||||
@@ -3214,6 +3350,12 @@ export default function Accounts() {
|
||||
onClick={(event) =>
|
||||
handleAccountRowClick(a.id, event)
|
||||
}
|
||||
onKeyDown={(event) =>
|
||||
handleAccountRowKeyDown(a.id, event)
|
||||
}
|
||||
tabIndex={0}
|
||||
aria-selected={selectedAccountIds.includes(a.id)}
|
||||
aria-label={`选择账号 ${resolveAccountDisplayName(a)}`}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} row-selectable ${selectedAccountIds.includes(a.id) ? "row-selected" : ""} ${highlightAccountId === a.id ? "row-focus-highlight" : ""}`.trim()}
|
||||
>
|
||||
<td>
|
||||
@@ -3259,48 +3401,7 @@ export default function Accounts() {
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{(() => {
|
||||
const health = resolveRuntimeHealth(a);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`badge ${health.cls}`}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`status-dot ${health.dotClass} ${health.pulse ? "animate-pulse-dot" : ""}`}
|
||||
style={{ marginRight: 0 }}
|
||||
/>
|
||||
{health.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
data-tooltip={health.reason}
|
||||
>
|
||||
{health.reason}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{renderRuntimeHealth(a)}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
<div
|
||||
@@ -3387,6 +3488,7 @@ export default function Accounts() {
|
||||
>
|
||||
<div className="accounts-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePin(a)}
|
||||
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
|
||||
className={`btn btn-link ${a.isPinned ? "btn-link-warning" : "btn-link-primary"}`}
|
||||
@@ -3402,6 +3504,7 @@ export default function Accounts() {
|
||||
{sortMode === "custom" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleMoveCustomOrder(a, "up")
|
||||
}
|
||||
@@ -3413,6 +3516,7 @@ export default function Accounts() {
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleMoveCustomOrder(a, "down")
|
||||
}
|
||||
@@ -3427,6 +3531,7 @@ export default function Accounts() {
|
||||
)}
|
||||
{capabilities.canRefreshBalance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
withLoading(
|
||||
`refresh-${a.id}`,
|
||||
@@ -3445,6 +3550,7 @@ export default function Accounts() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModelModal(a)}
|
||||
disabled={actionLoading[`models-${a.id}`]}
|
||||
className="btn btn-link btn-link-info"
|
||||
@@ -3453,6 +3559,7 @@ export default function Accounts() {
|
||||
</button>
|
||||
{capabilities.canCheckin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
withLoading(
|
||||
`checkin-${a.id}`,
|
||||
@@ -3473,6 +3580,7 @@ export default function Accounts() {
|
||||
{a.status === "expired" &&
|
||||
!capabilities.proxyOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openRebindPanel(a)}
|
||||
className="btn btn-link btn-link-warning"
|
||||
>
|
||||
@@ -3480,12 +3588,14 @@ export default function Accounts() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditPanel(a)}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDeleteConfirm({
|
||||
mode: "single",
|
||||
|
||||
@@ -190,14 +190,7 @@ export default function CheckinLog() {
|
||||
};
|
||||
|
||||
const timeRangeControls = (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 10,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div className="checkin-log-time-controls">
|
||||
<label className="proxy-logs-time-field">
|
||||
<span>开始</span>
|
||||
<input
|
||||
@@ -236,7 +229,9 @@ export default function CheckinLog() {
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={`pill-tab ${filter === tab.key ? "active" : ""}`}
|
||||
aria-pressed={filter === tab.key}
|
||||
onClick={() => setFilter(tab.key)}
|
||||
>
|
||||
{tab.label}{" "}
|
||||
@@ -249,7 +244,7 @@ export default function CheckinLog() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="checkin-log-page animate-fade-in">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{tr("签到记录")}</h2>
|
||||
<button
|
||||
@@ -287,8 +282,9 @@ export default function CheckinLog() {
|
||||
)}
|
||||
desktopContent={(
|
||||
<div className="toolbar" style={{ marginBottom: "12px" }}>
|
||||
<div style={{ minWidth: 280 }}>{filterTabs}</div>
|
||||
<div className="checkin-log-filter-tabs">{filterTabs}</div>
|
||||
<div
|
||||
className="checkin-log-time-slot"
|
||||
style={{
|
||||
flex: "0 0 auto",
|
||||
display: "flex",
|
||||
|
||||
+32
-87
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ComfyUI() {
|
||||
const [isDark, setIsDark] = React.useState(true);
|
||||
const [available, setAvailable] = React.useState(false);
|
||||
const [message, setMessage] = React.useState('正在检查外部 ComfyUI...');
|
||||
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||
@@ -16,15 +15,6 @@ export default function ComfyUI() {
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
const check = () => setIsDark(el.getAttribute('data-theme') !== 'light');
|
||||
check();
|
||||
const mo = new MutationObserver(check);
|
||||
mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
return () => mo.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
@@ -48,94 +38,49 @@ export default function ComfyUI() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 56px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: isDark ? '#1a1a2e' : '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<div style={toolbarStyle}>
|
||||
<div style={toolbarTitleStyle}>外部 ComfyUI</div>
|
||||
<div style={toolbarActionsStyle}>
|
||||
<button type="button" onClick={reloadFrame} disabled={!available} style={toolbarButtonStyle}>
|
||||
<div className="comfyui-embed-shell">
|
||||
<div className="comfyui-embed-toolbar">
|
||||
<div className="comfyui-embed-heading">
|
||||
<div className="comfyui-embed-title">外部 ComfyUI</div>
|
||||
<div className="comfyui-embed-subtitle">通过 BoosAPI 代理嵌入自部署工作台</div>
|
||||
</div>
|
||||
<div className="comfyui-embed-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reloadFrame}
|
||||
disabled={!available}
|
||||
className="btn btn-link comfyui-embed-action-btn"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" onClick={openFullscreen} disabled={!available} style={primaryToolbarButtonStyle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openFullscreen}
|
||||
disabled={!available}
|
||||
className="btn btn-primary comfyui-embed-primary-btn"
|
||||
>
|
||||
新窗口打开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{available ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="/api/comfyui/proxy/"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
title="External ComfyUI"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
<div className="comfyui-embed-frame-wrap">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="/api/comfyui/proxy/"
|
||||
className="comfyui-embed-frame"
|
||||
title="External ComfyUI"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
padding: 24,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 8 }}>
|
||||
内置 Docker ComfyUI 工作台已移除
|
||||
</div>
|
||||
<div style={{ maxWidth: 560, lineHeight: 1.7 }}>{message}</div>
|
||||
<div className="comfyui-embed-empty">
|
||||
<div className="comfyui-embed-empty-panel">
|
||||
<div className="comfyui-embed-empty-title">内置 Docker ComfyUI 工作台已移除</div>
|
||||
<div className="comfyui-embed-empty-desc">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolbarStyle: React.CSSProperties = {
|
||||
minHeight: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const toolbarTitleStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const toolbarActionsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const toolbarButtonStyle: React.CSSProperties = {
|
||||
minHeight: 30,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '5px 10px',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const primaryToolbarButtonStyle: React.CSSProperties = {
|
||||
...toolbarButtonStyle,
|
||||
borderColor: 'var(--color-primary)',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
+199
-58
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
|
||||
import { marked } from 'marked';
|
||||
import { getSessionToken } from '../authSession.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { filterModelTesterModelNames } from './helpers/modelTesterSession.js';
|
||||
|
||||
type AgentRole = 'user' | 'assistant' | 'system';
|
||||
@@ -746,6 +747,7 @@ async function readSseStream(
|
||||
export default function ComfyUIAgent() {
|
||||
const toast = useToast();
|
||||
const location = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const sessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const initialStoredSession = useMemo(
|
||||
() => readStoredSession(sessionAudience),
|
||||
@@ -1469,14 +1471,27 @@ export default function ComfyUIAgent() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="comfyui-agent-page" style={pageStyle}>
|
||||
<div
|
||||
className="comfyui-agent-page"
|
||||
style={{
|
||||
...pageStyle,
|
||||
...(isMobile ? mobilePageStyle : {}),
|
||||
}}
|
||||
>
|
||||
<aside
|
||||
style={{
|
||||
...conversationSidebarStyle,
|
||||
...(isMobile ? mobileConversationSidebarStyle : {}),
|
||||
...(conversationSidebarCollapsed ? conversationSidebarCollapsedStyle : {}),
|
||||
...(isMobile && conversationSidebarCollapsed ? mobileConversationSidebarCollapsedStyle : {}),
|
||||
}}
|
||||
>
|
||||
<div style={conversationSidebarHeaderStyle}>
|
||||
<div
|
||||
style={{
|
||||
...conversationSidebarHeaderStyle,
|
||||
...(isMobile ? mobileConversationSidebarHeaderStyle : {}),
|
||||
}}
|
||||
>
|
||||
{conversationSidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1490,7 +1505,12 @@ export default function ComfyUIAgent() {
|
||||
) : (
|
||||
<>
|
||||
<strong>对话</strong>
|
||||
<div style={conversationHeaderActionsStyle}>
|
||||
<div
|
||||
style={{
|
||||
...conversationHeaderActionsStyle,
|
||||
...(isMobile ? mobileConversationHeaderActionsStyle : {}),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConversationSidebarCollapsed(true)}
|
||||
@@ -1514,64 +1534,69 @@ export default function ComfyUIAgent() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!conversationSidebarCollapsed && <div style={conversationListStyle}>
|
||||
{conversations.map((conversation) => (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
onClick={() => selectConversation(conversation)}
|
||||
style={{
|
||||
...conversationItemStyle,
|
||||
borderColor:
|
||||
conversation.id === activeConversationId
|
||||
{!conversationSidebarCollapsed && <div
|
||||
style={{
|
||||
...conversationListStyle,
|
||||
...(isMobile ? mobileConversationListStyle : {}),
|
||||
}}
|
||||
role="list"
|
||||
aria-label="对话列表"
|
||||
>
|
||||
{conversations.map((conversation) => {
|
||||
const active = conversation.id === activeConversationId;
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
role="listitem"
|
||||
style={{
|
||||
...conversationItemStyle,
|
||||
...(isMobile ? mobileConversationItemStyle : {}),
|
||||
borderColor: active
|
||||
? 'var(--color-primary)'
|
||||
: 'var(--color-border-light)',
|
||||
background:
|
||||
conversation.id === activeConversationId
|
||||
background: active
|
||||
? 'var(--color-primary-soft, rgba(79, 70, 229, 0.12))'
|
||||
: 'var(--color-surface)',
|
||||
}}
|
||||
>
|
||||
<span style={conversationTitleStyle}>{conversation.title}</span>
|
||||
<span style={conversationMetaStyle}>
|
||||
{runningByConversation[conversation.id]
|
||||
? '生成中'
|
||||
: `${Math.max(0, conversation.messages.filter((message) => message.role !== 'system').length)} 条消息`}
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
deleteConversation(conversation.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
deleteConversation(conversation.id);
|
||||
}
|
||||
}}
|
||||
style={conversationDeleteStyle}
|
||||
aria-label="删除对话"
|
||||
>
|
||||
删除
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectConversation(conversation)}
|
||||
style={conversationSelectButtonStyle}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
aria-label={`切换到对话 ${conversation.title}`}
|
||||
>
|
||||
<span style={conversationTitleStyle}>{conversation.title}</span>
|
||||
<span style={conversationMetaStyle}>
|
||||
{runningByConversation[conversation.id]
|
||||
? '生成中'
|
||||
: `${Math.max(0, conversation.messages.filter((message) => message.role !== 'system').length)} 条消息`}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteConversation(conversation.id)}
|
||||
style={conversationDeleteStyle}
|
||||
aria-label={`删除对话 ${conversation.title}`}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>}
|
||||
</aside>
|
||||
|
||||
<section style={chatPanelStyle}>
|
||||
<header style={headerStyle}>
|
||||
<section style={{ ...chatPanelStyle, ...(isMobile ? mobileChatPanelStyle : {}) }}>
|
||||
<header style={{ ...headerStyle, ...(isMobile ? mobileHeaderStyle : {}) }}>
|
||||
<div>
|
||||
<h1 style={titleStyle}>视频助手</h1>
|
||||
<div style={statusStyle}>
|
||||
对话 {selectedModel || status?.model || '加载中'} · 生图 {selectedImageModel || 'gpt-image-2'} · 模式 {status?.mode || 'local'} · Key {status?.keyName || '当前用户'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={headerActionsStyle}>
|
||||
<label style={modelPickerStyle}>
|
||||
<div style={{ ...headerActionsStyle, ...(isMobile ? mobileHeaderActionsStyle : {}) }}>
|
||||
<label style={{ ...modelPickerStyle, ...(isMobile ? mobileModelPickerStyle : {}) }}>
|
||||
<span style={modelPickerLabelStyle}>对话模型</span>
|
||||
<input
|
||||
value={modelSearch}
|
||||
@@ -1602,7 +1627,7 @@ export default function ComfyUIAgent() {
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={modelPickerStyle}>
|
||||
<label style={{ ...modelPickerStyle, ...(isMobile ? mobileModelPickerStyle : {}) }}>
|
||||
<span style={modelPickerLabelStyle}>生图模型</span>
|
||||
<input
|
||||
value={imageModelSearch}
|
||||
@@ -1639,7 +1664,7 @@ export default function ComfyUIAgent() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section style={quickPromptStyle}>
|
||||
<section style={{ ...quickPromptStyle, ...(isMobile ? mobileQuickPromptStyle : {}) }} aria-label="快捷提示">
|
||||
{QUICK_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
@@ -1647,14 +1672,15 @@ export default function ComfyUIAgent() {
|
||||
onClick={() => setInput(prompt)}
|
||||
disabled={loading}
|
||||
style={chipButtonStyle}
|
||||
aria-label={`使用快捷提示:${prompt}`}
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section style={seedancePanelStyle}>
|
||||
<div style={seedanceHeaderStyle}>
|
||||
<section style={{ ...seedancePanelStyle, ...(isMobile ? mobileSeedancePanelStyle : {}) }} aria-label="Seedance 2.0 提示词模板">
|
||||
<div style={{ ...seedanceHeaderStyle, ...(isMobile ? mobileSeedanceHeaderStyle : {}) }}>
|
||||
<div>
|
||||
<div style={seedanceTitleStyle}>Seedance 2.0 提示词</div>
|
||||
<div style={seedanceSubtitleStyle}>
|
||||
@@ -1670,7 +1696,7 @@ export default function ComfyUIAgent() {
|
||||
填入基础模板
|
||||
</button>
|
||||
</div>
|
||||
<div style={seedanceTemplateGridStyle}>
|
||||
<div style={{ ...seedanceTemplateGridStyle, ...(isMobile ? mobileSeedanceTemplateGridStyle : {}) }}>
|
||||
{SEEDANCE_PROMPT_TEMPLATES.map((template) => (
|
||||
<button
|
||||
key={template.title}
|
||||
@@ -1678,6 +1704,7 @@ export default function ComfyUIAgent() {
|
||||
onClick={() => applySeedanceTemplate(template.prompt)}
|
||||
disabled={loading}
|
||||
style={seedanceTemplateCardStyle}
|
||||
aria-label={`填入 Seedance 模板:${template.title},${template.description}`}
|
||||
>
|
||||
<span style={seedanceTemplateTitleStyle}>{template.title}</span>
|
||||
<span style={seedanceTemplateDescStyle}>{template.description}</span>
|
||||
@@ -1686,12 +1713,13 @@ export default function ComfyUIAgent() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main style={messagesStyle}>
|
||||
<main style={{ ...messagesStyle, ...(isMobile ? mobileMessagesStyle : {}) }}>
|
||||
{messages.map((message) => (
|
||||
<article
|
||||
key={message.id}
|
||||
style={{
|
||||
...messageStyle,
|
||||
...(isMobile ? mobileMessageStyle : {}),
|
||||
alignSelf: message.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
background:
|
||||
message.role === 'user'
|
||||
@@ -1732,7 +1760,7 @@ export default function ComfyUIAgent() {
|
||||
))}
|
||||
|
||||
{streaming && (
|
||||
<article style={{ ...messageStyle, alignSelf: 'flex-start' }}>
|
||||
<article style={{ ...messageStyle, ...(isMobile ? mobileMessageStyle : {}), alignSelf: 'flex-start' }}>
|
||||
<div style={messageHeaderStyle}>
|
||||
<span style={messageRoleStyle}>助手</span>
|
||||
</div>
|
||||
@@ -1757,7 +1785,7 @@ export default function ComfyUIAgent() {
|
||||
<div ref={chatEndRef} />
|
||||
</main>
|
||||
|
||||
<footer style={inputBarStyle}>
|
||||
<footer style={{ ...inputBarStyle, ...(isMobile ? mobileInputBarStyle : {}) }}>
|
||||
<div style={composerMainStyle}>
|
||||
{attachments.length > 0 && (
|
||||
<div style={attachmentListStyle}>
|
||||
@@ -1850,13 +1878,17 @@ export default function ComfyUIAgent() {
|
||||
}
|
||||
|
||||
const pageStyle: CSSProperties = {
|
||||
height: 'calc(100vh - 56px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const mobilePageStyle: CSSProperties = {
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const chatPanelStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
@@ -1864,6 +1896,10 @@ const chatPanelStyle: CSSProperties = {
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
const mobileChatPanelStyle: CSSProperties = {
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
const conversationSidebarStyle: CSSProperties = {
|
||||
width: 260,
|
||||
minWidth: 220,
|
||||
@@ -1874,11 +1910,27 @@ const conversationSidebarStyle: CSSProperties = {
|
||||
transition: 'width 0.18s ease, min-width 0.18s ease',
|
||||
};
|
||||
|
||||
const mobileConversationSidebarStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
maxHeight: 'clamp(92px, calc(100dvh - var(--topbar-height) - 420px), 210px)',
|
||||
borderRight: 0,
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const conversationSidebarCollapsedStyle: CSSProperties = {
|
||||
width: 52,
|
||||
minWidth: 52,
|
||||
};
|
||||
|
||||
const mobileConversationSidebarCollapsedStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
maxHeight: 56,
|
||||
};
|
||||
|
||||
const conversationSidebarHeaderStyle: CSSProperties = {
|
||||
minHeight: 72,
|
||||
padding: '14px 14px',
|
||||
@@ -1889,12 +1941,21 @@ const conversationSidebarHeaderStyle: CSSProperties = {
|
||||
gap: 10,
|
||||
};
|
||||
|
||||
const mobileConversationSidebarHeaderStyle: CSSProperties = {
|
||||
minHeight: 52,
|
||||
padding: '10px 12px',
|
||||
};
|
||||
|
||||
const conversationHeaderActionsStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const mobileConversationHeaderActionsStyle: CSSProperties = {
|
||||
marginLeft: 'auto',
|
||||
};
|
||||
|
||||
const sidebarToggleButtonStyle: CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -1917,6 +1978,14 @@ const conversationListStyle: CSSProperties = {
|
||||
padding: 12,
|
||||
};
|
||||
|
||||
const mobileConversationListStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
gap: 8,
|
||||
padding: '0 12px 12px',
|
||||
};
|
||||
|
||||
const conversationItemStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
@@ -1930,8 +1999,24 @@ const conversationItemStyle: CSSProperties = {
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const mobileConversationItemStyle: CSSProperties = {
|
||||
flex: '0 0 min(260px, 82vw)',
|
||||
};
|
||||
|
||||
const conversationSelectButtonStyle: CSSProperties = {
|
||||
gridColumn: '1 / 2',
|
||||
minWidth: 0,
|
||||
border: 0,
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
display: 'grid',
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const conversationTitleStyle: CSSProperties = {
|
||||
gridColumn: '1 / -1',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -1945,8 +2030,13 @@ const conversationMetaStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
const conversationDeleteStyle: CSSProperties = {
|
||||
alignSelf: 'end',
|
||||
border: 0,
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
fontSize: 12,
|
||||
color: 'var(--color-danger, #dc2626)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const headerStyle: CSSProperties = {
|
||||
@@ -1960,6 +2050,13 @@ const headerStyle: CSSProperties = {
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const mobileHeaderStyle: CSSProperties = {
|
||||
minHeight: 'auto',
|
||||
alignItems: 'stretch',
|
||||
flexDirection: 'column',
|
||||
padding: '12px',
|
||||
};
|
||||
|
||||
const headerActionsStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -1968,6 +2065,12 @@ const headerActionsStyle: CSSProperties = {
|
||||
flexWrap: 'wrap',
|
||||
};
|
||||
|
||||
const mobileHeaderActionsStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
justifyContent: 'stretch',
|
||||
alignItems: 'stretch',
|
||||
};
|
||||
|
||||
const titleStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
@@ -1987,6 +2090,11 @@ const modelPickerStyle: CSSProperties = {
|
||||
alignItems: 'end',
|
||||
};
|
||||
|
||||
const mobileModelPickerStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
gridTemplateColumns: '1fr',
|
||||
};
|
||||
|
||||
const modelPickerLabelStyle: CSSProperties = {
|
||||
gridColumn: '1 / -1',
|
||||
fontSize: 12,
|
||||
@@ -2024,6 +2132,10 @@ const quickPromptStyle: CSSProperties = {
|
||||
overflowX: 'auto',
|
||||
};
|
||||
|
||||
const mobileQuickPromptStyle: CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
};
|
||||
|
||||
const seedancePanelStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gap: 10,
|
||||
@@ -2032,6 +2144,10 @@ const seedancePanelStyle: CSSProperties = {
|
||||
background: 'color-mix(in srgb, var(--color-primary) 3%, var(--color-surface))',
|
||||
};
|
||||
|
||||
const mobileSeedancePanelStyle: CSSProperties = {
|
||||
padding: '10px 12px',
|
||||
};
|
||||
|
||||
const seedanceHeaderStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
@@ -2039,6 +2155,11 @@ const seedanceHeaderStyle: CSSProperties = {
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const mobileSeedanceHeaderStyle: CSSProperties = {
|
||||
alignItems: 'stretch',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
const seedanceTitleStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
@@ -2053,10 +2174,17 @@ const seedanceSubtitleStyle: CSSProperties = {
|
||||
|
||||
const seedanceTemplateGridStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(min(150px, 100%), 1fr))',
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const mobileSeedanceTemplateGridStyle: CSSProperties = {
|
||||
gridTemplateColumns: '1fr',
|
||||
maxHeight: 'clamp(120px, calc(100dvh - var(--topbar-height) - 420px), 190px)',
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
};
|
||||
|
||||
const seedanceTemplateCardStyle: CSSProperties = {
|
||||
minHeight: 72,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
@@ -2092,6 +2220,10 @@ const messagesStyle: CSSProperties = {
|
||||
padding: 20,
|
||||
};
|
||||
|
||||
const mobileMessagesStyle: CSSProperties = {
|
||||
padding: 12,
|
||||
};
|
||||
|
||||
const messageStyle: CSSProperties = {
|
||||
width: 'min(860px, 88%)',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
@@ -2100,6 +2232,10 @@ const messageStyle: CSSProperties = {
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const mobileMessageStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
const messageHeaderStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -2224,6 +2360,11 @@ const inputBarStyle: CSSProperties = {
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const mobileInputBarStyle: CSSProperties = {
|
||||
gridTemplateColumns: '1fr',
|
||||
padding: 12,
|
||||
};
|
||||
|
||||
const composerMainStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
@@ -2280,7 +2421,7 @@ const chipButtonStyle: CSSProperties = {
|
||||
|
||||
const assetGridStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(min(120px, 100%), 1fr))',
|
||||
gap: 10,
|
||||
marginTop: 12,
|
||||
};
|
||||
|
||||
+333
-159
@@ -1,9 +1,13 @@
|
||||
import { Suspense, lazy, useEffect, useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { type AnchorHTMLAttributes, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, Suspense, lazy, useEffect, useState, useCallback } from "react";
|
||||
import { Link, useInRouterContext } from "react-router-dom";
|
||||
import { api } from "../api.js";
|
||||
import DiagnosticActionGroup from "../components/DiagnosticActionGroup.js";
|
||||
import { useToast } from "../components/Toast.js";
|
||||
import { useIsMobile } from "../components/useIsMobile.js";
|
||||
import { useOptionalI18n } from "../i18n.js";
|
||||
import { formatCompactTokenMetric } from "../numberFormat.js";
|
||||
import { buildRouteHealthScore, type RouteHealthScore } from "./token-routes/routeHealth.js";
|
||||
import type { RouteSummaryRow } from "./token-routes/types.js";
|
||||
|
||||
const ModelAnalysisPanel = lazy(
|
||||
() => import("../components/ModelAnalysisPanel.js"),
|
||||
@@ -15,6 +19,31 @@ const SiteTrendChart = lazy(
|
||||
() => import("../components/charts/SiteTrendChart.js"),
|
||||
);
|
||||
|
||||
const TREND_DAY_OPTIONS = [7, 30, 90] as const;
|
||||
|
||||
type DashboardLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
|
||||
children?: ReactNode;
|
||||
to: string;
|
||||
};
|
||||
|
||||
function DashboardLink({ children, to, ...props }: DashboardLinkProps) {
|
||||
const inRouter = useInRouterContext();
|
||||
|
||||
if (!inRouter) {
|
||||
return (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={to} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleDashboardIdleWork(callback: () => void, timeout = 1600): () => void {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
const idleId = requestIdleCallback(callback, { timeout });
|
||||
@@ -91,6 +120,10 @@ type SiteAvailabilitySummary = {
|
||||
buckets: SiteAvailabilityBucket[];
|
||||
};
|
||||
|
||||
type DashboardRouteHealthItem = {
|
||||
health: RouteHealthScore;
|
||||
};
|
||||
|
||||
function formatAvailabilityPercent(value: number | null | undefined): string {
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
@@ -143,40 +176,19 @@ function formatDateTimeRouteValue(value: Date): string {
|
||||
return `${value.getFullYear()}-${padDateTimeSegment(value.getMonth() + 1)}-${padDateTimeSegment(value.getDate())}T${padDateTimeSegment(value.getHours())}:${padDateTimeSegment(value.getMinutes())}`;
|
||||
}
|
||||
|
||||
function buildSiteLogsRoute(
|
||||
siteId: number,
|
||||
range?: { from: Date; to: Date },
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("siteId", String(siteId));
|
||||
if (range) {
|
||||
params.set("from", formatDateTimeRouteValue(range.from));
|
||||
params.set("to", formatDateTimeRouteValue(range.to));
|
||||
}
|
||||
return `/logs?${params.toString()}`;
|
||||
function buildSiteLast24hOpsRoute(siteId: number): string {
|
||||
return `/ops?siteId=${encodeURIComponent(String(siteId))}`;
|
||||
}
|
||||
|
||||
function buildSiteLast24hLogsRoute(siteId: number): string {
|
||||
const now = new Date();
|
||||
const from = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours() - 23,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const to = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours() + 1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
return buildSiteLogsRoute(siteId, { from, to });
|
||||
function formatDashboardLatency(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value) || value <= 0) {
|
||||
return "暂无延迟";
|
||||
}
|
||||
return `${Math.round(value).toLocaleString()}ms`;
|
||||
}
|
||||
|
||||
function formatDashboardLabelValue(label: string, value: string, language: string): string {
|
||||
return language === "en" ? `${label}: ${value}` : `${label}:${value}`;
|
||||
}
|
||||
|
||||
function parseAvailabilityBucketStart(startUtc?: string | null): Date | null {
|
||||
@@ -214,16 +226,21 @@ function formatAvailabilityBucketLabel(bucket: SiteAvailabilityBucket): string {
|
||||
return `${parsed.getFullYear()}-${padDateTimeSegment(parsed.getMonth() + 1)}-${padDateTimeSegment(parsed.getDate())} ${padDateTimeSegment(parsed.getHours())}:${padDateTimeSegment(parsed.getMinutes())}:${padDateTimeSegment(parsed.getSeconds())}`;
|
||||
}
|
||||
|
||||
function buildAvailabilityBucketLogsRoute(
|
||||
function buildAvailabilityBucketOpsRoute(
|
||||
siteId: number,
|
||||
bucket: SiteAvailabilityBucket,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("siteId", String(siteId));
|
||||
const start =
|
||||
parseAvailabilityBucketStart(bucket.startUtc) ||
|
||||
parseAvailabilityBucketLabel(bucket.label);
|
||||
if (!start) return buildSiteLast24hLogsRoute(siteId);
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||
return buildSiteLogsRoute(siteId, { from: start, to: end });
|
||||
if (start) {
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||
params.set("from", formatDateTimeRouteValue(start));
|
||||
params.set("to", formatDateTimeRouteValue(end));
|
||||
}
|
||||
return `/ops?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
@@ -232,6 +249,7 @@ export default function Dashboard({
|
||||
adminName?: string;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const { language, t } = useOptionalI18n();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [insightsData, setInsightsData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -242,6 +260,7 @@ export default function Dashboard({
|
||||
const [siteTrend, setSiteTrend] = useState<any[]>([]);
|
||||
const [siteLoading, setSiteLoading] = useState(true);
|
||||
const [sites, setSites] = useState<any[]>([]);
|
||||
const [routeRows, setRouteRows] = useState<RouteSummaryRow[]>([]);
|
||||
const [chartsReady, setChartsReady] = useState(false);
|
||||
const [siteSpeedStates, setSiteSpeedStates] = useState<
|
||||
Record<string, SiteSpeedState>
|
||||
@@ -249,7 +268,12 @@ export default function Dashboard({
|
||||
const [trendDays, setTrendDays] = useState(7);
|
||||
const [showInactiveSites, setShowInactiveSites] = useState(false);
|
||||
const toast = useToast();
|
||||
const normalizedAdminName = (adminName || "").trim() || "\u7ba1\u7406\u5458";
|
||||
const rawAdminName = (adminName || "").trim();
|
||||
const normalizedAdminName =
|
||||
rawAdminName && rawAdminName !== "\u7ba1\u7406\u5458"
|
||||
? rawAdminName
|
||||
: t("\u7ba1\u7406\u5458");
|
||||
const greetingText = `${t(getGreeting())}${language === "en" ? ", " : "\uFF0C"}${normalizedAdminName}`;
|
||||
|
||||
const getSiteSpeedKey = (site: any, idx: number) => String(site?.id ?? idx);
|
||||
|
||||
@@ -257,6 +281,26 @@ export default function Dashboard({
|
||||
setSiteSpeedStates((current) => ({ ...current, [siteKey]: nextState }));
|
||||
};
|
||||
|
||||
const handleTrendRangeKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
|
||||
const currentIndex = TREND_DAY_OPTIONS.findIndex((days) => days === trendDays);
|
||||
if (event.key === "Home") {
|
||||
setTrendDays(TREND_DAY_OPTIONS[0]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "End") {
|
||||
setTrendDays(TREND_DAY_OPTIONS[TREND_DAY_OPTIONS.length - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = event.key === "ArrowRight" ? 1 : -1;
|
||||
const safeCurrentIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
const nextIndex = (safeCurrentIndex + direction + TREND_DAY_OPTIONS.length) % TREND_DAY_OPTIONS.length;
|
||||
setTrendDays(TREND_DAY_OPTIONS[nextIndex]);
|
||||
};
|
||||
|
||||
const load = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
@@ -269,7 +313,7 @@ export default function Dashboard({
|
||||
);
|
||||
setData(result);
|
||||
} catch (err: any) {
|
||||
const message = err?.message || "加载仪表盘失败";
|
||||
const message = err?.message || t("加载仪表盘失败");
|
||||
setError(message);
|
||||
if (silent) toast.error(message);
|
||||
} finally {
|
||||
@@ -277,7 +321,7 @@ export default function Dashboard({
|
||||
setRefreshing(false);
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
[toast, t],
|
||||
);
|
||||
|
||||
const loadInsights = useCallback(async (forceRefresh = false) => {
|
||||
@@ -316,6 +360,15 @@ export default function Dashboard({
|
||||
[trendDays],
|
||||
);
|
||||
|
||||
const loadRouteHealth = useCallback(async () => {
|
||||
try {
|
||||
const routes = await api.getRoutesSummary();
|
||||
setRouteRows(Array.isArray(routes) ? routes : []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load route health:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
@@ -328,6 +381,10 @@ export default function Dashboard({
|
||||
loadSiteStats();
|
||||
}, [loadSiteStats]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRouteHealth();
|
||||
}, [loadRouteHealth]);
|
||||
|
||||
useEffect(() => scheduleDashboardIdleWork(() => setChartsReady(true)), []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -459,7 +516,7 @@ export default function Dashboard({
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<h2 className="greeting" style={{ marginBottom: 24 }}>
|
||||
{getGreeting() + "\uFF0C" + normalizedAdminName}
|
||||
{greetingText}
|
||||
</h2>
|
||||
<div className="card" style={{ padding: 48, textAlign: "center" }}>
|
||||
<div
|
||||
@@ -489,7 +546,7 @@ export default function Dashboard({
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>加载失败</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t("加载失败")}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
@@ -500,7 +557,7 @@ export default function Dashboard({
|
||||
{error}
|
||||
</div>
|
||||
<button onClick={() => load()} className="btn btn-soft-primary">
|
||||
重试
|
||||
{t("重试")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -565,18 +622,39 @@ export default function Dashboard({
|
||||
return bScore - aScore;
|
||||
})
|
||||
.slice(0, 3);
|
||||
const routeHealthItems: DashboardRouteHealthItem[] = routeRows
|
||||
.map((route) => ({
|
||||
health: buildRouteHealthScore(route),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const toneRank = { danger: 0, warning: 1, good: 2, muted: 3 };
|
||||
const rankDelta = toneRank[a.health.tone] - toneRank[b.health.tone];
|
||||
if (rankDelta !== 0) return rankDelta;
|
||||
return a.health.score - b.health.score;
|
||||
});
|
||||
const routeHealthCounts = routeHealthItems.reduce(
|
||||
(counts, item) => {
|
||||
counts[item.health.tone] += 1;
|
||||
return counts;
|
||||
},
|
||||
{ good: 0, warning: 0, danger: 0, muted: 0 },
|
||||
);
|
||||
const routeRiskItems = routeHealthItems.filter(
|
||||
(item) => item.health.tone === "danger" || item.health.tone === "warning",
|
||||
);
|
||||
const routeRiskCount = routeRiskItems.length;
|
||||
const overallHealthTone =
|
||||
proxySuccessRate >= 99 && siteRiskCount === 0
|
||||
proxySuccessRate >= 99 && siteRiskCount === 0 && routeRiskCount === 0
|
||||
? "success"
|
||||
: proxySuccessRate >= 95 && siteRiskCount <= 2
|
||||
: proxySuccessRate >= 95 && siteRiskCount <= 2 && routeHealthCounts.danger === 0
|
||||
? "warning"
|
||||
: "danger";
|
||||
const overallHealthLabel =
|
||||
overallHealthTone === "success"
|
||||
? "稳定"
|
||||
? t("稳定")
|
||||
: overallHealthTone === "warning"
|
||||
? "需关注"
|
||||
: "异常";
|
||||
? t("需关注")
|
||||
: t("异常");
|
||||
const spendTone =
|
||||
todaySpend <= todayReward
|
||||
? "success"
|
||||
@@ -586,28 +664,65 @@ export default function Dashboard({
|
||||
const throughputLabel =
|
||||
requestsPerMinute > 0
|
||||
? `${Math.round(requestsPerMinute).toLocaleString()} RPM`
|
||||
: "暂无流量";
|
||||
: t("暂无流量");
|
||||
const proxyFailed24h = Math.max(0, proxy24hTotal - proxy24hSuccess);
|
||||
const avgActiveSiteLatency = activeSites.length > 0
|
||||
? Math.round(
|
||||
activeSites.reduce(
|
||||
(total, site) => total + safeNumber(site.averageLatencyMs),
|
||||
0,
|
||||
) / activeSites.length,
|
||||
)
|
||||
: 0;
|
||||
const healthSummary =
|
||||
overallHealthTone === "success"
|
||||
? t("平台运行平稳,可以继续观察吞吐和成本变化。")
|
||||
: overallHealthTone === "warning"
|
||||
? t("部分指标需要留意,建议优先查看风险站点、路由和运维监控。")
|
||||
: t("平台存在明显异常,建议立即进入运维监控检查路由、站点和请求异常。");
|
||||
const dashboardHeroSignals = [
|
||||
{
|
||||
label: t("24h 失败"),
|
||||
value: Math.round(proxyFailed24h).toLocaleString(),
|
||||
tone: proxyFailed24h > 0 ? "warning" : "success",
|
||||
},
|
||||
{
|
||||
label: t("平均延迟"),
|
||||
value: avgActiveSiteLatency > 0 ? `${avgActiveSiteLatency}ms` : t("暂无"),
|
||||
tone: avgActiveSiteLatency > 1800 ? "warning" : "success",
|
||||
},
|
||||
{
|
||||
label: t("账户启用"),
|
||||
value: `${activeAccountRate.toFixed(0)}%`,
|
||||
tone: activeAccountRate >= 80 ? "success" : "warning",
|
||||
},
|
||||
{
|
||||
label: "Tokens/min",
|
||||
value: formatCompactTokenMetric(tokensPerMinute),
|
||||
tone: requestsPerMinute > 0 ? "primary" : "muted",
|
||||
},
|
||||
];
|
||||
const operationsPulse = [
|
||||
{
|
||||
label: "整体状态",
|
||||
label: t("整体状态"),
|
||||
value: overallHealthLabel,
|
||||
note: `${proxySuccessRate.toFixed(1)}% 成功率`,
|
||||
note: `${proxySuccessRate.toFixed(1)}% ${t("成功率")}`,
|
||||
tone: overallHealthTone,
|
||||
},
|
||||
{
|
||||
label: "站点风险",
|
||||
label: t("站点风险"),
|
||||
value: `${siteRiskCount}`,
|
||||
note: `${activeSites.length}/${rawSiteAvailability.length} 活跃站点`,
|
||||
note: `${activeSites.length}/${rawSiteAvailability.length} ${t("活跃站点")}`,
|
||||
tone: siteRiskCount > 0 ? "warning" : "success",
|
||||
},
|
||||
{
|
||||
label: "今日成本",
|
||||
label: t("今日成本"),
|
||||
value: `$${todaySpend.toFixed(2)}`,
|
||||
note: `奖励 $${todayReward.toFixed(2)}`,
|
||||
note: `${t("奖励")} $${todayReward.toFixed(2)}`,
|
||||
tone: spendTone,
|
||||
},
|
||||
{
|
||||
label: "当前吞吐",
|
||||
label: t("当前吞吐"),
|
||||
value: throughputLabel,
|
||||
note: `${formatCompactTokenMetric(tokensPerMinute)} TPM`,
|
||||
tone: requestsPerMinute > 0 ? "primary" : "muted",
|
||||
@@ -615,35 +730,41 @@ export default function Dashboard({
|
||||
];
|
||||
const dashboardCommandLinks = [
|
||||
{
|
||||
to: "/routes",
|
||||
label: "路由健康",
|
||||
value: `${proxySuccessRate.toFixed(1)}%`,
|
||||
note: "24h 成功率",
|
||||
tone: proxySuccessRate >= 99 ? "success" : proxySuccessRate >= 95 ? "warning" : "danger",
|
||||
to: "/ops",
|
||||
label: t("运维监控"),
|
||||
value: overallHealthLabel,
|
||||
note: t("值班入口"),
|
||||
tone: overallHealthTone,
|
||||
},
|
||||
{
|
||||
to: "/logs",
|
||||
label: "使用日志",
|
||||
value: Math.round(proxy24hTotal).toLocaleString(),
|
||||
note: "最近 24h 请求",
|
||||
tone: "primary",
|
||||
to: "/routes",
|
||||
label: t("路由健康"),
|
||||
value: routeHealthItems.length > 0 ? String(routeRiskCount) : t("暂无"),
|
||||
note: routeHealthItems.length > 0 ? (routeRiskCount > 0 ? t("需处理路由") : t("配置正常")) : t("等待路由"),
|
||||
tone: routeHealthItems.length > 0 ? (routeHealthCounts.danger > 0 ? "danger" : routeRiskCount > 0 ? "warning" : "success") : "muted",
|
||||
},
|
||||
{
|
||||
to: "/accounts",
|
||||
label: t("连接管理"),
|
||||
value: `${Math.round(activeAccounts)}/${Math.round(totalAccounts)}`,
|
||||
note: t("账号健康"),
|
||||
tone: activeAccountRate >= 80 ? "success" : "warning",
|
||||
},
|
||||
{
|
||||
to: "/sites",
|
||||
label: "站点管理",
|
||||
label: t("站点管理"),
|
||||
value: String(siteRiskCount),
|
||||
note: siteRiskCount > 0 ? "需关注站点" : "风险站点",
|
||||
note: siteRiskCount > 0 ? t("需关注站点") : t("风险站点"),
|
||||
tone: siteRiskCount > 0 ? "warning" : "success",
|
||||
},
|
||||
{
|
||||
to: "/settings/notify",
|
||||
label: "通知设置",
|
||||
value: `${activeAccountRate.toFixed(0)}%`,
|
||||
note: "账户启用率",
|
||||
tone: activeAccountRate >= 80 ? "success" : "warning",
|
||||
to: "/ip-management",
|
||||
label: t("IP 管理"),
|
||||
value: siteRiskCount > 0 ? t("检查") : t("正常"),
|
||||
note: t("代理与出口"),
|
||||
tone: siteRiskCount > 0 ? "warning" : "success",
|
||||
},
|
||||
];
|
||||
|
||||
const getLatencyColor = (ms: number) =>
|
||||
ms <= 500
|
||||
? "var(--color-success)"
|
||||
@@ -662,11 +783,11 @@ export default function Dashboard({
|
||||
const speedState = siteSpeedStates[siteKey];
|
||||
|
||||
if (!speedState || speedState.status === "loading") {
|
||||
return speedState ? "..." : "测速";
|
||||
return speedState ? "..." : t("测速");
|
||||
}
|
||||
|
||||
if (speedState.status === "timeout") {
|
||||
return "超时";
|
||||
return t("超时");
|
||||
}
|
||||
|
||||
const ms = speedState.ms;
|
||||
@@ -696,13 +817,30 @@ export default function Dashboard({
|
||||
<div className="dashboard-page animate-fade-in">
|
||||
<section className="console-hero dashboard-hero">
|
||||
<div className="console-hero-copy">
|
||||
<span className="console-kicker">BossAI Console</span>
|
||||
<h1>{getGreeting()},{normalizedAdminName}</h1>
|
||||
<p>统一观察模型路由、站点健康、账户余额、请求吞吐和计费成本。这里是整个平台的运营控制台。</p>
|
||||
<div className="dashboard-hero-topline">
|
||||
<span className="console-kicker">BossAI Console</span>
|
||||
<div className="dashboard-hero-status" data-tone={overallHealthTone}>
|
||||
<span className="dashboard-hero-status-dot" aria-hidden="true" />
|
||||
<strong>{overallHealthLabel}</strong>
|
||||
<span>{healthSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-hero-maincopy">
|
||||
<h1>{greetingText}</h1>
|
||||
<p>{t("统一观察模型路由、站点健康、账户余额、请求吞吐和计费成本。这里是整个平台的运营控制台。")}</p>
|
||||
</div>
|
||||
<div className="dashboard-hero-signals" aria-label={t("关键运行信号")}>
|
||||
{dashboardHeroSignals.map((signal) => (
|
||||
<div key={signal.label} className="dashboard-hero-signal" data-tone={signal.tone}>
|
||||
<span>{signal.label}</span>
|
||||
<strong>{signal.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-hero-rail" aria-label="实时运营摘要">
|
||||
<div className="console-hero-rail" aria-label={t("实时运营摘要")}>
|
||||
<div>
|
||||
<span>24h 请求</span>
|
||||
<span>{t("24h 请求")}</span>
|
||||
<strong>{Math.round(proxy24hTotal).toLocaleString()}</strong>
|
||||
</div>
|
||||
<div>
|
||||
@@ -710,7 +848,7 @@ export default function Dashboard({
|
||||
<strong>{Math.round(requestsPerMinute).toLocaleString()}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>站点</span>
|
||||
<span>{t("站点")}</span>
|
||||
<strong>{activeSites.length}/{rawSiteAvailability.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -720,11 +858,12 @@ export default function Dashboard({
|
||||
void load(true);
|
||||
void loadInsights(true);
|
||||
void loadSiteStats(true);
|
||||
void loadRouteHealth();
|
||||
}}
|
||||
disabled={refreshing}
|
||||
className="topbar-icon-btn"
|
||||
data-tooltip="刷新"
|
||||
aria-label="刷新"
|
||||
data-tooltip={t("刷新")}
|
||||
aria-label={t("刷新")}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
@@ -747,7 +886,7 @@ export default function Dashboard({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-war-room" aria-label="运营战情室">
|
||||
<section className="dashboard-war-room" aria-label={t("运营战情室")}>
|
||||
<div className="dashboard-pulse-grid">
|
||||
{operationsPulse.map((item) => (
|
||||
<div className="dashboard-pulse-card" data-tone={item.tone} key={item.label}>
|
||||
@@ -760,36 +899,53 @@ export default function Dashboard({
|
||||
<div className="dashboard-risk-panel">
|
||||
<div className="dashboard-risk-head">
|
||||
<div>
|
||||
<span>风险雷达</span>
|
||||
<strong>{riskySites.length > 0 ? "优先检查这些站点" : "暂无明显站点风险"}</strong>
|
||||
<span>{t("风险雷达")}</span>
|
||||
<strong>{riskySites.length > 0 ? t("优先检查这些站点") : t("暂无明显站点风险")}</strong>
|
||||
</div>
|
||||
<Link to="/sites" className="btn btn-ghost">站点管理</Link>
|
||||
<DashboardLink to="/sites" className="btn btn-ghost">{t("站点管理")}</DashboardLink>
|
||||
</div>
|
||||
{riskySites.length > 0 ? (
|
||||
<div className="dashboard-risk-list">
|
||||
{riskySites.map((site) => (
|
||||
<Link
|
||||
<article
|
||||
key={site.siteId}
|
||||
to={buildSiteLast24hLogsRoute(site.siteId)}
|
||||
className="dashboard-risk-row"
|
||||
>
|
||||
<strong>{site.siteName}</strong>
|
||||
<span>{formatAvailabilityPercent(site.availabilityPercent)}</span>
|
||||
<em>{site.averageLatencyMs != null ? `${site.averageLatencyMs}ms` : "暂无延迟"}</em>
|
||||
</Link>
|
||||
<div className="dashboard-risk-row-main">
|
||||
<strong>{site.siteName}</strong>
|
||||
<span>{formatAvailabilityPercent(site.availabilityPercent)}</span>
|
||||
<em>{t(formatDashboardLatency(site.averageLatencyMs))}</em>
|
||||
</div>
|
||||
<DiagnosticActionGroup
|
||||
className="dashboard-risk-actions"
|
||||
actions={[
|
||||
{
|
||||
label: t("运维监控"),
|
||||
href: buildSiteLast24hOpsRoute(site.siteId),
|
||||
hint: t("查看该站点 24h 请求"),
|
||||
tone: (site.availabilityPercent ?? 100) < 95 ? "danger" : "primary",
|
||||
},
|
||||
{
|
||||
label: t("站点管理"),
|
||||
href: "/sites",
|
||||
hint: t("检查代理和模型配置"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-risk-empty">
|
||||
站点可用性、平均延迟和失败数都处于可接受范围。
|
||||
{t("站点可用性、平均延迟和失败数都处于可接受范围。")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav className="dashboard-command-strip" aria-label="运营快捷入口">
|
||||
<nav className="dashboard-command-strip" aria-label={t("运营快捷入口")}>
|
||||
{dashboardCommandLinks.map((item) => (
|
||||
<Link
|
||||
<DashboardLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="dashboard-command-item"
|
||||
@@ -798,7 +954,7 @@ export default function Dashboard({
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.value}</strong>
|
||||
<em>{item.note}</em>
|
||||
</Link>
|
||||
</DashboardLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -813,7 +969,7 @@ export default function Dashboard({
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
账户数据
|
||||
{t("账户数据")}
|
||||
</div>
|
||||
<div className="stat-card-row">
|
||||
<div className="stat-icon stat-icon-blue">
|
||||
@@ -833,7 +989,7 @@ export default function Dashboard({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">当前余额</div>
|
||||
<div className="stat-label">{t("当前余额")}</div>
|
||||
<div className="stat-value animate-count-up">
|
||||
${totalBalance.toFixed(2)}
|
||||
</div>
|
||||
@@ -848,7 +1004,7 @@ export default function Dashboard({
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
今日 +{todayReward.toFixed(2)}
|
||||
{t("今日")} +{todayReward.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -870,7 +1026,7 @@ export default function Dashboard({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">累计消耗</div>
|
||||
<div className="stat-label">{t("累计消耗")}</div>
|
||||
<div className="stat-value animate-count-up">
|
||||
${totalUsed.toFixed(2)}
|
||||
</div>
|
||||
@@ -885,7 +1041,7 @@ export default function Dashboard({
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
今日 -{todaySpend.toFixed(2)}
|
||||
{t("今日")} -{todaySpend.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -901,7 +1057,7 @@ export default function Dashboard({
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
使用统计
|
||||
{t("使用统计")}
|
||||
</div>
|
||||
<div className="stat-card-row">
|
||||
<div className="stat-icon stat-icon-yellow">
|
||||
@@ -921,7 +1077,7 @@ export default function Dashboard({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">24h 请求</div>
|
||||
<div className="stat-label">{t("24h 请求")}</div>
|
||||
<div className="stat-value animate-count-up">
|
||||
{Math.round(proxy24hTotal).toLocaleString()}
|
||||
</div>
|
||||
@@ -945,7 +1101,7 @@ export default function Dashboard({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">成功请求</div>
|
||||
<div className="stat-label">{t("成功请求")}</div>
|
||||
<div className="stat-value animate-count-up">
|
||||
{Math.round(proxy24hSuccess).toLocaleString()}
|
||||
</div>
|
||||
@@ -963,7 +1119,7 @@ export default function Dashboard({
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
资源消耗
|
||||
{t("资源消耗")}
|
||||
</div>
|
||||
<div className="stat-card-row">
|
||||
<div className="stat-icon stat-icon-pink">
|
||||
@@ -983,7 +1139,7 @@ export default function Dashboard({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">活跃账户</div>
|
||||
<div className="stat-label">{t("活跃账户")}</div>
|
||||
<div className="stat-value animate-count-up">
|
||||
{Math.round(activeAccounts)}/{Math.round(totalAccounts)}
|
||||
</div>
|
||||
@@ -1025,7 +1181,7 @@ export default function Dashboard({
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
签到状态
|
||||
{t("签到状态")}
|
||||
</div>
|
||||
<div className="stat-card-row">
|
||||
<div className="stat-icon stat-icon-purple">
|
||||
@@ -1045,7 +1201,7 @@ export default function Dashboard({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">今日签到</div>
|
||||
<div className="stat-label">{t("今日签到")}</div>
|
||||
<div className="stat-value animate-count-up">
|
||||
{Math.round(todaySuccess)}/{Math.round(todayTotal)}
|
||||
</div>
|
||||
@@ -1069,7 +1225,7 @@ export default function Dashboard({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">成功率</div>
|
||||
<div className="stat-label">{t("成功率")}</div>
|
||||
<div className="stat-value animate-count-up">
|
||||
{todayTotal > 0
|
||||
? Math.round((todaySuccess / todayTotal) * 100)
|
||||
@@ -1090,7 +1246,7 @@ export default function Dashboard({
|
||||
d="M13 3v10h8M5 12h3m-3 4h6m-6 4h10a2 2 0 002-2V8.828a2 2 0 00-.586-1.414l-4.828-4.828A2 2 0 0010.172 2H5a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
性能指标
|
||||
{t("性能指标")}
|
||||
</div>
|
||||
<div className="stat-card-row">
|
||||
<div className="stat-icon stat-icon-blue">
|
||||
@@ -1115,7 +1271,7 @@ export default function Dashboard({
|
||||
{Math.round(requestsPerMinute).toLocaleString()}
|
||||
</div>
|
||||
<div className="dashboard-stat-note">
|
||||
最近 {performanceWindowSeconds} 秒请求
|
||||
{t("最近")} {performanceWindowSeconds} {t("秒请求")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1142,7 +1298,7 @@ export default function Dashboard({
|
||||
{formatCompactTokenMetric(tokensPerMinute)}
|
||||
</div>
|
||||
<div className="dashboard-stat-note">
|
||||
最近 {performanceWindowSeconds} 秒 Tokens
|
||||
{t("最近")} {performanceWindowSeconds} {t("秒 Tokens")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1166,16 +1322,27 @@ export default function Dashboard({
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
站点分析
|
||||
{t("站点分析")}
|
||||
</div>
|
||||
<div className="dashboard-range-tabs">
|
||||
{[7, 30, 90].map((d) => (
|
||||
<div
|
||||
className="dashboard-range-tabs"
|
||||
role="tablist"
|
||||
aria-label={t("站点趋势时间范围")}
|
||||
onKeyDown={handleTrendRangeKeyDown}
|
||||
>
|
||||
{TREND_DAY_OPTIONS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
id={`dashboard-range-tab-${d}`}
|
||||
type="button"
|
||||
onClick={() => setTrendDays(d)}
|
||||
className={`dashboard-range-tab ${trendDays === d ? "is-active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={trendDays === d}
|
||||
aria-controls="dashboard-site-trend-panel"
|
||||
tabIndex={trendDays === d ? 0 : -1}
|
||||
>
|
||||
{d}天
|
||||
{d}{t("天")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1194,7 +1361,12 @@ export default function Dashboard({
|
||||
<ChartFallback height={320} />
|
||||
)}
|
||||
</div>
|
||||
<div className="chart-panel-enter animate-slide-up stagger-7">
|
||||
<div
|
||||
id="dashboard-site-trend-panel"
|
||||
className="chart-panel-enter animate-slide-up stagger-7"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`dashboard-range-tab-${trendDays}`}
|
||||
>
|
||||
{chartsReady ? (
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
||||
@@ -1223,18 +1395,18 @@ export default function Dashboard({
|
||||
d="M3 12h4l3 8 4-16 3 8h4"
|
||||
/>
|
||||
</svg>
|
||||
站点可用性观测
|
||||
{t("站点可用性观测")}
|
||||
<span className="site-observability-count-badge">
|
||||
{activeSites.length}/{rawSiteAvailability.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="site-observability-subtitle">
|
||||
最近 24 小时 · 每色块 = 1h · 按使用量排序
|
||||
{t("最近 24 小时 · 每色块 = 1h · 按使用量排序")}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div className="site-observability-legend">
|
||||
<span className="site-observability-legend-text">低</span>
|
||||
<span className="site-observability-legend-text">{t("低")}</span>
|
||||
<span
|
||||
className="site-observability-legend-chip"
|
||||
style={{ background: getAvailabilityColor(0) }}
|
||||
@@ -1247,16 +1419,18 @@ export default function Dashboard({
|
||||
className="site-observability-legend-chip"
|
||||
style={{ background: getAvailabilityColor(100) }}
|
||||
/>
|
||||
<span className="site-observability-legend-text">高</span>
|
||||
<span className="site-observability-legend-text">{t("高")}</span>
|
||||
</div>
|
||||
{inactiveSites.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="site-observability-toggle-btn"
|
||||
aria-pressed={showInactiveSites}
|
||||
onClick={() => setShowInactiveSites((v) => !v)}
|
||||
>
|
||||
{showInactiveSites
|
||||
? "隐藏未使用"
|
||||
: `显示未使用 (${inactiveSites.length})`}
|
||||
? t("隐藏未使用")
|
||||
: `${t("显示未使用")} (${inactiveSites.length})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1303,10 +1477,10 @@ export default function Dashboard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={buildSiteLast24hLogsRoute(site.siteId)}
|
||||
<DashboardLink
|
||||
to={buildSiteLast24hOpsRoute(site.siteId)}
|
||||
className="site-observability-log-link-compact"
|
||||
title="查看日志"
|
||||
title={t("查看运维监控")}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -1322,7 +1496,7 @@ export default function Dashboard({
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</DashboardLink>
|
||||
</div>
|
||||
<div className="site-observability-card-metrics">
|
||||
<span
|
||||
@@ -1343,16 +1517,16 @@ export default function Dashboard({
|
||||
>
|
||||
{site.averageLatencyMs != null
|
||||
? `${site.averageLatencyMs}ms`
|
||||
: "暂无"}
|
||||
: t("暂无")}
|
||||
</span>
|
||||
<span className="site-observability-metric-sep">·</span>
|
||||
<span>{Math.round(site.totalRequests || 0)} 次</span>
|
||||
<span>{Math.round(site.totalRequests || 0)} {t("次")}</span>
|
||||
</div>
|
||||
<div className="site-availability-strip-compact">
|
||||
{site.buckets.map((bucket, index) => (
|
||||
<Link
|
||||
<DashboardLink
|
||||
key={`${site.siteId}-${index}`}
|
||||
to={buildAvailabilityBucketLogsRoute(site.siteId, bucket)}
|
||||
to={buildAvailabilityBucketOpsRoute(site.siteId, bucket)}
|
||||
className="site-availability-cell site-availability-cell-link site-availability-cell-pill"
|
||||
style={{
|
||||
background: getAvailabilityColor(
|
||||
@@ -1361,28 +1535,28 @@ export default function Dashboard({
|
||||
opacity: bucket.totalRequests > 0 ? 1 : 0.3,
|
||||
}}
|
||||
data-tooltip={[
|
||||
`时间:${formatAvailabilityBucketLabel(bucket)}`,
|
||||
formatDashboardLabelValue(t("时间"), formatAvailabilityBucketLabel(bucket), language),
|
||||
bucket.totalRequests > 0
|
||||
? `可用性:${formatAvailabilityPercent(bucket.availabilityPercent)}`
|
||||
: "可用性:无请求",
|
||||
`请求:${bucket.totalRequests} 次`,
|
||||
`成功/失败:${bucket.successCount}/${bucket.failedCount}`,
|
||||
? formatDashboardLabelValue(t("可用性"), formatAvailabilityPercent(bucket.availabilityPercent), language)
|
||||
: formatDashboardLabelValue(t("可用性"), t("无请求"), language),
|
||||
formatDashboardLabelValue(t("请求"), `${bucket.totalRequests} ${t("次")}`, language),
|
||||
formatDashboardLabelValue(t("成功/失败"), `${bucket.successCount}/${bucket.failedCount}`, language),
|
||||
bucket.averageLatencyMs != null
|
||||
? `平均响应:${bucket.averageLatencyMs}ms`
|
||||
: "平均响应:暂无",
|
||||
? formatDashboardLabelValue(t("平均响应"), `${bucket.averageLatencyMs}ms`, language)
|
||||
: formatDashboardLabelValue(t("平均响应"), t("暂无"), language),
|
||||
].join(" · ")}
|
||||
data-tooltip-align="start"
|
||||
title={[
|
||||
formatAvailabilityBucketLabel(bucket),
|
||||
bucket.totalRequests > 0
|
||||
? `可用性 ${formatAvailabilityPercent(bucket.availabilityPercent)}`
|
||||
: "无请求",
|
||||
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
|
||||
? `${t("可用性")} ${formatAvailabilityPercent(bucket.availabilityPercent)}`
|
||||
: t("无请求"),
|
||||
`${bucket.successCount} ${t("成功")} / ${bucket.failedCount} ${t("失败")}`,
|
||||
bucket.averageLatencyMs != null
|
||||
? `平均响应 ${bucket.averageLatencyMs}ms`
|
||||
: "平均响应 暂无",
|
||||
? `${t("平均响应")} ${bucket.averageLatencyMs}ms`
|
||||
: `${t("平均响应")} ${t("暂无")}`,
|
||||
].join(" | ")}
|
||||
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`}
|
||||
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} ${t("运维监控")}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1392,10 +1566,10 @@ export default function Dashboard({
|
||||
) : (
|
||||
<div className="site-observability-empty">
|
||||
<div className="site-observability-empty-title">
|
||||
暂无站点观测数据
|
||||
{t("暂无站点观测数据")}
|
||||
</div>
|
||||
<div className="site-observability-empty-note">
|
||||
有代理请求后,这里会自动生成每个站点的可用性条和平均响应速度。
|
||||
{t("有代理请求后,这里会自动生成每个站点的可用性条和平均响应速度。")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1441,7 +1615,7 @@ export default function Dashboard({
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
模型数据分析
|
||||
{t("模型数据分析")}
|
||||
</div>
|
||||
</div>
|
||||
{insightsLoading && !insightsData ? (
|
||||
@@ -1485,7 +1659,7 @@ export default function Dashboard({
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
站点信息
|
||||
{t("站点信息")}
|
||||
</span>
|
||||
{sites.length > 0 && (
|
||||
<button
|
||||
@@ -1517,7 +1691,7 @@ export default function Dashboard({
|
||||
}
|
||||
}),
|
||||
);
|
||||
toast.success("全部测速完成");
|
||||
toast.success(t("全部测速完成"));
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
@@ -1534,7 +1708,7 @@ export default function Dashboard({
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
一键测速
|
||||
{t("一键测速")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1594,7 +1768,7 @@ export default function Dashboard({
|
||||
toast.success(`${site.name}: ${ms}ms`);
|
||||
} catch {
|
||||
setSiteSpeedState(siteKey, { status: "timeout" });
|
||||
toast.error(`${site.name}: 测速失败`);
|
||||
toast.error(`${site.name}: ${t("测速失败")}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1644,7 +1818,7 @@ export default function Dashboard({
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
跳转
|
||||
{t("跳转")}
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
@@ -1696,7 +1870,7 @@ export default function Dashboard({
|
||||
color: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
代理端点可用
|
||||
{t("代理端点可用")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -1706,7 +1880,7 @@ export default function Dashboard({
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
使用{" "}
|
||||
{t("使用")}{" "}
|
||||
<code
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
@@ -1717,7 +1891,7 @@ export default function Dashboard({
|
||||
>
|
||||
/v1/chat/completions
|
||||
</code>{" "}
|
||||
访问
|
||||
{t("访问")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1735,7 +1909,7 @@ export default function Dashboard({
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
24h 活跃调用
|
||||
{t("24h 活跃调用")}
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
{proxy24hTotal > 0
|
||||
|
||||
@@ -0,0 +1,964 @@
|
||||
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));');
|
||||
});
|
||||
});
|
||||
+454
-37
@@ -1,7 +1,15 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api, fetchSessionResponse, userApi, type DigitalHumanJob, type WorkflowSummary, type WorkspaceAsset } from '../api.js';
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { api, fetchSessionResponse, userApi, type DigitalHumanJob, type DigitalHumanStatusResponse, type WorkflowSummary, type WorkspaceAsset } from '../api.js';
|
||||
import type { SessionAudience } from '../authSession.js';
|
||||
import { trapFocusInContainer } from '../components/focusTrap.js';
|
||||
import {
|
||||
buildCreationFailureHref,
|
||||
buildCreationModelTesterHref,
|
||||
buildCreationUsageHref,
|
||||
} from './helpers/creationTraceLinks.js';
|
||||
import { BillingRechargeAlert, isQuotaRechargeError } from './helpers/billingRecharge.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
|
||||
type AudioRole = 'lip_sync' | 'voice_reference' | 'both';
|
||||
type UploadPurpose = 'avatar' | 'audio';
|
||||
@@ -10,12 +18,15 @@ type MappingKey = keyof typeof DEFAULT_MAPPING;
|
||||
const DEFAULT_MAPPING = {
|
||||
prompt: { nodeId: '', field: 'prompt' },
|
||||
script: { nodeId: '', field: 'text' },
|
||||
model: { nodeId: '', field: 'model' },
|
||||
avatar: { nodeId: '', field: 'image' },
|
||||
referenceAudio: { nodeId: '', field: 'audio' },
|
||||
voiceReference: { nodeId: '', field: 'audio' },
|
||||
output: { nodeId: '', field: 'video' },
|
||||
};
|
||||
|
||||
const REQUIRED_MAPPING_KEYS: MappingKey[] = ['prompt', 'script', 'avatar', 'referenceAudio'];
|
||||
|
||||
const AUDIO_ROLES: Array<{ value: AudioRole; label: string; description: string }> = [
|
||||
{ value: 'both', label: '口型 + 音色参考', description: '同一参考音频同时注入口型和音色节点。' },
|
||||
{ value: 'lip_sync', label: '只驱动口型', description: '人物按参考音频开口,适合已有成品配音。' },
|
||||
@@ -46,12 +57,6 @@ function parseError(response: Response) {
|
||||
});
|
||||
}
|
||||
|
||||
function graphMapping(workflow?: WorkflowSummary | null) {
|
||||
return workflow?.graph?.metadata?.digitalHuman?.nodeMapping
|
||||
|| workflow?.graph?.digitalHuman?.nodeMapping
|
||||
|| DEFAULT_MAPPING;
|
||||
}
|
||||
|
||||
function mappingValue(mapping: any, key: MappingKey) {
|
||||
const value = mapping?.[key] || DEFAULT_MAPPING[key];
|
||||
if (typeof value === 'string') return { nodeId: value, field: DEFAULT_MAPPING[key].field };
|
||||
@@ -65,10 +70,75 @@ function jobAssets(job: DigitalHumanJob) {
|
||||
return Array.isArray(job.result?.assets) ? job.result.assets : [];
|
||||
}
|
||||
|
||||
function jobPromptId(job: DigitalHumanJob) {
|
||||
return job.result?.promptId || job.result?.prompt_id || '';
|
||||
}
|
||||
|
||||
function jobTemplateName(job: DigitalHumanJob) {
|
||||
return job.result?.templateName || job.result?.request?.templateName || '';
|
||||
}
|
||||
|
||||
function jobKeyName(job: DigitalHumanJob) {
|
||||
return job.result?.keyName || job.result?.request?.keyName || '';
|
||||
}
|
||||
|
||||
function jobKeyId(job: DigitalHumanJob) {
|
||||
return job.result?.keyId || job.result?.downstreamKeyId || job.result?.request?.keyId || job.result?.request?.downstreamKeyId || null;
|
||||
}
|
||||
|
||||
function jobModelName(job: DigitalHumanJob) {
|
||||
const request = job.result?.request || {};
|
||||
const comfyApiModel = normalizeComfyApiVideoModelName(
|
||||
job.result?.comfyApiModel
|
||||
|| job.result?.comfy_api_model
|
||||
|| request.comfyApiModel
|
||||
|| request.comfy_api_model
|
||||
|| request.apiModel
|
||||
|| request.api_model
|
||||
|| '',
|
||||
);
|
||||
return comfyApiModel || job.result?.model || request.model || jobTemplateName(job) || 'comfy-digital-human';
|
||||
}
|
||||
|
||||
function jobTracePrompt(job: DigitalHumanJob) {
|
||||
return job.result?.script || job.result?.request?.script || job.result?.prompt || job.result?.request?.prompt || job.title || job.id;
|
||||
}
|
||||
|
||||
function jobTraceQuery(job: DigitalHumanJob) {
|
||||
return jobTracePrompt(job) || jobModelName(job);
|
||||
}
|
||||
|
||||
function jobAssetHref(asset: any, workspacePath: string) {
|
||||
if (asset?.remoteUrl) return String(asset.remoteUrl);
|
||||
if (asset?.publicId) return `${workspacePath}?q=${encodeURIComponent(asset.publicId)}`;
|
||||
return workspacePath;
|
||||
}
|
||||
|
||||
function jobAssetLabel(asset: any) {
|
||||
const kind = asset?.kind === 'video' ? '视频' : asset?.kind === 'audio' ? '音频' : asset?.kind === 'image' ? '图片' : '产物';
|
||||
if (asset?.publicId) return `工作空间 · ${kind} · ${asset.publicId}`;
|
||||
if (asset?.remoteUrl) return `远端产物 · ${kind}`;
|
||||
return `产物 · ${asset?.status || '已记录'}`;
|
||||
}
|
||||
|
||||
function statusModeLabel(status?: DigitalHumanStatusResponse | null) {
|
||||
if (!status?.mode) return '同步中';
|
||||
if (status.mode === 'comfy-native') return 'Comfy API';
|
||||
if (status.mode === 'boosapi-video-route') return 'BoosAPI 视频路由';
|
||||
return status.mode;
|
||||
}
|
||||
|
||||
function comfyStatusText(status?: DigitalHumanStatusResponse | null) {
|
||||
if (!status) return '状态同步中';
|
||||
if (status.comfy?.configured) return status.comfy.backend ? `Comfy ${status.comfy.backend}` : 'Comfy API 已配置';
|
||||
if (status.runninghub?.configured) return 'RunningHub 已配置';
|
||||
return '未配置 native Comfy';
|
||||
}
|
||||
|
||||
function templateHasMapping(workflow: WorkflowSummary) {
|
||||
const mapping = graphMapping(workflow);
|
||||
return ['prompt', 'script', 'avatar', 'referenceAudio', 'output'].every((key) => {
|
||||
return Boolean(mappingValue(mapping, key as MappingKey).nodeId);
|
||||
return REQUIRED_MAPPING_KEYS.every((key) => {
|
||||
return Boolean(mappingValue(mapping, key).nodeId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,10 +179,15 @@ function collectPromptNodes(graph: any) {
|
||||
const id = String(node.id || node.nodeId || '');
|
||||
if (!id || nodes.some((item) => item.id === id)) continue;
|
||||
const classType = String(node.type || node.class_type || '');
|
||||
const inputs = node.inputs && typeof node.inputs === 'object'
|
||||
? node.inputs
|
||||
: node.params && typeof node.params === 'object'
|
||||
? node.params
|
||||
: {};
|
||||
nodes.push({
|
||||
id,
|
||||
classType,
|
||||
inputs: {},
|
||||
inputs,
|
||||
title: String(node.title || node.label || classType || id),
|
||||
});
|
||||
}
|
||||
@@ -143,6 +218,7 @@ function inferMappingFromWorkflow(workflow: WorkflowSummary | null) {
|
||||
const nodes = collectPromptNodes(workflow.graph);
|
||||
const promptNode = findNode(nodes, ['prompt', 'positive', 'textgenerate', 'text generate', 'wan', 'ltx'], ['prompt']);
|
||||
const scriptNode = findNode(nodes, ['script', 'text', 'tts', 'voice', 'audio'], ['text', 'script', 'caption']);
|
||||
const modelNode = findNode(nodes, ['bytedance', 'seedance', 'wan', 'ltx', 'kling', 'video'], ['model', 'model_name', 'ckpt_name']);
|
||||
const avatarNode = findNode(nodes, ['loadimage', 'image', 'video', 'avatar', 'portrait', 'person'], ['image', 'video', 'avatar']);
|
||||
const audioNode = findNode(nodes, ['audio', 'sound', 'voice', 'tts', 'load audio'], ['audio', 'voice']);
|
||||
const outputNode = findNode(nodes, ['savevideo', 'save video', 'previewvideo', 'video combine', 'vhs', 'output'], ['video', 'filename_prefix'])
|
||||
@@ -151,6 +227,7 @@ function inferMappingFromWorkflow(workflow: WorkflowSummary | null) {
|
||||
return {
|
||||
prompt: { nodeId: promptNode?.id || '', field: promptNode?.inputs?.prompt !== undefined ? 'prompt' : 'text' },
|
||||
script: { nodeId: scriptNode?.id || promptNode?.id || '', field: scriptNode?.inputs?.script !== undefined ? 'script' : 'text' },
|
||||
model: { nodeId: modelNode?.id || '', field: modelNode?.inputs?.model_name !== undefined ? 'model_name' : modelNode?.inputs?.ckpt_name !== undefined ? 'ckpt_name' : 'model' },
|
||||
avatar: { nodeId: avatarNode?.id || '', field: avatarNode?.inputs?.video !== undefined ? 'video' : 'image' },
|
||||
referenceAudio: { nodeId: audioNode?.id || '', field: 'audio' },
|
||||
voiceReference: { nodeId: audioNode?.id || '', field: 'audio' },
|
||||
@@ -158,6 +235,115 @@ function inferMappingFromWorkflow(workflow: WorkflowSummary | null) {
|
||||
};
|
||||
}
|
||||
|
||||
function graphMapping(workflow?: WorkflowSummary | null) {
|
||||
return workflow?.graph?.metadata?.digitalHuman?.nodeMapping
|
||||
|| workflow?.graph?.digitalHuman?.nodeMapping
|
||||
|| inferMappingFromWorkflow(workflow || null);
|
||||
}
|
||||
|
||||
function workflowLooksDigitalHumanCapable(workflow: WorkflowSummary) {
|
||||
const graphText = JSON.stringify(workflow.graph || {}).toLowerCase();
|
||||
if ([
|
||||
'digitalhuman',
|
||||
'digital_human',
|
||||
'lip_sync',
|
||||
'lipsync',
|
||||
'voice_reference',
|
||||
'talkinghead',
|
||||
'talking_head',
|
||||
'bytedancedigitalhuman',
|
||||
].some((keyword) => graphText.includes(keyword))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nodes = collectPromptNodes(workflow.graph);
|
||||
const hasPrompt = Boolean(findNode(nodes, ['prompt', 'positive', 'textgenerate', 'text generate'], ['prompt', 'positive']));
|
||||
const hasScript = Boolean(findNode(nodes, ['script', 'tts', 'voice', 'audio', 'text'], ['text', 'script', 'caption']));
|
||||
const hasAvatar = Boolean(findNode(nodes, ['loadimage', 'load image', 'image', 'avatar', 'portrait', 'person'], ['image', 'avatar', 'video']));
|
||||
const hasAudio = Boolean(findNode(nodes, ['loadaudio', 'load audio', 'audio', 'sound', 'voice', 'tts'], ['audio', 'voice']));
|
||||
const hasVideoOutput = Boolean(findNode(nodes, ['savevideo', 'save video', 'previewvideo', 'video combine', 'vhs', 'output'], ['video', 'filename_prefix']));
|
||||
|
||||
return hasAvatar && hasAudio && hasVideoOutput && (hasPrompt || hasScript);
|
||||
}
|
||||
|
||||
function digitalHumanWorkflows(items: WorkflowSummary[]) {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((workflow) => {
|
||||
const key = workflow.publicId || String(workflow.id);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return workflowLooksDigitalHumanCapable(workflow);
|
||||
});
|
||||
}
|
||||
|
||||
function firstStringValue(value: any, fields: string[]): string {
|
||||
for (const field of fields) {
|
||||
const next = value?.[field];
|
||||
if (typeof next === 'string' && next.trim()) return next.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
const items: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
items.push(normalized);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function isComfyApiVideoModelName(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return [
|
||||
'seedance',
|
||||
'wan',
|
||||
'kling',
|
||||
'ltx',
|
||||
'hunyuanvideo',
|
||||
'video',
|
||||
't2v',
|
||||
'i2v',
|
||||
].some((keyword) => normalized.includes(keyword));
|
||||
}
|
||||
|
||||
function normalizeComfyApiVideoModelName(value?: string | null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (normalized === 'seedance-1-0-lite-t2v') return 'seedance-1-0-lite-t2v-250428';
|
||||
if (normalized === 'seedance-1-0-lite-i2v') return 'seedance-1-0-pro-i2v-250528';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function workflowModelNames(payload: any) {
|
||||
const rawItems = [
|
||||
...(Array.isArray(payload?.names) ? payload.names : []),
|
||||
...(Array.isArray(payload?.items) ? payload.items : []),
|
||||
...(Array.isArray(payload?.models) ? payload.models : []),
|
||||
...(Array.isArray(payload?.data) ? payload.data : []),
|
||||
];
|
||||
return uniqueNonEmpty(rawItems.map((item) => normalizeComfyApiVideoModelName(
|
||||
typeof item === 'string'
|
||||
? item
|
||||
: item?.name ?? item?.model ?? item?.value ?? item?.filename ?? item?.path
|
||||
)));
|
||||
}
|
||||
|
||||
function comfyApiModelFromWorkflow(workflow: WorkflowSummary | null) {
|
||||
if (!workflow) return '';
|
||||
const nodes = collectPromptNodes(workflow.graph);
|
||||
const modelFields = ['model', 'model_name', 'ckpt_name', 'unet_name'];
|
||||
const preferred = nodes.find((node) => (
|
||||
includesAny(`${node.classType} ${node.title}`, ['bytedance', 'seedance', 'wan', 'ltx', 'kling', 'video'])
|
||||
&& Boolean(firstStringValue(node.inputs, modelFields))
|
||||
));
|
||||
const fallback = nodes.find((node) => firstStringValue(node.inputs, modelFields));
|
||||
return normalizeComfyApiVideoModelName(firstStringValue((preferred || fallback)?.inputs, modelFields));
|
||||
}
|
||||
|
||||
function assetFileUrl(asset: WorkspaceAsset, audience: SessionAudience) {
|
||||
if (asset.remoteUrl) return asset.remoteUrl;
|
||||
if (!asset.hasLocalFile) return '';
|
||||
@@ -170,27 +356,31 @@ function assetName(asset: WorkspaceAsset) {
|
||||
}
|
||||
|
||||
function formatJobTime(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
return value ? formatDateTimeLocal(value) : '';
|
||||
}
|
||||
|
||||
export default function DigitalHuman() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
|
||||
const assetPickerTitleId = useId();
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const assetPickerDialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastAssetPickerActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
|
||||
const [workspaceAssets, setWorkspaceAssets] = useState<WorkspaceAsset[]>([]);
|
||||
const [comfyApiModels, setComfyApiModels] = useState<string[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [mapping, setMapping] = useState<any>(DEFAULT_MAPPING);
|
||||
const [avatarUrl, setAvatarUrl] = useState('');
|
||||
const [referenceAudioUrl, setReferenceAudioUrl] = useState('');
|
||||
const [productScript, setProductScript] = useState('今天给大家介绍这款商品。它的核心优势是上手简单、效果稳定,适合直播间高频讲解和短视频种草。');
|
||||
const [prompt, setPrompt] = useState('真人数字人主播,面向镜头自然讲解商品,口型准确,表情稳定,商业带货风格。');
|
||||
const [comfyApiModel, setComfyApiModel] = useState('');
|
||||
const [comfyApiModelTouched, setComfyApiModelTouched] = useState(false);
|
||||
const comfyApiModelTouchedRef = useRef(false);
|
||||
const [audioRole, setAudioRole] = useState<AudioRole>('both');
|
||||
const [ratio, setRatio] = useState('9:16');
|
||||
const [resolution, setResolution] = useState('1080p');
|
||||
@@ -199,20 +389,46 @@ export default function DigitalHuman() {
|
||||
const [uploading, setUploading] = useState<UploadPurpose | ''>('');
|
||||
const [assetPicker, setAssetPicker] = useState<UploadPurpose | ''>('');
|
||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||
const [status, setStatus] = useState<DigitalHumanStatusResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [billingError, setBillingError] = useState<unknown>(null);
|
||||
|
||||
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
||||
const digitalHumanWorkflowItems = useMemo(() => digitalHumanWorkflows(workflows), [workflows]);
|
||||
const selectableWorkflows = digitalHumanWorkflowItems.length > 0 ? digitalHumanWorkflowItems : workflows;
|
||||
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
||||
const templateComfyApiModel = comfyApiModelFromWorkflow(selectedWorkflow);
|
||||
const configuredComfyApiModels = useMemo(
|
||||
() => uniqueNonEmpty(comfyApiModels.filter(isComfyApiVideoModelName)),
|
||||
[comfyApiModels],
|
||||
);
|
||||
const comfyApiModelOptions = useMemo(
|
||||
() => uniqueNonEmpty([
|
||||
templateComfyApiModel,
|
||||
...configuredComfyApiModels,
|
||||
].filter(isComfyApiVideoModelName)),
|
||||
[configuredComfyApiModels, templateComfyApiModel],
|
||||
);
|
||||
const hasConfiguredComfyApiVideoModels = configuredComfyApiModels.length > 0;
|
||||
const hasSelectedComfyApiModel = Boolean(normalizeComfyApiVideoModelName(comfyApiModel).trim());
|
||||
const comfyApiModelHint = templateComfyApiModel
|
||||
? (hasConfiguredComfyApiVideoModels
|
||||
? `默认使用模板内 Comfy API 模型 ${templateComfyApiModel};video_models 另有 ${configuredComfyApiModels.length} 个候选`
|
||||
: `已使用模板内 Comfy API 模型 ${templateComfyApiModel}`)
|
||||
: hasConfiguredComfyApiVideoModels
|
||||
? `优先使用 Comfy API video_models;已读取 ${configuredComfyApiModels.length} 个视频模型`
|
||||
: '未读取到 Comfy API 视频模型,请先在 Comfy 模板或 video_models 中配置模型';
|
||||
const sortedWorkflows = useMemo(() => {
|
||||
return [...workflows].sort((a, b) => {
|
||||
return [...selectableWorkflows].sort((a, b) => {
|
||||
const score = (item: WorkflowSummary) => (templateHasMapping(item) ? 0 : 10)
|
||||
+ (item.isTemplate || item.visibility === 'template' ? 0 : item.visibility === 'public' ? 2 : 4);
|
||||
return score(a) - score(b) || a.name.localeCompare(b.name, 'zh-CN');
|
||||
});
|
||||
}, [workflows]);
|
||||
}, [selectableWorkflows]);
|
||||
const mapped = useMemo(() => ({
|
||||
prompt: mappingValue(mapping, 'prompt'),
|
||||
script: mappingValue(mapping, 'script'),
|
||||
model: mappingValue(mapping, 'model'),
|
||||
avatar: mappingValue(mapping, 'avatar'),
|
||||
referenceAudio: mappingValue(mapping, 'referenceAudio'),
|
||||
voiceReference: mappingValue(mapping, 'voiceReference'),
|
||||
@@ -224,13 +440,13 @@ export default function DigitalHuman() {
|
||||
['文案节点', mapped.script.nodeId],
|
||||
['人物素材节点', mapped.avatar.nodeId],
|
||||
['参考音频节点', mapped.referenceAudio.nodeId],
|
||||
['输出节点', mapped.output.nodeId],
|
||||
].filter(([, value]) => !value).map(([label]) => label);
|
||||
|
||||
const canSubmit = !!templateId
|
||||
&& !!avatarUrl.trim()
|
||||
&& !!referenceAudioUrl.trim()
|
||||
&& !!productScript.trim()
|
||||
&& hasSelectedComfyApiModel
|
||||
&& missingMapping.length === 0
|
||||
&& !submitting;
|
||||
const selectedTemplateReady = selectedWorkflow ? templateHasMapping(selectedWorkflow) || missingMapping.length === 0 : false;
|
||||
@@ -239,16 +455,99 @@ export default function DigitalHuman() {
|
||||
if (assetPicker === 'avatar') return asset.kind === 'image' || asset.kind === 'video';
|
||||
return false;
|
||||
});
|
||||
const closeAssetPicker = useCallback(() => {
|
||||
setAssetPicker('');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetPicker || typeof document === 'undefined' || !document.body?.style) return undefined;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [assetPicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (assetPicker) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastAssetPickerActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
const focusDialog = () => {
|
||||
const dialog = assetPickerDialogRef.current;
|
||||
if (!dialog) return;
|
||||
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && dialog.contains(currentActiveElement)) return;
|
||||
dialog.focus({ preventScroll: true });
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const animationFrame = window.requestAnimationFrame(focusDialog);
|
||||
return () => {
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}
|
||||
focusDialog();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastAssetPickerActiveElementRef.current?.isConnected !== false) {
|
||||
lastAssetPickerActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastAssetPickerActiveElementRef.current = null;
|
||||
return undefined;
|
||||
}, [assetPicker]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (lastAssetPickerActiveElementRef.current?.isConnected !== false) {
|
||||
lastAssetPickerActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastAssetPickerActiveElementRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetPicker || typeof document === 'undefined') return undefined;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeAssetPicker();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, assetPickerDialogRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [assetPicker, closeAssetPicker]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const [workflowPayload, jobPayload] = await Promise.all([
|
||||
const [statusPayload, workflowPayload, jobPayload, modelPayload] = await Promise.all([
|
||||
client.getDigitalHumanStatus(),
|
||||
client.getWorkflows({ include_public: true }),
|
||||
client.getDigitalHumanJobs({ limit: 30 }),
|
||||
client.getWorkflowModelFolder('video_models').catch(() => ({ success: true, names: [] as string[] })),
|
||||
]);
|
||||
setStatus(statusPayload);
|
||||
const nextWorkflows = Array.isArray(workflowPayload.items) ? workflowPayload.items : [];
|
||||
const nextDigitalHumanWorkflows = digitalHumanWorkflows(nextWorkflows);
|
||||
const nextSelectableWorkflows = nextDigitalHumanWorkflows.length > 0 ? nextDigitalHumanWorkflows : nextWorkflows;
|
||||
setWorkflows(nextWorkflows);
|
||||
setJobs(Array.isArray(jobPayload.jobs) ? jobPayload.jobs : []);
|
||||
setTemplateId((current) => current || nextWorkflows.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId || nextWorkflows[0]?.publicId || '');
|
||||
const modelNames = workflowModelNames(modelPayload).filter(isComfyApiVideoModelName);
|
||||
setComfyApiModels(modelNames);
|
||||
if (!comfyApiModelTouchedRef.current && modelNames[0]) {
|
||||
setComfyApiModel(modelNames[0]);
|
||||
}
|
||||
setTemplateId((current) => {
|
||||
if (current && nextSelectableWorkflows.some((item) => item.publicId === current)) {
|
||||
return current;
|
||||
}
|
||||
return nextSelectableWorkflows.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId
|
||||
|| nextSelectableWorkflows[0]?.publicId
|
||||
|| '';
|
||||
});
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -259,6 +558,11 @@ export default function DigitalHuman() {
|
||||
setMapping(graphMapping(selectedWorkflow));
|
||||
}, [selectedWorkflow?.publicId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (comfyApiModelTouched || comfyApiModelTouchedRef.current) return;
|
||||
setComfyApiModel(comfyApiModelOptions[0] || '');
|
||||
}, [comfyApiModelOptions, comfyApiModelTouched, selectedWorkflow?.publicId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobs.some((job) => !terminal(job.status))) return;
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -330,13 +634,14 @@ export default function DigitalHuman() {
|
||||
}
|
||||
if (assetPicker === 'avatar') setAvatarUrl(url);
|
||||
if (assetPicker === 'audio') setReferenceAudioUrl(url);
|
||||
setAssetPicker('');
|
||||
closeAssetPicker();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setBillingError(null);
|
||||
try {
|
||||
const payload = await client.createDigitalHumanJob({
|
||||
templateId,
|
||||
@@ -345,6 +650,7 @@ export default function DigitalHuman() {
|
||||
referenceAudioUrl: referenceAudioUrl.trim(),
|
||||
productScript: productScript.trim(),
|
||||
prompt: prompt.trim(),
|
||||
comfyApiModel: normalizeComfyApiVideoModelName(comfyApiModel) || undefined,
|
||||
audioRole,
|
||||
ratio,
|
||||
resolution,
|
||||
@@ -352,6 +658,7 @@ export default function DigitalHuman() {
|
||||
});
|
||||
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
|
||||
} catch (err: any) {
|
||||
setBillingError(isQuotaRechargeError(err) ? err : null);
|
||||
setError(err?.message || '数字人任务创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -376,6 +683,11 @@ export default function DigitalHuman() {
|
||||
setReferenceAudioUrl(request.referenceAudioUrl || '');
|
||||
setProductScript(request.productScript || '');
|
||||
setPrompt(request.prompt || prompt);
|
||||
const restoredModel = normalizeComfyApiVideoModelName(request.comfyApiModel || request.comfy_api_model || request.apiModel || request.api_model || '');
|
||||
setComfyApiModel(restoredModel);
|
||||
const restoredModelTouched = Boolean(String(restoredModel).trim());
|
||||
comfyApiModelTouchedRef.current = restoredModelTouched;
|
||||
setComfyApiModelTouched(restoredModelTouched);
|
||||
setAudioRole(request.audioRole || 'both');
|
||||
setRatio(request.ratio || '9:16');
|
||||
setResolution(request.resolution || '1080p');
|
||||
@@ -392,8 +704,8 @@ export default function DigitalHuman() {
|
||||
</div>
|
||||
<div className="console-hero-rail" aria-label="数字人任务摘要">
|
||||
<div>
|
||||
<span>模板</span>
|
||||
<strong>{sortedWorkflows.length}</strong>
|
||||
<span>后端</span>
|
||||
<strong>{statusModeLabel(status)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>任务</span>
|
||||
@@ -412,14 +724,41 @@ export default function DigitalHuman() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||
{billingError ? (
|
||||
<BillingRechargeAlert error={billingError} />
|
||||
) : error ? (
|
||||
<div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="digital-human-summary">
|
||||
<div className="digital-human-summary-card">
|
||||
<span>当前后端</span>
|
||||
<strong>{statusModeLabel(status)}</strong>
|
||||
<em>{status?.keyName ? `使用 ${status.keyName}` : '等待状态同步'}</em>
|
||||
</div>
|
||||
<div className="digital-human-summary-card">
|
||||
<span>Comfy 状态</span>
|
||||
<strong>{comfyStatusText(status)}</strong>
|
||||
<em>{status?.comfy?.baseUrl || status?.runninghub?.endpoint || '按系统配置选择通道'}</em>
|
||||
</div>
|
||||
<div className="digital-human-summary-card">
|
||||
<span>模板</span>
|
||||
<strong>{sortedWorkflows.length} 个可选</strong>
|
||||
<em>{selectedTemplateReady ? '当前模板可提交' : '需要补齐映射或素材'}</em>
|
||||
</div>
|
||||
<div className="digital-human-summary-card">
|
||||
<span>能力</span>
|
||||
<strong>{status?.capabilities?.length || 0} 项</strong>
|
||||
<em>口型、音色、工作空间归档</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="digital-human-layout">
|
||||
<section className="digital-human-panel">
|
||||
<div className="digital-human-section-head">
|
||||
<div>
|
||||
<h2>1. 选择工作流模板</h2>
|
||||
<p>优先选择已配置数字人节点映射的模板;普通 Comfy 工作流也可以用自动猜测补齐。</p>
|
||||
<p>优先选择已配置数字人节点映射的模板;这里只展示可识别为数字人视频的 Comfy 模板。</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>猜测映射</button>
|
||||
</div>
|
||||
@@ -432,6 +771,7 @@ export default function DigitalHuman() {
|
||||
key={workflow.publicId}
|
||||
type="button"
|
||||
className={`digital-human-template-card${active ? ' is-active' : ''}`}
|
||||
aria-pressed={active}
|
||||
onClick={() => setTemplateId(workflow.publicId)}
|
||||
>
|
||||
<span className="digital-human-template-top">
|
||||
@@ -464,7 +804,7 @@ export default function DigitalHuman() {
|
||||
<div className="digital-human-template-meta">
|
||||
<strong>{selectedWorkflow.name}</strong>
|
||||
<span>{selectedWorkflow.description || '这个模板需要配置节点映射后才能注入人物、文案和音频。'}</span>
|
||||
<span>{selectedTemplateReady ? '节点映射已准备好。' : '节点映射不完整,可点击“猜测映射”后再微调。'}</span>
|
||||
<span>{selectedTemplateReady ? '必需节点映射已准备好。输出节点可选,系统会从 Comfy 历史中自动识别产物。' : '必需节点映射不完整,可点击“猜测映射”后再微调。'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -543,6 +883,28 @@ export default function DigitalHuman() {
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>Comfy API 模型</span>
|
||||
<input
|
||||
list="digital-human-comfy-api-models"
|
||||
value={comfyApiModel}
|
||||
onChange={(event) => {
|
||||
setComfyApiModel(event.target.value);
|
||||
const touched = Boolean(event.target.value.trim());
|
||||
comfyApiModelTouchedRef.current = touched;
|
||||
setComfyApiModelTouched(touched);
|
||||
}}
|
||||
placeholder={comfyApiModelOptions[0] || '先在 Comfy API 模型候选中选择'}
|
||||
/>
|
||||
<datalist id="digital-human-comfy-api-models">
|
||||
{comfyApiModelOptions.map((model) => (
|
||||
<option key={model} value={model} />
|
||||
))}
|
||||
</datalist>
|
||||
<em className="money-printer-field-hint">
|
||||
{comfyApiModelHint}
|
||||
</em>
|
||||
</label>
|
||||
<label className="digital-human-checkbox">
|
||||
<input type="checkbox" checked={subtitles} onChange={(event) => setSubtitles(event.target.checked)} />
|
||||
<span>生成字幕</span>
|
||||
@@ -562,10 +924,11 @@ export default function DigitalHuman() {
|
||||
{([
|
||||
['prompt', '提示词节点'],
|
||||
['script', '文案节点'],
|
||||
['model', 'Comfy 模型节点(可选)'],
|
||||
['avatar', '人物素材节点'],
|
||||
['referenceAudio', '参考音频节点'],
|
||||
['voiceReference', '音色参考节点(可选)'],
|
||||
['output', '输出视频节点'],
|
||||
['output', '输出视频节点(可选)'],
|
||||
] as Array<[keyof typeof DEFAULT_MAPPING, string]>).map(([key, label]) => {
|
||||
const item = mapped[key];
|
||||
return (
|
||||
@@ -595,6 +958,13 @@ export default function DigitalHuman() {
|
||||
</div>
|
||||
) : jobs.map((job) => {
|
||||
const progress = Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
|
||||
const promptId = jobPromptId(job);
|
||||
const templateName = jobTemplateName(job);
|
||||
const keyName = jobKeyName(job);
|
||||
const keyId = jobKeyId(job);
|
||||
const traceModel = jobModelName(job);
|
||||
const tracePrompt = jobTracePrompt(job);
|
||||
const assets = jobAssets(job);
|
||||
return (
|
||||
<article key={job.id} className="digital-human-job-card">
|
||||
<div className="digital-human-job-head">
|
||||
@@ -606,19 +976,58 @@ export default function DigitalHuman() {
|
||||
</div>
|
||||
<div className="digital-human-job-meta">
|
||||
<span>进度 {progress}%</span>
|
||||
{job.result?.prompt_id && <span>Prompt {job.result.prompt_id}</span>}
|
||||
{promptId && <span>Prompt {String(promptId).slice(0, 12)}</span>}
|
||||
{templateName && <span>{templateName}</span>}
|
||||
{keyName && <span>{keyName}</span>}
|
||||
</div>
|
||||
<div className="digital-human-progress"><span style={{ width: `${progress}%` }} /></div>
|
||||
{job.error && <div className="digital-human-job-error">{job.error}</div>}
|
||||
{jobAssets(job).length > 0 && (
|
||||
{assets.length > 0 && (
|
||||
<div className="digital-human-assets">
|
||||
{jobAssets(job).map((asset: any) => (
|
||||
<a key={asset.publicId} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId)}`}>
|
||||
{asset.kind === 'video' ? '视频' : asset.kind === 'audio' ? '音频' : '封面'} · {asset.publicId}
|
||||
{assets.map((asset: any, index: number) => (
|
||||
<a
|
||||
key={asset.publicId || asset.remoteUrl || `${asset.status || 'asset'}-${index}`}
|
||||
href={jobAssetHref(asset, workspacePath)}
|
||||
target={asset.remoteUrl ? '_blank' : undefined}
|
||||
rel={asset.remoteUrl ? 'noreferrer' : undefined}
|
||||
>
|
||||
{jobAssetLabel(asset)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="creation-trace-links" aria-label="任务追踪">
|
||||
<Link
|
||||
to={buildCreationUsageHref({
|
||||
audience,
|
||||
query: audience === 'user' ? jobTraceQuery(job) : undefined,
|
||||
model: traceModel,
|
||||
prompt: tracePrompt,
|
||||
taskId: job.id,
|
||||
keyId,
|
||||
keyName,
|
||||
status: job.status,
|
||||
})}
|
||||
>
|
||||
{audience === 'user' ? '使用记录' : '使用日志'}
|
||||
</Link>
|
||||
<Link
|
||||
to={buildCreationFailureHref({
|
||||
audience,
|
||||
query: audience === 'user' ? jobTraceQuery(job) : undefined,
|
||||
model: traceModel,
|
||||
prompt: tracePrompt,
|
||||
taskId: job.id,
|
||||
keyId,
|
||||
keyName,
|
||||
})}
|
||||
>
|
||||
失败记录
|
||||
</Link>
|
||||
<Link to={buildCreationModelTesterHref(traceModel)}>
|
||||
模型测试器
|
||||
</Link>
|
||||
</div>
|
||||
<div className="digital-human-job-buttons">
|
||||
{!terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => cancel(job.id)}>取消</button>}
|
||||
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>重试</button>}
|
||||
@@ -631,14 +1040,22 @@ export default function DigitalHuman() {
|
||||
</div>
|
||||
|
||||
{assetPicker && (
|
||||
<div className="modal-overlay" onClick={() => setAssetPicker('')}>
|
||||
<div className="digital-human-picker modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-overlay" role="presentation" onClick={closeAssetPicker}>
|
||||
<div
|
||||
ref={assetPickerDialogRef}
|
||||
className="digital-human-picker modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={assetPickerTitleId}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h3>{assetPicker === 'audio' ? '选择参考音频' : '选择人物素材'}</h3>
|
||||
<h3 id={assetPickerTitleId}>{assetPicker === 'audio' ? '选择参考音频' : '选择人物素材'}</h3>
|
||||
<p>从工作空间选取已有素材,只有最终产出仍会归档到工作空间。</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setAssetPicker('')}>关闭</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={closeAssetPicker}>关闭</button>
|
||||
</div>
|
||||
{loadingAssets ? (
|
||||
<div className="empty-state" style={{ minHeight: 180 }}>
|
||||
|
||||
@@ -80,6 +80,13 @@ function findGhostButtonByText(root: ReactTestInstance, text: string): ReactTest
|
||||
));
|
||||
}
|
||||
|
||||
function findListCard(root: ReactTestInstance): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.props?.className === 'card'
|
||||
&& node.props['aria-live'] === 'polite'
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -207,8 +214,10 @@ describe('DownstreamKeys mobile layout', () => {
|
||||
const cards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
|
||||
expect(cards.length).toBe(2);
|
||||
expect(collectText(root!.root)).toContain('全选可见');
|
||||
expect(findListCard(root!.root).props['aria-busy']).toBe(false);
|
||||
|
||||
const selectAllButton = findButtonByText(root!.root, '全选可见');
|
||||
expect(selectAllButton.props.type).toBe('button');
|
||||
await act(async () => {
|
||||
selectAllButton.props.onClick();
|
||||
});
|
||||
@@ -217,9 +226,21 @@ describe('DownstreamKeys mobile layout', () => {
|
||||
expect(collectText(root!.root)).toContain('已选 2 个密钥');
|
||||
expect(collectText(root!.root)).toContain('取消全选');
|
||||
|
||||
let resolveBatch: ((value: unknown) => void) | undefined;
|
||||
apiMock.batchDownstreamApiKeys.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveBatch = resolve;
|
||||
}));
|
||||
|
||||
const disableButton = findGhostButtonByText(root!.root, '禁用');
|
||||
expect(disableButton.props.type).toBe('button');
|
||||
await act(async () => {
|
||||
disableButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(findListCard(root!.root).props['aria-busy']).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveBatch?.({ success: true, successIds: [1, 2], failedItems: [] });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
@@ -227,6 +248,7 @@ describe('DownstreamKeys mobile layout', () => {
|
||||
ids: [1, 2],
|
||||
action: 'disable',
|
||||
});
|
||||
expect(findListCard(root!.root).props['aria-busy']).toBe(false);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -356,6 +356,12 @@ describe('DownstreamKeys page', () => {
|
||||
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '24h' });
|
||||
expect(collectText(root!.root)).toContain('trend:2');
|
||||
|
||||
const drawerDialog = root!.root.find((node) => node.props.role === 'dialog');
|
||||
expect(drawerDialog.props['aria-modal']).toBe('true');
|
||||
expect(typeof drawerDialog.props['aria-labelledby']).toBe('string');
|
||||
const drawerTitle = root!.root.find((node) => node.props.id === drawerDialog.props['aria-labelledby']);
|
||||
expect(collectText(drawerTitle)).toContain('smoke-key');
|
||||
|
||||
const range7d = root!.root.findAll((node) => node.type === 'button' && collectText(node) === '7d')[0];
|
||||
await act(async () => {
|
||||
range7d.props.onClick();
|
||||
@@ -371,6 +377,43 @@ describe('DownstreamKeys page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the downstream key drawer from the keyboard row handler', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/downstream-keys']}>
|
||||
<ToastProvider>
|
||||
<DownstreamKeys />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onKeyDown === 'function')[0];
|
||||
expect(row.props['aria-selected']).toBe(false);
|
||||
const preventDefault = vi.fn();
|
||||
await act(async () => {
|
||||
row.props.onKeyDown({
|
||||
key: 'Enter',
|
||||
target: { closest: () => row },
|
||||
currentTarget: row,
|
||||
preventDefault,
|
||||
});
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(row.props.tabIndex).toBe(0);
|
||||
expect(row.props['aria-selected']).toBe(false);
|
||||
expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledWith(1);
|
||||
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '24h' });
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not refetch drawer trend repeatedly after a trend error toast', async () => {
|
||||
apiMock.getDownstreamApiKeyTrend
|
||||
.mockRejectedValueOnce(new Error('function date_trunc(unknown, text) does not exist'))
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type Range,
|
||||
type SummaryItem,
|
||||
} from './downstream-keys/shared.js';
|
||||
import { shouldHandleRowSelectionKeyDown, shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
|
||||
type Status = 'all' | 'enabled' | 'disabled';
|
||||
|
||||
@@ -722,6 +723,11 @@ export default function DownstreamKeys() {
|
||||
const visibleIds = useMemo(() => visibleItems.map((item) => item.id), [visibleItems]);
|
||||
const selectedVisibleCount = useMemo(() => selectedIds.filter((id) => visibleIds.includes(id)).length, [selectedIds, visibleIds]);
|
||||
const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length;
|
||||
const downstreamKeysListBusy =
|
||||
loading ||
|
||||
saving ||
|
||||
batchActionLoading ||
|
||||
Object.values(rowLoading).some(Boolean);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds((current) => current.filter((id) => managedItems.some((item) => item.id == id)));
|
||||
@@ -888,6 +894,21 @@ export default function DownstreamKeys() {
|
||||
setSelectedIds((current) => checked ? uniqIds([...current, id]) : current.filter((item) => item !== id));
|
||||
};
|
||||
|
||||
const openKeyDrawer = (id: number) => {
|
||||
setSelectedId(id);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleKeyRowClick = (id: number, event?: React.MouseEvent<HTMLTableRowElement>) => {
|
||||
if (event && shouldIgnoreRowSelectionClick(event.target, event.currentTarget)) return;
|
||||
openKeyDrawer(id);
|
||||
};
|
||||
|
||||
const handleKeyRowKeyDown = (id: number, event: React.KeyboardEvent<HTMLTableRowElement>) => {
|
||||
if (!shouldHandleRowSelectionKeyDown(event)) return;
|
||||
openKeyDrawer(id);
|
||||
};
|
||||
|
||||
const toggleSelectAllVisible = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setSelectedIds((current) => current.filter((id) => !visibleIds.includes(id)));
|
||||
@@ -1000,10 +1021,10 @@ export default function DownstreamKeys() {
|
||||
};
|
||||
|
||||
const filterControls = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div className="toolbar" style={{ marginBottom: 0, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 420px', minWidth: 280, flexWrap: 'wrap' }}>
|
||||
<div className="toolbar-search" style={{ maxWidth: 'unset', flex: '1 1 320px' }}>
|
||||
<div className="downstream-keys-filter-stack">
|
||||
<div className="toolbar downstream-keys-filter-toolbar">
|
||||
<div className="downstream-keys-search-cluster">
|
||||
<div className="toolbar-search downstream-keys-search-field">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
@@ -1015,13 +1036,13 @@ export default function DownstreamKeys() {
|
||||
</div>
|
||||
<InlineToggle value={tagMatchMode} onChange={setTagMatchMode} />
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<div className="downstream-keys-select-filter">
|
||||
<ModernSelect value={status} onChange={(value) => setStatus((value as Status) || 'all')} options={statusOptions} />
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<div className="downstream-keys-select-filter">
|
||||
<ModernSelect value={groupFilter} onChange={(value) => setGroupFilter(String(value || '__all__'))} options={groupFilterOptions} />
|
||||
</div>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => { setSearchInput(''); setStatus('all'); setGroupFilter('__all__'); setSelectedTags([]); setTagMatchMode('any'); }}>
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => { setSearchInput(''); setStatus('all'); setGroupFilter('__all__'); setSelectedTags([]); setTagMatchMode('any'); }}>
|
||||
重置筛选
|
||||
</button>
|
||||
</div>
|
||||
@@ -1032,6 +1053,7 @@ export default function DownstreamKeys() {
|
||||
const fromPinnedTags = selectedTags.some((item) => item.toLowerCase() === tag.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={tag}
|
||||
className="btn btn-ghost"
|
||||
style={{ ...tagChipStyle('accent'), cursor: 'pointer', opacity: fromPinnedTags ? 1 : 0.82 }}
|
||||
@@ -1053,7 +1075,7 @@ export default function DownstreamKeys() {
|
||||
);
|
||||
})}
|
||||
{tagSuggestions.filter((tag) => !activeTagFilters.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 8).map((tag) => (
|
||||
<button key={tag} className="btn btn-ghost" style={tagChipStyle()} onClick={() => addTagFilter(tag)}>
|
||||
<button type="button" key={tag} className="btn btn-ghost" style={tagChipStyle()} onClick={() => addTagFilter(tag)}>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
@@ -1073,10 +1095,10 @@ export default function DownstreamKeys() {
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<RangeToggle range={range} onChange={setRange} />
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void load()} disabled={loading}>
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void load()} disabled={loading}>
|
||||
{loading ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={openCreate}>+ 新增下游密钥</button>
|
||||
<button type="button" className="btn btn-primary" onClick={openCreate}>+ 新增下游密钥</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1115,15 +1137,15 @@ export default function DownstreamKeys() {
|
||||
info={`已选 ${selectedIds.length} 个密钥`}
|
||||
infoStyle={{ color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>{isMobile ? '归类/标签' : '批量归类/标签'}</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>{isMobile ? '启用' : '批量启用'}</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>{isMobile ? '禁用' : '批量禁用'}</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>{isMobile ? '清零' : '批量清零用量'}</button>
|
||||
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>{isMobile ? '删除' : '批量删除'}</button>
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>{isMobile ? '归类/标签' : '批量归类/标签'}</button>
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>{isMobile ? '启用' : '批量启用'}</button>
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>{isMobile ? '禁用' : '批量禁用'}</button>
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>{isMobile ? '清零' : '批量清零用量'}</button>
|
||||
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>{isMobile ? '删除' : '批量删除'}</button>
|
||||
</ResponsiveBatchActionBar>
|
||||
) : null}
|
||||
|
||||
<div className="card" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="card" aria-busy={downstreamKeysListBusy} aria-live="polite" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
@@ -1132,10 +1154,11 @@ export default function DownstreamKeys() {
|
||||
</div>
|
||||
{isMobile && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => setShowFilters(true)}>
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => setShowFilters(true)}>
|
||||
筛选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={() => toggleSelectAllVisible(!allVisibleSelected)}
|
||||
@@ -1186,11 +1209,11 @@ export default function DownstreamKeys() {
|
||||
)}
|
||||
footerActions={(
|
||||
<>
|
||||
<button className="btn btn-link" onClick={() => { setSelectedId(row.id); setDrawerOpen(true); }}>查看</button>
|
||||
<button className="btn btn-link" onClick={() => openEdit(row)}>编辑</button>
|
||||
<button className="btn btn-link" onClick={() => void toggleEnabled(row)} disabled={loadingToggle}>{loadingToggle ? '处理中...' : (row.enabled ? '禁用' : '启用')}</button>
|
||||
<button className="btn btn-link" onClick={() => void resetUsage(row)} disabled={loadingReset}>{loadingReset ? '处理中...' : '清零用量'}</button>
|
||||
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'single', item: row })} disabled={loadingDelete}>{loadingDelete ? '处理中...' : '删除'}</button>
|
||||
<button type="button" className="btn btn-link" onClick={() => openKeyDrawer(row.id)}>查看</button>
|
||||
<button type="button" className="btn btn-link" onClick={() => openEdit(row)}>编辑</button>
|
||||
<button type="button" className="btn btn-link" onClick={() => void toggleEnabled(row)} disabled={loadingToggle}>{loadingToggle ? '处理中...' : (row.enabled ? '禁用' : '启用')}</button>
|
||||
<button type="button" className="btn btn-link" onClick={() => void resetUsage(row)} disabled={loadingReset}>{loadingReset ? '处理中...' : '清零用量'}</button>
|
||||
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'single', item: row })} disabled={loadingDelete}>{loadingDelete ? '处理中...' : '删除'}</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -1213,7 +1236,7 @@ export default function DownstreamKeys() {
|
||||
<MobileField
|
||||
label="计费额度"
|
||||
value={(
|
||||
<div style={{ display: 'grid', gap: 10, minWidth: 220 }}>
|
||||
<div style={{ display: 'grid', gap: 10, minWidth: 0 }}>
|
||||
<BillingLimitBar label="请求" used={row.usedRequests || 0} max={row.maxRequests} formatter={(value) => Math.trunc(value).toLocaleString()} />
|
||||
<BillingLimitBar label="成本" used={row.usedCost || 0} max={row.maxCost} formatter={formatMoney} />
|
||||
</div>
|
||||
@@ -1232,7 +1255,7 @@ export default function DownstreamKeys() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 42 }}>
|
||||
<input type="checkbox" checked={allVisibleSelected} onChange={(e) => toggleSelectAllVisible(e.target.checked)} />
|
||||
<input type="checkbox" aria-label="选择当前可见下游密钥" checked={allVisibleSelected} onChange={(e) => toggleSelectAllVisible(e.target.checked)} />
|
||||
</th>
|
||||
<th>密钥信息</th>
|
||||
<th>授权范围</th>
|
||||
@@ -1249,9 +1272,17 @@ export default function DownstreamKeys() {
|
||||
const loadingDelete = !!rowLoading[`delete-${row.id}`];
|
||||
const checked = selectedIds.includes(row.id);
|
||||
return (
|
||||
<tr key={row.id} className={`row-selectable ${checked ? 'row-selected' : ''}`.trim()} onClick={() => { setSelectedId(row.id); setDrawerOpen(true); }}>
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`row-selectable ${checked ? 'row-selected' : ''}`.trim()}
|
||||
onClick={(event) => handleKeyRowClick(row.id, event)}
|
||||
onKeyDown={(event) => handleKeyRowKeyDown(row.id, event)}
|
||||
tabIndex={0}
|
||||
aria-selected={checked}
|
||||
aria-label={`查看下游密钥 ${row.name}`}
|
||||
>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => toggleSelection(row.id, e.target.checked)} />
|
||||
<input type="checkbox" aria-label={`选择 ${row.name}`} checked={checked} onChange={(e) => toggleSelection(row.id, e.target.checked)} />
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
@@ -1293,11 +1324,11 @@ export default function DownstreamKeys() {
|
||||
<td style={{ color: 'var(--color-text-muted)' }}>{formatIso(row.lastUsedAt)}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<div className="accounts-row-actions" style={{ justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-link" onClick={() => { setSelectedId(row.id); setDrawerOpen(true); }}>查看</button>
|
||||
<button className="btn btn-link" onClick={() => openEdit(row)}>编辑</button>
|
||||
<button className="btn btn-link" onClick={() => void toggleEnabled(row)} disabled={loadingToggle}>{loadingToggle ? '处理中...' : (row.enabled ? '禁用' : '启用')}</button>
|
||||
<button className="btn btn-link" onClick={() => void resetUsage(row)} disabled={loadingReset}>{loadingReset ? '处理中...' : '清零用量'}</button>
|
||||
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'single', item: row })} disabled={loadingDelete}>{loadingDelete ? '处理中...' : '删除'}</button>
|
||||
<button type="button" className="btn btn-link" onClick={() => openKeyDrawer(row.id)}>查看</button>
|
||||
<button type="button" className="btn btn-link" onClick={() => openEdit(row)}>编辑</button>
|
||||
<button type="button" className="btn btn-link" onClick={() => void toggleEnabled(row)} disabled={loadingToggle}>{loadingToggle ? '处理中...' : (row.enabled ? '禁用' : '启用')}</button>
|
||||
<button type="button" className="btn btn-link" onClick={() => void resetUsage(row)} disabled={loadingReset}>{loadingReset ? '处理中...' : '清零用量'}</button>
|
||||
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'single', item: row })} disabled={loadingDelete}>{loadingDelete ? '处理中...' : '删除'}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1330,11 +1361,12 @@ export default function DownstreamKeys() {
|
||||
onClose={() => { setBatchMetadataOpen(false); resetBatchMetadataForm(); }}
|
||||
title="批量归类 / 标签"
|
||||
maxWidth={720}
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={() => { setBatchMetadataOpen(false); resetBatchMetadataForm(); }} disabled={batchActionLoading}>取消</button>
|
||||
<button className="btn btn-primary" onClick={() => void runBatchMetadata()} disabled={batchActionLoading}>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => { setBatchMetadataOpen(false); resetBatchMetadataForm(); }} disabled={batchActionLoading}>取消</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void runBatchMetadata()} disabled={batchActionLoading}>
|
||||
{batchActionLoading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '应用到所选密钥'}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -44,6 +44,13 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findLiveRegion(root: ReactTestInstance, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.props['aria-live'] === 'polite'
|
||||
&& collectText(node).includes(text)
|
||||
));
|
||||
}
|
||||
|
||||
const allApiHubV2Payload = JSON.stringify({
|
||||
version: '2.0',
|
||||
timestamp: 1735689600000,
|
||||
@@ -352,6 +359,8 @@ describe('ImportExport', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(findLiveRegion(root!.root, '数据预览').props['aria-busy']).toBe(false);
|
||||
|
||||
const importButton = root!.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
@@ -359,6 +368,7 @@ describe('ImportExport', () => {
|
||||
)).at(-1);
|
||||
|
||||
expect(importButton).toBeTruthy();
|
||||
expect(importButton?.props.type).toBe('button');
|
||||
|
||||
await act(async () => {
|
||||
importButton!.props.onClick();
|
||||
@@ -384,6 +394,62 @@ describe('ImportExport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('marks the import region busy while backup import is running', async () => {
|
||||
let resolveImport: (value: unknown) => void = () => {};
|
||||
apiMock.importBackup.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}));
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = root!.root.findByType('textarea');
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const importButton = root!.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('导入')
|
||||
)).at(-1);
|
||||
|
||||
expect(importButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
importButton!.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(findLiveRegion(root!.root, '导入中').props['aria-busy']).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveImport({
|
||||
allImported: true,
|
||||
sections: {
|
||||
accounts: true,
|
||||
preferences: true,
|
||||
},
|
||||
appliedSettings: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(findLiveRegion(root!.root, '数据预览').props['aria-busy']).toBe(false);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('loads webdav config and saves updates from the import/export page', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
@@ -439,6 +505,13 @@ describe('ImportExport', () => {
|
||||
expect(rendered).toContain('导出全部(连接 + 路由 + 策略 + 设置)');
|
||||
expect(rendered).toContain('仅导出连接与路由策略');
|
||||
expect(rendered).toContain('覆盖备份中的连接/路由/策略配置,但会保留本机日志、公告、缓存和统计。');
|
||||
|
||||
const exportButtons = root!.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& ['导出全部(连接 + 路由 + 策略 + 设置)', '仅导出连接与路由策略', '仅导出系统设置'].some((label) => collectText(node).includes(label))
|
||||
));
|
||||
expect(exportButtons.map((button) => button.props.type)).toEqual(['button', 'button', 'button']);
|
||||
expect(findLiveRegion(root!.root, '导出全部').props['aria-busy']).toBe(false);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
@@ -492,6 +565,47 @@ describe('ImportExport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a native button for the upload drop zone and opens the file picker', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
const clickFileInput = vi.fn();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => (
|
||||
element.type === 'input' && element.props.type === 'file'
|
||||
? { click: clickFileInput }
|
||||
: null
|
||||
),
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const uploadZone = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& String(node.props.className || '').includes('import-export-upload-zone')
|
||||
&& node.props['aria-label'] === '选择或拖拽 JSON 备份文件'
|
||||
));
|
||||
expect(uploadZone.props.type).toBe('button');
|
||||
expect(uploadZone.props.role).toBeUndefined();
|
||||
expect(uploadZone.props.tabIndex).toBeUndefined();
|
||||
expect(uploadZone.props.onKeyDown).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
uploadZone.props.onClick();
|
||||
});
|
||||
|
||||
expect(clickFileInput).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables webdav actions while config has unsaved changes', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
@@ -528,6 +642,9 @@ describe('ImportExport', () => {
|
||||
|
||||
expect(exportButton?.props.disabled).toBe(true);
|
||||
expect(importButton?.props.disabled).toBe(true);
|
||||
expect(exportButton?.props.type).toBe('button');
|
||||
expect(importButton?.props.type).toBe('button');
|
||||
expect(findLiveRegion(root!.root, '保存 WebDAV 配置').props['aria-busy']).toBe(false);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
@@ -584,6 +701,7 @@ describe('ImportExport', () => {
|
||||
)).at(-1);
|
||||
|
||||
expect(saveButton).toBeTruthy();
|
||||
expect(saveButton?.props.type).toBe('button');
|
||||
|
||||
await act(async () => {
|
||||
saveButton!.props.onClick();
|
||||
|
||||
@@ -518,7 +518,7 @@ export default function ImportExport() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(360px,1fr))', gap: 14 }}>
|
||||
<div className="import-export-action-grid">
|
||||
{/* ====== 导出区 ====== */}
|
||||
<div className="card animate-slide-up stagger-1" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
@@ -531,8 +531,9 @@ export default function ImportExport() {
|
||||
将连接、路由策略与设置导出为 JSON 文件进行备份
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div aria-busy={Boolean(exportingType)} aria-live="polite" style={{ display: 'grid', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleExport('all')}
|
||||
disabled={!!exportingType}
|
||||
className="btn btn-primary"
|
||||
@@ -542,6 +543,7 @@ export default function ImportExport() {
|
||||
{exportingType === 'all' ? <span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleExport('accounts')}
|
||||
disabled={!!exportingType}
|
||||
className="btn btn-ghost"
|
||||
@@ -551,6 +553,7 @@ export default function ImportExport() {
|
||||
{exportingType === 'accounts' ? <span className="spinner spinner-sm" /> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleExport('preferences')}
|
||||
disabled={!!exportingType}
|
||||
className="btn btn-ghost"
|
||||
@@ -574,30 +577,36 @@ export default function ImportExport() {
|
||||
从备份文件恢复数据
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div aria-busy={importing} aria-live="polite" style={{ display: 'grid', gap: 12 }}>
|
||||
{/* ---- 拖拽上传区 ---- */}
|
||||
<div
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={handleImportFile}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="import-export-upload-zone"
|
||||
aria-label={selectedFileName ? `重新选择备份文件:${selectedFileName}` : '选择或拖拽 JSON 备份文件'}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: `2px dashed ${dragOver ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '28px 20px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
transition: 'all 0.2s ease',
|
||||
background: dragOver ? 'var(--color-primary-light)' : 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={handleImportFile}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{selectedFileName ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)">
|
||||
@@ -623,7 +632,7 @@ export default function ImportExport() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* ---- 数据预览 ---- */}
|
||||
<div>
|
||||
@@ -702,6 +711,7 @@ export default function ImportExport() {
|
||||
|
||||
{/* ---- 操作按钮 ---- */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={importing || !summary?.valid}
|
||||
className="btn btn-primary"
|
||||
@@ -724,7 +734,7 @@ export default function ImportExport() {
|
||||
支持手动推送、手动拉取,以及定时自动导出到 WebDAV。
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px 20px' }}>
|
||||
<div className="import-export-webdav-grid">
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={formFieldLabelStyle}>文件 URL</div>
|
||||
<input
|
||||
@@ -829,8 +839,9 @@ export default function ImportExport() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
<div aria-busy={webdavSaving || webdavAction !== ''} aria-live="polite" style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveWebdavConfig}
|
||||
disabled={webdavSaving}
|
||||
className="btn btn-primary"
|
||||
@@ -838,6 +849,7 @@ export default function ImportExport() {
|
||||
{webdavSaving ? '保存中...' : '保存 WebDAV 配置'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportToWebdav}
|
||||
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
|
||||
className="btn btn-ghost"
|
||||
@@ -846,6 +858,7 @@ export default function ImportExport() {
|
||||
{webdavAction === 'export' ? '导出中...' : '立即导出到 WebDAV'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportFromWebdav}
|
||||
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
|
||||
className="btn btn-ghost"
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
|
||||
type SiteProxyRow = {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
platform?: string | null;
|
||||
status?: string | null;
|
||||
proxyUrl?: string | null;
|
||||
proxy_url?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
use_system_proxy?: boolean | null;
|
||||
};
|
||||
|
||||
type OAuthProxyRow = {
|
||||
accountId: number;
|
||||
provider: string;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
site?: { name?: string | null; platform?: string | null } | null;
|
||||
};
|
||||
|
||||
type TestState =
|
||||
| { kind: 'success'; text: string; latencyMs?: number }
|
||||
| { kind: 'error'; text: string }
|
||||
| null;
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function redactProxyUrl(value: string | null | undefined): string {
|
||||
const text = (value || '').trim();
|
||||
if (!text) return '未配置';
|
||||
try {
|
||||
const parsed = new URL(text);
|
||||
if (parsed.username || parsed.password) {
|
||||
parsed.username = parsed.username ? '***' : '';
|
||||
parsed.password = parsed.password ? '***' : '';
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return text.replace(/:\/\/([^:@/]+):([^@/]+)@/, '://***:***@');
|
||||
}
|
||||
}
|
||||
|
||||
function siteUsesSystemProxy(site: SiteProxyRow): boolean {
|
||||
return site.useSystemProxy === true || site.use_system_proxy === true;
|
||||
}
|
||||
|
||||
function siteProxyUrl(site: SiteProxyRow): string {
|
||||
return asString(site.proxyUrl ?? site.proxy_url).trim();
|
||||
}
|
||||
|
||||
function statusTone(status: string | null | undefined) {
|
||||
const normalized = (status || '').toLowerCase();
|
||||
if (['disabled', 'inactive', 'error', 'abnormal'].includes(normalized)) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
export default function IpManagement() {
|
||||
const toast = useToast();
|
||||
const [systemProxyUrl, setSystemProxyUrl] = useState('');
|
||||
const [savedSystemProxyUrl, setSavedSystemProxyUrl] = useState('');
|
||||
const [sites, setSites] = useState<SiteProxyRow[]>([]);
|
||||
const [oauthRows, setOauthRows] = useState<OAuthProxyRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testState, setTestState] = useState<TestState>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [runtimeResult, sitesResult, oauthResult] = await Promise.allSettled([
|
||||
api.getRuntimeSettings(),
|
||||
api.getSites(),
|
||||
api.getOAuthConnections({ limit: 200, offset: 0 }),
|
||||
]);
|
||||
|
||||
if (runtimeResult.status === 'fulfilled') {
|
||||
const url = asString((runtimeResult.value as any)?.systemProxyUrl).trim();
|
||||
setSystemProxyUrl(url);
|
||||
setSavedSystemProxyUrl(url);
|
||||
}
|
||||
if (sitesResult.status === 'fulfilled') {
|
||||
setSites(Array.isArray(sitesResult.value) ? sitesResult.value : []);
|
||||
}
|
||||
if (oauthResult.status === 'fulfilled') {
|
||||
setOauthRows(Array.isArray((oauthResult.value as any)?.items) ? (oauthResult.value as any).items : []);
|
||||
}
|
||||
|
||||
const failed = [runtimeResult, sitesResult, oauthResult].filter((result) => result.status === 'rejected').length;
|
||||
if (failed > 0) {
|
||||
setError(`${failed} 个数据源暂时不可用,已展示可获取的数据`);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const systemEnabledSites = sites.filter(siteUsesSystemProxy);
|
||||
const customProxySites = sites.filter((site) => siteProxyUrl(site));
|
||||
const systemOauth = oauthRows.filter((row) => row.useSystemProxy);
|
||||
const customOauth = oauthRows.filter((row) => (row.proxyUrl || '').trim());
|
||||
const affectedCount = systemEnabledSites.length + systemOauth.length;
|
||||
return {
|
||||
systemEnabledSites,
|
||||
customProxySites,
|
||||
systemOauth,
|
||||
customOauth,
|
||||
affectedCount,
|
||||
customCount: customProxySites.length + customOauth.length,
|
||||
};
|
||||
}, [oauthRows, sites]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setTestState(null);
|
||||
try {
|
||||
const res = await api.updateRuntimeSettings({ systemProxyUrl: systemProxyUrl.trim() });
|
||||
const nextUrl = asString((res as any)?.systemProxyUrl).trim();
|
||||
const resolved = nextUrl || systemProxyUrl.trim();
|
||||
setSystemProxyUrl(resolved);
|
||||
setSavedSystemProxyUrl(resolved);
|
||||
toast.success('系统代理已保存');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存系统代理失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
const proxyUrl = systemProxyUrl.trim();
|
||||
if (!proxyUrl) {
|
||||
const message = '请先填写系统代理地址';
|
||||
setTestState({ kind: 'error', text: message });
|
||||
toast.info(message);
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
setTestState(null);
|
||||
try {
|
||||
const res = await api.testSystemProxy({ proxyUrl });
|
||||
const text = `连通成功,延迟 ${res.latencyMs} ms`;
|
||||
setTestState({ kind: 'success', text, latencyMs: res.latencyMs });
|
||||
toast.success(text);
|
||||
} catch (err: any) {
|
||||
const text = err?.message || '系统代理测试失败';
|
||||
setTestState({ kind: 'error', text });
|
||||
toast.error(text);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in admin-ops-page ip-management-page">
|
||||
<div className="page-header">
|
||||
<div className="page-header-copy">
|
||||
<div className="page-title">IP 管理</div>
|
||||
<div className="page-subtitle">正在加载代理配置。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ops-monitor-skeleton-grid">
|
||||
{[0, 1, 2].map((item) => <div key={item} className="skeleton" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in admin-ops-page ip-management-page">
|
||||
<div className="page-header">
|
||||
<div className="page-header-copy">
|
||||
<div className="page-title">IP 管理</div>
|
||||
<div className="page-subtitle">
|
||||
管理全局出站代理,追踪站点和 OAuth 连接的代理使用范围,并快速验证当前代理是否可用。
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void load()}>刷新</button>
|
||||
<Link className="btn btn-primary" to="/settings#settings-proxy">系统设置</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="ops-monitor-inline-alert">{error}</div>}
|
||||
|
||||
<section className="ip-management-summary-grid" aria-label="代理摘要">
|
||||
<article className="ops-monitor-kpi" data-tone={savedSystemProxyUrl ? 'success' : 'muted'}>
|
||||
<span>系统代理</span>
|
||||
<strong>{savedSystemProxyUrl ? '已配置' : '未配置'}</strong>
|
||||
<em>{redactProxyUrl(savedSystemProxyUrl)}</em>
|
||||
</article>
|
||||
<article className="ops-monitor-kpi" data-tone={summary.affectedCount > 0 ? 'primary' : 'muted'}>
|
||||
<span>使用系统代理</span>
|
||||
<strong>{summary.affectedCount}</strong>
|
||||
<em>{summary.systemEnabledSites.length} 站点 · {summary.systemOauth.length} OAuth</em>
|
||||
</article>
|
||||
<article className="ops-monitor-kpi" data-tone={summary.customCount > 0 ? 'warning' : 'success'}>
|
||||
<span>自定义代理</span>
|
||||
<strong>{summary.customCount}</strong>
|
||||
<em>{summary.customProxySites.length} 站点 · {summary.customOauth.length} OAuth</em>
|
||||
</article>
|
||||
<article className="ops-monitor-kpi" data-tone={testState?.kind === 'success' ? 'success' : testState?.kind === 'error' ? 'danger' : 'muted'}>
|
||||
<span>最近探测</span>
|
||||
<strong>{testState?.kind === 'success' ? `${testState.latencyMs}ms` : testState?.kind === 'error' ? '失败' : '未测试'}</strong>
|
||||
<em>{testState?.text || '保存后可立即测试'}</em>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="ip-management-layout">
|
||||
<div className="card ip-management-editor">
|
||||
<div className="ops-monitor-panel-head">
|
||||
<div>
|
||||
<span>全局出站代理</span>
|
||||
<strong>系统代理配置</strong>
|
||||
</div>
|
||||
<span className="ip-management-state-pill" data-tone={systemProxyUrl.trim() === savedSystemProxyUrl ? 'success' : 'warning'}>
|
||||
{systemProxyUrl.trim() === savedSystemProxyUrl ? '已同步' : '未保存'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ip-management-input-row">
|
||||
<input
|
||||
value={systemProxyUrl}
|
||||
onChange={(event) => {
|
||||
setSystemProxyUrl(event.target.value);
|
||||
setTestState(null);
|
||||
}}
|
||||
placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={handleTest} disabled={testing}>
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
{testState && (
|
||||
<div className="ip-management-test-result" data-tone={testState.kind}>
|
||||
{testState.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="ip-management-usage-strip">
|
||||
<Link to="/sites">站点页启用系统代理</Link>
|
||||
<Link to="/oauth">OAuth 连接设置代理</Link>
|
||||
<Link to="/events?type=runtime">查看代理探测日志</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card ip-management-panel">
|
||||
<div className="ops-monitor-panel-head">
|
||||
<div>
|
||||
<span>覆盖范围</span>
|
||||
<strong>正在使用代理的对象</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ip-management-coverage">
|
||||
<div>
|
||||
<span>站点系统代理</span>
|
||||
<strong>{summary.systemEnabledSites.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>站点自定义代理</span>
|
||||
<strong>{summary.customProxySites.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>OAuth 系统代理</span>
|
||||
<strong>{summary.systemOauth.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>OAuth 自定义代理</span>
|
||||
<strong>{summary.customOauth.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ip-management-table-grid">
|
||||
<div className="card ip-management-panel">
|
||||
<div className="ops-monitor-panel-head">
|
||||
<div>
|
||||
<span>站点代理</span>
|
||||
<strong>{sites.length} 个站点</strong>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" to="/sites">管理站点</Link>
|
||||
</div>
|
||||
<div className="ip-management-list">
|
||||
{sites.map((site) => {
|
||||
const customProxy = siteProxyUrl(site);
|
||||
const system = siteUsesSystemProxy(site);
|
||||
return (
|
||||
<div key={site.id} className="ip-management-row">
|
||||
<div>
|
||||
<strong>{site.name || `站点 ${site.id}`}</strong>
|
||||
<span>{site.platform || 'unknown'} · {site.status || 'active'}</span>
|
||||
</div>
|
||||
<div className="ip-management-row-meta">
|
||||
<span data-tone={statusTone(site.status)}>{system ? '系统代理' : customProxy ? '自定义代理' : '直连'}</span>
|
||||
<em>{customProxy ? redactProxyUrl(customProxy) : system ? redactProxyUrl(savedSystemProxyUrl) : '未使用代理'}</em>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sites.length === 0 && <div className="ops-monitor-empty">暂无站点</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card ip-management-panel">
|
||||
<div className="ops-monitor-panel-head">
|
||||
<div>
|
||||
<span>OAuth 代理</span>
|
||||
<strong>{oauthRows.length} 个连接</strong>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" to="/oauth">管理 OAuth</Link>
|
||||
</div>
|
||||
<div className="ip-management-list">
|
||||
{oauthRows.map((row) => {
|
||||
const customProxy = (row.proxyUrl || '').trim();
|
||||
return (
|
||||
<div key={row.accountId} className="ip-management-row">
|
||||
<div>
|
||||
<strong>{row.username || row.email || `${row.provider} #${row.accountId}`}</strong>
|
||||
<span>{row.site?.name || row.provider || 'OAuth'} · #{row.accountId}</span>
|
||||
</div>
|
||||
<div className="ip-management-row-meta">
|
||||
<span data-tone={row.useSystemProxy || customProxy ? 'success' : 'muted'}>
|
||||
{row.useSystemProxy ? '系统代理' : customProxy ? '自定义代理' : '直连'}
|
||||
</span>
|
||||
<em>{customProxy ? redactProxyUrl(customProxy) : row.useSystemProxy ? redactProxyUrl(savedSystemProxyUrl) : '未使用代理'}</em>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{oauthRows.length === 0 && <div className="ops-monitor-empty">暂无 OAuth 连接</div>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1053
-50
File diff suppressed because it is too large
Load Diff
@@ -124,6 +124,55 @@ describe('Models marketplace text', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes model cards as keyboard-expandable disclosure controls', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
let card = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& node.props.role === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.split(' ').includes('model-card')
|
||||
));
|
||||
expect(card.props.tabIndex).toBe(0);
|
||||
expect(card.props['aria-expanded']).toBe(false);
|
||||
expect(card.props['aria-controls']).toBeUndefined();
|
||||
expect(card.props['aria-label']).toContain('gpt-4o');
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
await act(async () => {
|
||||
card.props.onKeyDown({ key: 'Enter', preventDefault });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
card = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& node.props.role === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.split(' ').includes('model-card')
|
||||
));
|
||||
expect(card.props['aria-expanded']).toBe(true);
|
||||
expect(card.props['aria-controls']).toBeTruthy();
|
||||
const expandedPanel = root.root.find((node) => node.props.id === card.props['aria-controls']);
|
||||
expect(String(expandedPanel.props.className)).toContain('anim-collapse');
|
||||
expect(collectText(expandedPanel)).toContain('基础信息');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows newly recognized brands in the marketplace filter panel', async () => {
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
@@ -554,8 +603,7 @@ describe('Models marketplace text', () => {
|
||||
await flushMicrotasks();
|
||||
|
||||
const siteFilterItem = root!.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('filter-item')
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('站点 A')
|
||||
@@ -693,8 +741,7 @@ describe('Models marketplace text', () => {
|
||||
await flushMicrotasks();
|
||||
|
||||
const siteFilterItem = root!.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('filter-item')
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('站点 A')
|
||||
@@ -770,8 +817,7 @@ describe('Models marketplace text', () => {
|
||||
await flushMicrotasks();
|
||||
|
||||
const siteFilterItem = root!.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('filter-item')
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('站点 A')
|
||||
|
||||
+144
-46
@@ -125,6 +125,17 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string {
|
||||
return `${pricing.perCallTotal ?? 0} USD / call`;
|
||||
}
|
||||
|
||||
function shouldToggleModelExpansionByKeyboard(event: React.KeyboardEvent): boolean {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveSortAria(sortBy: SortColumn, sortDir: 'asc' | 'desc', column: SortColumn): 'ascending' | 'descending' | 'none' {
|
||||
if (sortBy !== column) return 'none';
|
||||
return sortDir === 'asc' ? 'ascending' : 'descending';
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -427,6 +438,27 @@ export default function Models() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortColumn = (column: SortColumn) => {
|
||||
setSortBy(column);
|
||||
setSortDir((direction) => (direction === 'asc' ? 'desc' : 'asc'));
|
||||
};
|
||||
|
||||
const renderSortButton = (column: SortColumn, label: string) => {
|
||||
const active = sortBy === column;
|
||||
const directionLabel = active ? (sortDir === 'desc' ? tr('当前降序') : tr('当前升序')) : tr('未排序');
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={active ? 'model-sort-button is-active' : 'model-sort-button'}
|
||||
onClick={() => handleSortColumn(column)}
|
||||
aria-label={`${label},${directionLabel}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span aria-hidden="true">{active ? (sortDir === 'desc' ? '↓' : '↑') : '↕'}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const filterControls = (
|
||||
<>
|
||||
<div className="filter-panel-section">
|
||||
@@ -434,18 +466,22 @@ export default function Models() {
|
||||
{tr('品牌')}
|
||||
{activeBrand && <button onClick={() => setActiveBrand(null)}>{tr('重置')}</button>}
|
||||
</div>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-item ${!activeBrand ? 'active' : ''}`}
|
||||
aria-pressed={!activeBrand}
|
||||
onClick={() => setActiveBrand(null)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}>✓</span>
|
||||
{tr('全部品牌')}
|
||||
<span className="filter-item-count">{data.models.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
{brandList.list.map(([brandName, { count, brand }]) => (
|
||||
<div
|
||||
<button
|
||||
key={brandName}
|
||||
type="button"
|
||||
className={`filter-item ${activeBrand === brandName ? 'active' : ''}`}
|
||||
aria-pressed={activeBrand === brandName}
|
||||
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -453,17 +489,19 @@ export default function Models() {
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{brandList.otherCount > 0 && (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-item ${activeBrand === '__other__' ? 'active' : ''}`}
|
||||
aria-pressed={activeBrand === '__other__'}
|
||||
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10, borderRadius: 4 }}>?</span>
|
||||
{tr('其他')}
|
||||
<span className="filter-item-count">{brandList.otherCount}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -474,9 +512,11 @@ export default function Models() {
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
|
||||
</div>
|
||||
{siteMap.map(([site, count]) => (
|
||||
<div
|
||||
<button
|
||||
key={site}
|
||||
type="button"
|
||||
className={`filter-item ${activeSite === site ? 'active' : ''}`}
|
||||
aria-pressed={activeSite === site}
|
||||
onClick={() => setActiveSite(activeSite === site ? null : site)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
|
||||
@@ -484,7 +524,7 @@ export default function Models() {
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -498,9 +538,11 @@ export default function Models() {
|
||||
{ key: 'successRate' as SortColumn, label: tr('成功率') },
|
||||
{ key: 'name' as SortColumn, label: tr('名称') },
|
||||
].map(opt => (
|
||||
<div
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
className={`filter-item ${sortBy === opt.key ? 'active' : ''}`}
|
||||
aria-pressed={sortBy === opt.key}
|
||||
onClick={() => {
|
||||
if (sortBy === opt.key) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
@@ -516,7 +558,7 @@ export default function Models() {
|
||||
{sortDir === 'desc' ? '↓' : '↑'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>}
|
||||
</>
|
||||
@@ -525,7 +567,7 @@ export default function Models() {
|
||||
/* ---- loading skeleton ---- */
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||
<div className="models-page animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||
{!isMobile && (
|
||||
<div style={{ width: 240 }}>
|
||||
{[...Array(6)].map((_, i) => <div key={i} className="skeleton" style={{ height: 28, marginBottom: 8, borderRadius: 8 }} />)}
|
||||
@@ -533,7 +575,7 @@ export default function Models() {
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
<div className="page-header-copy">
|
||||
<div className="skeleton" style={{ width: 220, height: 28, marginBottom: 8 }} />
|
||||
<div className="skeleton" style={{ width: 160, height: 16 }} />
|
||||
</div>
|
||||
@@ -564,7 +606,7 @@ export default function Models() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||
<div className="models-page animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||
{!isMobile && filterPanelPresence.shouldRender && (
|
||||
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
{filterControls}
|
||||
@@ -582,10 +624,10 @@ export default function Models() {
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Header */}
|
||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div className="page-header-copy">
|
||||
<h2 className="page-title models-page-title">
|
||||
{activeBrand || activeSite || tr('模型广场')}
|
||||
<span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
<span className="badge badge-info models-count-badge" style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
{tr('共')} {filteredModels.length} {tr('个模型')}
|
||||
</span>
|
||||
</h2>
|
||||
@@ -622,10 +664,24 @@ export default function Models() {
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div className="view-toggle">
|
||||
<button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} data-tooltip={tr('卡片视图')} aria-label={tr('卡片视图')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('card')}
|
||||
data-tooltip={tr('卡片视图')}
|
||||
aria-label={tr('卡片视图')}
|
||||
aria-pressed={viewMode === 'card'}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} data-tooltip={tr('表格视图')} aria-label={tr('表格视图')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('table')}
|
||||
data-tooltip={tr('表格视图')}
|
||||
aria-label={tr('表格视图')}
|
||||
aria-pressed={viewMode === 'table'}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18M3 6h18M3 18h18M10 3v18M14 3v18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -655,7 +711,7 @@ export default function Models() {
|
||||
</div>
|
||||
{/* Quick stats */}
|
||||
{!isUserView && (
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
|
||||
<div className="models-quick-stats">
|
||||
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
|
||||
{tr('覆盖档位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
|
||||
</span>
|
||||
@@ -683,8 +739,23 @@ export default function Models() {
|
||||
<div>
|
||||
{paged.map((m) => {
|
||||
const isExpanded = expanded === m.name;
|
||||
const detailId = `model-card-detail-${encodeURIComponent(m.name)}`;
|
||||
return (
|
||||
<div key={m.name} className="model-card" onClick={() => setExpanded(isExpanded ? null : m.name)}>
|
||||
<div
|
||||
key={m.name}
|
||||
className="model-card"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={isExpanded ? detailId : undefined}
|
||||
aria-label={`${isExpanded ? tr('收起') : tr('展开')} ${m.name}`}
|
||||
onClick={() => setExpanded(isExpanded ? null : m.name)}
|
||||
onKeyDown={(event) => {
|
||||
if (shouldToggleModelExpansionByKeyboard(event)) {
|
||||
setExpanded(isExpanded ? null : m.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="model-card-header">
|
||||
<BrandIcon model={m.name} size={44} />
|
||||
<div className="model-card-info">
|
||||
@@ -731,11 +802,18 @@ export default function Models() {
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="model-card-action-btn"
|
||||
data-tooltip={isExpanded ? tr('收起') : tr('展开')}
|
||||
aria-label={isExpanded ? tr('收起') : tr('展开')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="model-card-action-btn"
|
||||
data-tooltip={isExpanded ? tr('收起') : tr('展开')}
|
||||
aria-label={isExpanded ? tr('收起') : tr('展开')}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={isExpanded ? detailId : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setExpanded(isExpanded ? null : m.name);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: isExpanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
@@ -764,7 +842,7 @@ export default function Models() {
|
||||
|
||||
{/* Expand: Account Details */}
|
||||
{isExpanded ? (
|
||||
<div className="anim-collapse is-open" onClick={e => e.stopPropagation()}>
|
||||
<div id={detailId} className="anim-collapse is-open" onClick={e => e.stopPropagation()}>
|
||||
<div className="anim-collapse-inner">
|
||||
<div className="model-card-expand">
|
||||
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
|
||||
@@ -913,34 +991,48 @@ export default function Models() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }} />
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('模型名称')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
<th aria-sort={resolveSortAria(sortBy, sortDir, 'name')}>
|
||||
{renderSortButton('name', tr('模型名称'))}
|
||||
</th>
|
||||
{!isUserView && (
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
<th aria-sort={resolveSortAria(sortBy, sortDir, 'accountCount')}>
|
||||
{renderSortButton('accountCount', tr('账号数'))}
|
||||
</th>
|
||||
)}
|
||||
{!isUserView && (
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
<th aria-sort={resolveSortAria(sortBy, sortDir, 'tokenCount')}>
|
||||
{renderSortButton('tokenCount', tr('令牌数'))}
|
||||
</th>
|
||||
)}
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('延迟')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
<th aria-sort={resolveSortAria(sortBy, sortDir, 'avgLatency')}>
|
||||
{renderSortButton('avgLatency', tr('延迟'))}
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('successRate'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('成功率')} {sortBy === 'successRate' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
<th aria-sort={resolveSortAria(sortBy, sortDir, 'successRate')}>
|
||||
{renderSortButton('successRate', tr('成功率'))}
|
||||
</th>
|
||||
<th style={{ width: 60 }}>{tr('操作')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((m) => {
|
||||
const isExpanded = expanded === m.name;
|
||||
return (
|
||||
<React.Fragment key={m.name}>
|
||||
<tr onClick={() => setExpanded(isExpanded ? null : m.name)} style={{ cursor: 'pointer' }}>
|
||||
{paged.map((m) => {
|
||||
const isExpanded = expanded === m.name;
|
||||
const detailId = `model-table-detail-${encodeURIComponent(m.name)}`;
|
||||
return (
|
||||
<React.Fragment key={m.name}>
|
||||
<tr
|
||||
onClick={() => setExpanded(isExpanded ? null : m.name)}
|
||||
onKeyDown={(event) => {
|
||||
if (shouldToggleModelExpansionByKeyboard(event)) {
|
||||
setExpanded(isExpanded ? null : m.name);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={isExpanded ? detailId : undefined}
|
||||
aria-label={`${isExpanded ? tr('收起') : tr('展开')} ${m.name}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td>
|
||||
<BrandIcon model={m.name} size={28} />
|
||||
</td>
|
||||
@@ -989,7 +1081,7 @@ export default function Models() {
|
||||
{isExpanded ? (
|
||||
<tr className="log-detail-row">
|
||||
<td colSpan={isUserView ? 5 : 7} style={{ padding: 0 }}>
|
||||
<div className="anim-collapse is-open">
|
||||
<div id={detailId} className="anim-collapse is-open">
|
||||
<div className="anim-collapse-inner">
|
||||
<div style={{ padding: '12px 16px 12px 54px' }}>
|
||||
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
|
||||
@@ -1085,7 +1177,7 @@ export default function Models() {
|
||||
{/* Pagination */}
|
||||
{filteredModels.length > 0 && (
|
||||
<div className="pagination">
|
||||
<button className="pagination-btn" disabled={safePageVal <= 1} onClick={() => setPage(p => p - 1)}>
|
||||
<button type="button" className="pagination-btn" disabled={safePageVal <= 1} onClick={() => setPage(p => p - 1)}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
@@ -1100,17 +1192,23 @@ export default function Models() {
|
||||
pageNum = safePageVal - 3 + i;
|
||||
}
|
||||
return (
|
||||
<button key={pageNum} className={`pagination-btn ${safePageVal === pageNum ? 'active' : ''}`} onClick={() => setPage(pageNum)}>
|
||||
<button
|
||||
key={pageNum}
|
||||
type="button"
|
||||
className={`pagination-btn ${safePageVal === pageNum ? 'active' : ''}`}
|
||||
aria-current={safePageVal === pageNum ? 'page' : undefined}
|
||||
onClick={() => setPage(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button className="pagination-btn" disabled={safePageVal >= totalPages} onClick={() => setPage(p => p + 1)}>
|
||||
<button type="button" className="pagination-btn" disabled={safePageVal >= totalPages} onClick={() => setPage(p => p + 1)}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
<div className="pagination-size">
|
||||
{tr('每页条数')}:
|
||||
<div style={{ minWidth: 86 }}>
|
||||
<div className="models-pagination-size-select" style={{ minWidth: 86 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(pageSize)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 MoneyPrinter from './MoneyPrinter.js';
|
||||
|
||||
@@ -9,6 +11,7 @@ const { apiMock, userApiMock } = vi.hoisted(() => {
|
||||
getMoneyPrinterJobs: vi.fn(),
|
||||
getMoneyPrinterStatus: vi.fn(),
|
||||
getWorkflows: vi.fn(),
|
||||
getWorkflowModelFolder: vi.fn(),
|
||||
createMoneyPrinterJob: vi.fn(),
|
||||
cancelMoneyPrinterJob: vi.fn(),
|
||||
clearMoneyPrinterJobs: vi.fn(),
|
||||
@@ -60,6 +63,22 @@ function findButton(root: ReactTestRenderer, label: string) {
|
||||
));
|
||||
}
|
||||
|
||||
function findComfyModelInput(root: ReactTestRenderer) {
|
||||
return root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.list === 'money-printer-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 || '');
|
||||
}
|
||||
|
||||
function defaultStatus(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
success: true,
|
||||
@@ -90,11 +109,21 @@ describe('MoneyPrinter page', () => {
|
||||
apiMock.getMoneyPrinterJobs.mockResolvedValue({ success: true, jobs: [] });
|
||||
apiMock.getMoneyPrinterStatus.mockResolvedValue(defaultStatus());
|
||||
apiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
|
||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [{ name: 'wan2.1-t2v' }, { name: 'seedance-1-0-pro-t2v-250528' }],
|
||||
names: ['wan2.1-t2v', 'seedance-1-0-pro-t2v-250528'],
|
||||
});
|
||||
apiMock.clearMoneyPrinterJobs.mockResolvedValue({ success: true, cleared: 0 });
|
||||
apiMock.cancelMoneyPrinterJob.mockResolvedValue({ success: true, job: null });
|
||||
userApiMock.getMoneyPrinterJobs.mockResolvedValue({ success: true, jobs: [] });
|
||||
userApiMock.getMoneyPrinterStatus.mockResolvedValue(defaultStatus());
|
||||
userApiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
|
||||
userApiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [{ name: 'wan2.1-t2v' }, { name: 'seedance-1-0-pro-t2v-250528' }],
|
||||
names: ['wan2.1-t2v', 'seedance-1-0-pro-t2v-250528'],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -109,12 +138,214 @@ describe('MoneyPrinter page', () => {
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Comfy Cloud');
|
||||
expect(text).toContain('内置 Cloud Seedance');
|
||||
expect(text).toContain('Comfy API 模型:seedance-1-0-lite-t2v-250428');
|
||||
expect(text).toContain('创作生产平台');
|
||||
expect(text).toContain('Comfy API 模型会随任务提交');
|
||||
expect(text).toContain('当前模型来源:模板默认 · seedance-1-0-lite-t2v-250428');
|
||||
expect(apiMock.getWorkflowModelFolder).toHaveBeenCalledWith('video_models');
|
||||
expect(text).toContain('完成后进入工作空间');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('submits the selected Comfy API video_models model instead of the BoosAPI video model', async () => {
|
||||
apiMock.createMoneyPrinterJob.mockResolvedValue({
|
||||
success: true,
|
||||
job: {
|
||||
id: 'mp_new',
|
||||
type: 'money-printer-video',
|
||||
title: '短视频工厂任务',
|
||||
status: 'running',
|
||||
progress: 1,
|
||||
progressTotal: 5,
|
||||
result: {},
|
||||
},
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter();
|
||||
const comfyModelInput = root.root.findAll((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.list === 'money-printer-comfy-api-models'
|
||||
))[0];
|
||||
expect(comfyModelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
||||
const modelOptions = root.root.findAll((node) => node.type === 'option');
|
||||
expect(modelOptions.some((node) => node.props.value === 'wan2.1-t2v')).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
comfyModelInput.props.onChange({
|
||||
target: { value: 'seedance-1-0-pro-t2v-250528' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(root, '生成短视频').props.onClick();
|
||||
});
|
||||
|
||||
expect(apiMock.createMoneyPrinterJob).toHaveBeenCalledTimes(1);
|
||||
const payload = apiMock.createMoneyPrinterJob.mock.calls[0][0];
|
||||
expect(payload.videoBackend).toBe('comfy');
|
||||
expect(payload.comfyApiModel).toBe('seedance-1-0-pro-t2v-250528');
|
||||
expect(payload.videoModel).toBeUndefined();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows internal recharge guidance when a user Comfy video task hits quota', async () => {
|
||||
const quotaError = new Error('下游密钥成本额度已用完') as any;
|
||||
quotaError.status = 429;
|
||||
quotaError.billing = {
|
||||
rechargePath: '/user/recharge',
|
||||
usagePath: '/user/usage',
|
||||
};
|
||||
userApiMock.createMoneyPrinterJob.mockRejectedValue(quotaError);
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter('/user/money-printer');
|
||||
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 {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the template model and filters non-video aliases out of the Comfy 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 renderMoneyPrinter();
|
||||
const modelOptions = findDatalistOptionValues(root, 'money-printer-comfy-api-models');
|
||||
expect(modelOptions).toContain('seedance-1-0-lite-t2v-250428');
|
||||
expect(modelOptions).toContain('seedance-1-0-pro-t2v-250528');
|
||||
expect(modelOptions).not.toContain('gpt-4o');
|
||||
expect(modelOptions).not.toContain('gpt-image-2');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the selected Cloud template model when Comfy API video_models is empty', async () => {
|
||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [],
|
||||
names: [],
|
||||
});
|
||||
apiMock.createMoneyPrinterJob.mockResolvedValue({
|
||||
success: true,
|
||||
job: {
|
||||
id: 'mp_cloud_template_model',
|
||||
type: 'money-printer-video',
|
||||
title: 'Cloud 模板任务',
|
||||
status: 'running',
|
||||
progress: 1,
|
||||
progressTotal: 5,
|
||||
result: {},
|
||||
},
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter();
|
||||
const comfyModelInput = findComfyModelInput(root);
|
||||
expect(comfyModelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
||||
const modelOptions = findDatalistOptionValues(root, 'money-printer-comfy-api-models');
|
||||
expect(modelOptions).toContain('seedance-1-0-lite-t2v-250428');
|
||||
expect(collectText(root.root)).toContain('已使用模板内 Comfy API 模型 seedance-1-0-lite-t2v-250428');
|
||||
const generateButton = findButton(root, '生成短视频');
|
||||
expect(generateButton.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
generateButton.props.onClick();
|
||||
});
|
||||
|
||||
expect(apiMock.createMoneyPrinterJob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
videoBackend: 'comfy',
|
||||
comfyApiModel: 'seedance-1-0-lite-t2v-250428',
|
||||
}));
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads Comfy API video models from item payloads and normalizes old Seedance aliases', async () => {
|
||||
apiMock.getMoneyPrinterStatus.mockResolvedValue(defaultStatus({
|
||||
comfy: { enabled: true, backend: 'external', baseUrl: 'https://comfy.example.com' },
|
||||
builtinCloudTemplate: { enabled: false },
|
||||
}));
|
||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{ value: 'seedance-1-0-lite-t2v' },
|
||||
{ filename: 'wan2.2-t2v' },
|
||||
{ name: 'gpt-4o' },
|
||||
],
|
||||
});
|
||||
apiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter();
|
||||
const comfyModelInput = findComfyModelInput(root);
|
||||
expect(comfyModelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
||||
const modelOptions = findDatalistOptionValues(root, 'money-printer-comfy-api-models');
|
||||
expect(modelOptions).toContain('seedance-1-0-lite-t2v-250428');
|
||||
expect(modelOptions).toContain('wan2.2-t2v');
|
||||
expect(modelOptions).not.toContain('seedance-1-0-lite-t2v');
|
||||
expect(modelOptions).not.toContain('gpt-4o');
|
||||
expect(collectText(root.root)).toContain('已从 Comfy API video_models 读取 2 个视频模型');
|
||||
expect(collectText(root.root)).toContain('当前模型来源:Comfy video_models · seedance-1-0-lite-t2v-250428');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a manually selected Comfy API video model after refreshing data', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter();
|
||||
const comfyModelInput = findComfyModelInput(root);
|
||||
expect(comfyModelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
||||
|
||||
await act(async () => {
|
||||
comfyModelInput.props.onChange({
|
||||
target: { value: 'seedance-1-0-pro-t2v-250528' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(root, '刷新').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const refreshedInput = findComfyModelInput(root);
|
||||
expect(refreshedInput.props.value).toBe('seedance-1-0-pro-t2v-250528');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies a quick topic preset into the production form', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
@@ -134,6 +365,33 @@ describe('MoneyPrinter page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('announces the template debug collapse state', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter();
|
||||
let toggle = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& String(node.props.className || '').includes('money-printer-advanced-toggle')
|
||||
));
|
||||
expect(toggle.props['aria-expanded']).toBe(false);
|
||||
expect(toggle.props['aria-controls']).toBe('money-printer-template-debug');
|
||||
|
||||
await act(async () => {
|
||||
toggle.props.onClick();
|
||||
});
|
||||
|
||||
toggle = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& String(node.props.className || '').includes('money-printer-advanced-toggle')
|
||||
));
|
||||
expect(toggle.props['aria-expanded']).toBe(true);
|
||||
const panel = root.root.find((node) => node.props.id === 'money-printer-template-debug');
|
||||
expect(String(panel.props.className)).toContain('is-open');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('filters the task queue by failed jobs', async () => {
|
||||
apiMock.getMoneyPrinterJobs.mockResolvedValue({
|
||||
success: true,
|
||||
@@ -174,6 +432,14 @@ describe('MoneyPrinter page', () => {
|
||||
findButton(root, '失败').props.onClick();
|
||||
});
|
||||
|
||||
const tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
const failedTab = tabs.find((node) => collectText(node).includes('失败'));
|
||||
expect(failedTab?.props['aria-selected']).toBe(true);
|
||||
expect(failedTab?.props['aria-controls']).toBe('money-printer-filter-panel-failed');
|
||||
const activePanel = root.root.find((node) => node.props.role === 'tabpanel');
|
||||
expect(activePanel.props.id).toBe('money-printer-filter-panel-failed');
|
||||
expect(activePanel.props['aria-labelledby']).toBe('money-printer-filter-tab-failed');
|
||||
|
||||
const filteredText = collectText(root.root);
|
||||
expect(filteredText).not.toContain('正在生成的视频');
|
||||
expect(filteredText).toContain('失败的视频');
|
||||
@@ -182,4 +448,202 @@ describe('MoneyPrinter page', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces Comfy prompt ids, key names, prompts, and remote output links on jobs', async () => {
|
||||
apiMock.getMoneyPrinterJobs.mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
id: 'mp_done',
|
||||
type: 'money-printer-video',
|
||||
title: 'Comfy 成片',
|
||||
status: 'succeeded',
|
||||
message: '短视频生成完成',
|
||||
progress: 5,
|
||||
progressTotal: 5,
|
||||
result: {
|
||||
videoBackend: 'comfy',
|
||||
model: 'comfy-api',
|
||||
keyName: '默认生产 Key',
|
||||
keyId: 77,
|
||||
promptId: 'prompt_abcdef123456',
|
||||
prompt: 'cinematic product demo, fast camera movement, clean studio lighting',
|
||||
topic: 'BossAI 商品短视频',
|
||||
request: {
|
||||
videoBackend: 'comfy',
|
||||
comfyApiModel: 'seedance-1-0-lite-t2v-250428',
|
||||
duration: 5,
|
||||
ratio: '9:16',
|
||||
resolution: '720p',
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
kind: 'video',
|
||||
status: 'completed',
|
||||
remoteUrl: 'https://cdn.example.com/final.mp4',
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: '2026-05-26 10:02:00',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter();
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Prompt prompt_abcde');
|
||||
expect(text).toContain('默认生产 Key');
|
||||
expect(text).toContain('seedance-1-0-lite-t2v-250428');
|
||||
expect(text).toContain('视频提示词:cinematic product demo');
|
||||
expect(text).toContain('远端产物 · 视频');
|
||||
const remoteLink = root.root.find((node) => (
|
||||
node.type === 'a'
|
||||
&& node.props.href === 'https://cdn.example.com/final.mp4'
|
||||
));
|
||||
expect(remoteLink.props.target).toBe('_blank');
|
||||
expect(remoteLink.props.rel).toBe('noreferrer');
|
||||
|
||||
const traceLinks = root.root.findAll((node) => node.type === 'a');
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=seedance-1-0-lite-t2v-250428&status=success&downstreamKeyId=77')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=seedance-1-0-lite-t2v-250428&status=failed&downstreamKeyId=77')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=seedance-1-0-lite-t2v-250428')).toBe(true);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('links user video tasks back to self-service usage records', async () => {
|
||||
userApiMock.getMoneyPrinterJobs.mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
id: 'mp_user_done',
|
||||
type: 'money-printer-video',
|
||||
title: '用户短视频',
|
||||
status: 'succeeded',
|
||||
message: '短视频生成完成',
|
||||
progress: 5,
|
||||
progressTotal: 5,
|
||||
result: {
|
||||
videoBackend: 'comfy',
|
||||
model: 'seedance-1-0-pro-t2v-250528',
|
||||
keyName: '默认生产 Key',
|
||||
keyId: 77,
|
||||
topic: 'BossAI 商品短视频',
|
||||
request: {
|
||||
videoBackend: 'comfy',
|
||||
comfyApiModel: 'seedance-1-0-pro-t2v-250528',
|
||||
},
|
||||
},
|
||||
createdAt: '2026-05-26 10:02:00',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter('/user/money-printer');
|
||||
const traceLinks = root.root.findAll((node) => node.type === 'a');
|
||||
expect(userApiMock.getMoneyPrinterJobs).toHaveBeenCalled();
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=BossAI+%E5%95%86%E5%93%81%E7%9F%AD%E8%A7%86%E9%A2%91&key_id=77&key=%E9%BB%98%E8%AE%A4%E7%94%9F%E4%BA%A7+Key&status=success')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=BossAI+%E5%95%86%E5%93%81%E7%9F%AD%E8%A7%86%E9%A2%91&key_id=77&key=%E9%BB%98%E8%AE%A4%E7%94%9F%E4%BA%A7+Key&status=failed')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=seedance-1-0-pro-t2v-250528')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link).startsWith('/logs'))).toBe(false);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the submitted Comfy API model on jobs before the backend result model is filled', async () => {
|
||||
apiMock.getMoneyPrinterJobs.mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
id: 'mp_running_comfy_model',
|
||||
type: 'money-printer-video',
|
||||
title: 'Comfy 排队任务',
|
||||
status: 'running',
|
||||
message: '等待 Comfy API',
|
||||
progress: 2,
|
||||
progressTotal: 5,
|
||||
result: {
|
||||
request: {
|
||||
videoBackend: 'comfy',
|
||||
comfyApiModel: 'seedance-1-0-pro-t2v-250528',
|
||||
duration: 5,
|
||||
ratio: '9:16',
|
||||
resolution: '720p',
|
||||
},
|
||||
},
|
||||
createdAt: '2026-05-26 10:03:00',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter();
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Comfy 排队任务');
|
||||
expect(text).toContain('seedance-1-0-pro-t2v-250528');
|
||||
expect(text).not.toContain('Comfy 模板模型');
|
||||
expect(text).not.toContain('Comfy API 模型待回填');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the Comfy API model when reusing a terminal task', async () => {
|
||||
apiMock.getMoneyPrinterJobs.mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
id: 'mp_retry',
|
||||
type: 'money-printer-video',
|
||||
title: '可复用的视频',
|
||||
status: 'failed',
|
||||
message: '任务失败',
|
||||
progress: 3,
|
||||
progressTotal: 5,
|
||||
result: {
|
||||
request: {
|
||||
topic: '复用这个短视频主题',
|
||||
videoBackend: 'comfy',
|
||||
comfyApiModel: 'seedance-1-0-pro-i2v-250528',
|
||||
duration: 5,
|
||||
ratio: '9:16',
|
||||
resolution: '720p',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
root = await renderMoneyPrinter();
|
||||
await act(async () => {
|
||||
findButton(root, '复用参数').props.onClick();
|
||||
});
|
||||
|
||||
const comfyModelInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.list === 'money-printer-comfy-api-models'
|
||||
));
|
||||
expect(comfyModelInput.props.value).toBe('seedance-1-0-pro-i2v-250528');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps production form and job headers shrink-safe on mobile', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.money-printer-form-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(160px, 100%), 1fr));');
|
||||
expect(css).toContain('.money-printer-section-head,\n .money-printer-job-head {\n align-items: stretch;\n flex-direction: column;');
|
||||
expect(css).toContain('.money-printer-status-pill {\n width: fit-content;\n max-width: 100%;');
|
||||
expect(css).not.toContain('grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));');
|
||||
});
|
||||
});
|
||||
|
||||
+346
-45
@@ -1,7 +1,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { api, userApi, type MoneyPrinterJob, type MoneyPrinterStatusResponse, type WorkflowSummary } from '../api.js';
|
||||
import type { SessionAudience } from '../authSession.js';
|
||||
import {
|
||||
buildCreationFailureHref,
|
||||
buildCreationModelTesterHref,
|
||||
buildCreationUsageHref,
|
||||
} from './helpers/creationTraceLinks.js';
|
||||
import { BillingRechargeAlert, isQuotaRechargeError } from './helpers/billingRecharge.js';
|
||||
import { formatDateTimeLocal, formatDateTimeMinuteLocal } from './helpers/checkinLogTime.js';
|
||||
|
||||
const STYLE_PRESETS = [
|
||||
'爆款短视频,节奏紧凑,适合口播',
|
||||
@@ -88,10 +95,7 @@ function progress(job: MoneyPrinterJob) {
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
return value ? formatDateTimeLocal(value) : '';
|
||||
}
|
||||
|
||||
function jobAssets(job: MoneyPrinterJob) {
|
||||
@@ -115,10 +119,22 @@ function jobBackendLabel(job: MoneyPrinterJob) {
|
||||
}
|
||||
|
||||
function jobModelLabel(job: MoneyPrinterJob) {
|
||||
return job.result?.model
|
||||
|| job.result?.request?.videoModel
|
||||
|| job.result?.request?.model
|
||||
|| (String(job.result?.videoBackend || job.result?.request?.videoBackend || '').toLowerCase() === 'comfy' ? 'Comfy 模板模型' : '默认视频模型');
|
||||
const request = job.result?.request || {};
|
||||
const comfyApiModel = normalizeComfyApiVideoModelName(
|
||||
job.result?.comfyApiModel
|
||||
|| job.result?.comfy_api_model
|
||||
|| request.comfyApiModel
|
||||
|| request.comfy_api_model
|
||||
|| request.apiModel
|
||||
|| request.api_model
|
||||
|| '',
|
||||
);
|
||||
return comfyApiModel
|
||||
|| job.result?.model
|
||||
|| request.videoModel
|
||||
|| request.video_model
|
||||
|| request.model
|
||||
|| (String(job.result?.videoBackend || request.videoBackend || request.video_backend || '').toLowerCase() === 'comfy' ? 'Comfy API 模型待回填' : '默认视频模型');
|
||||
}
|
||||
|
||||
function jobTemplateLabel(job: MoneyPrinterJob, workflows: WorkflowSummary[]) {
|
||||
@@ -143,6 +159,45 @@ function planValue(job: MoneyPrinterJob, key: string) {
|
||||
return job.result?.script?.plan?.[key] || '';
|
||||
}
|
||||
|
||||
function jobPromptId(job: MoneyPrinterJob) {
|
||||
return job.result?.promptId || job.result?.video?.promptId || job.result?.prompt_id || '';
|
||||
}
|
||||
|
||||
function jobPromptText(job: MoneyPrinterJob) {
|
||||
return job.result?.prompt || job.result?.video?.prompt || job.result?.request?.videoPrompt || job.result?.request?.video_prompt || '';
|
||||
}
|
||||
|
||||
function jobKeyName(job: MoneyPrinterJob) {
|
||||
return job.result?.keyName || job.result?.request?.keyName || '';
|
||||
}
|
||||
|
||||
function jobKeyId(job: MoneyPrinterJob) {
|
||||
return job.result?.keyId || job.result?.downstreamKeyId || job.result?.request?.keyId || job.result?.request?.downstreamKeyId || null;
|
||||
}
|
||||
|
||||
function jobTracePrompt(job: MoneyPrinterJob) {
|
||||
return job.result?.topic || job.result?.request?.topic || jobPromptText(job) || job.title || job.id;
|
||||
}
|
||||
|
||||
function jobTraceQuery(job: MoneyPrinterJob) {
|
||||
return job.result?.topic || job.result?.request?.topic || jobModelLabel(job);
|
||||
}
|
||||
|
||||
function assetHref(asset: any, workspacePath: string) {
|
||||
if (asset?.remoteUrl) return String(asset.remoteUrl);
|
||||
if (asset?.publicId) return `${workspacePath}?q=${encodeURIComponent(asset.publicId)}`;
|
||||
return workspacePath;
|
||||
}
|
||||
|
||||
function assetLabel(asset: any) {
|
||||
const kind = asset?.kind === 'video' ? '视频' : asset?.kind === 'audio' ? '音频' : asset?.kind === 'image' ? '图片' : '产物';
|
||||
return asset?.publicId
|
||||
? `工作空间 · ${kind} · ${asset.publicId}`
|
||||
: asset?.remoteUrl
|
||||
? `远端产物 · ${kind}`
|
||||
: `产物 · ${asset?.status || '已记录'}`;
|
||||
}
|
||||
|
||||
function workflowPromptFromGraph(graph: any) {
|
||||
return graph?.prompt || graph?.workflow || graph?.comfyPrompt || graphToComfyPrompt(graph) || graph || null;
|
||||
}
|
||||
@@ -172,15 +227,46 @@ function inferMappingFromPrompt(prompt: any) {
|
||||
const promptNode = find(['text', 'prompt', 'wan', 'ltx', 'seedance', 'video'], ['prompt', 'positive', 'text']);
|
||||
const negativeNode = find(['negative'], ['negative']);
|
||||
const videoNode = find(['wan', 'ltx', 'video'], ['duration', 'ratio', 'resolution']);
|
||||
const modelNode = find(['bytedance', 'wan', 'ltx', 'seedance', 'video'], ['model']);
|
||||
return {
|
||||
prompt: { nodeId: String(promptNode), field: firstInputField(String(promptNode), ['prompt', 'positive', 'text', 'positive_prompt'], 'text') },
|
||||
negativePrompt: { nodeId: String(negativeNode), field: 'text' },
|
||||
model: { nodeId: String(modelNode || videoNode), field: firstInputField(String(modelNode || videoNode), ['model', 'model_name', 'ckpt_name'], 'model') },
|
||||
ratio: { nodeId: String(videoNode), field: firstInputField(String(videoNode), ['ratio', 'model.ratio', 'aspect_ratio'], 'ratio') },
|
||||
resolution: { nodeId: String(videoNode), field: firstInputField(String(videoNode), ['model.resolution', 'resolution', 'size'], 'resolution') },
|
||||
duration: { nodeId: String(videoNode), field: firstInputField(String(videoNode), ['model.duration', 'duration', 'length'], 'duration') },
|
||||
};
|
||||
}
|
||||
|
||||
function firstStringValue(value: any, fields: string[]): string {
|
||||
for (const field of fields) {
|
||||
const next = value?.[field];
|
||||
if (typeof next === 'string' && next.trim()) return next.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function firstComfyApiModelFromPrompt(prompt: any): string {
|
||||
const nodes = prompt && typeof prompt === 'object' && !Array.isArray(prompt)
|
||||
? Object.values(prompt as Record<string, any>)
|
||||
: [];
|
||||
const modelFields = ['model', 'model_name', 'ckpt_name', 'unet_name'];
|
||||
const apiVideoKeywords = ['bytedance', 'seedance', 'wan', 'ltx', 'kling', 'video'];
|
||||
const preferred = nodes.find((node: any) => {
|
||||
const classType = String(node?.class_type || node?.type || '').toLowerCase();
|
||||
const inputs = node?.inputs || {};
|
||||
return apiVideoKeywords.some((keyword) => classType.includes(keyword))
|
||||
&& Boolean(firstStringValue(inputs, modelFields));
|
||||
}) as any;
|
||||
const fallback = nodes.find((node: any) => firstStringValue(node?.inputs || {}, modelFields)) as any;
|
||||
return firstStringValue((preferred || fallback)?.inputs || {}, modelFields);
|
||||
}
|
||||
|
||||
function comfyApiModelFromWorkflow(workflow: WorkflowSummary | null): string {
|
||||
if (!workflow) return '';
|
||||
return normalizeComfyApiVideoModelName(firstComfyApiModelFromPrompt(workflowPromptFromGraph(workflow.graph)));
|
||||
}
|
||||
|
||||
function normalizeParam(classType: string, field: string, value: any) {
|
||||
if (classType === 'KSampler' && field === 'cfg') {
|
||||
return typeof value === 'number' && value >= 0 && value <= 50 ? value : 8;
|
||||
@@ -255,16 +341,7 @@ function templateLabel(workflow: WorkflowSummary) {
|
||||
|
||||
function workflowTimestampLabel(workflow: WorkflowSummary) {
|
||||
const value = workflow.updatedAt || workflow.createdAt;
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return value ? formatDateTimeMinuteLocal(value) : '';
|
||||
}
|
||||
|
||||
function workflowOptionLabel(workflow: WorkflowSummary) {
|
||||
@@ -331,6 +408,55 @@ const DEFAULT_BUILTIN_CLOUD_DURATION = 5;
|
||||
const DEFAULT_BUILTIN_CLOUD_RESOLUTION = '480p';
|
||||
const DEFAULT_CLOUD_RATIOS = ['9:16', '16:9', '1:1'];
|
||||
const DEFAULT_CLOUD_RESOLUTIONS = ['480p', '720p', '1080p'];
|
||||
const DEFAULT_BOOSAPI_VIDEO_MODEL = 'seedance-1-0-lite-t2v-250428';
|
||||
|
||||
function normalizeComfyApiVideoModelName(value?: string | null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (normalized === 'seedance-1-0-lite-t2v') return 'seedance-1-0-lite-t2v-250428';
|
||||
if (normalized === 'seedance-1-0-lite-i2v') return 'seedance-1-0-pro-i2v-250528';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: Array<string | undefined | null>) {
|
||||
const seen = new Set<string>();
|
||||
const items: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
items.push(normalized);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function isComfyApiVideoModelName(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return [
|
||||
'seedance',
|
||||
'wan',
|
||||
'kling',
|
||||
'ltx',
|
||||
'hunyuanvideo',
|
||||
'video',
|
||||
't2v',
|
||||
'i2v',
|
||||
].some((keyword) => normalized.includes(keyword));
|
||||
}
|
||||
|
||||
function workflowModelNames(payload: any) {
|
||||
const rawItems = [
|
||||
...(Array.isArray(payload?.names) ? payload.names : []),
|
||||
...(Array.isArray(payload?.items) ? payload.items : []),
|
||||
...(Array.isArray(payload?.models) ? payload.models : []),
|
||||
...(Array.isArray(payload?.data) ? payload.data : []),
|
||||
];
|
||||
return uniqueNonEmpty(rawItems.map((item) => normalizeComfyApiVideoModelName(
|
||||
typeof item === 'string'
|
||||
? item
|
||||
: item?.name ?? item?.model ?? item?.value ?? item?.filename ?? item?.path
|
||||
)));
|
||||
}
|
||||
|
||||
export default function MoneyPrinter() {
|
||||
const location = useLocation();
|
||||
@@ -339,7 +465,7 @@ export default function MoneyPrinter() {
|
||||
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
||||
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
|
||||
const [scriptModel, setScriptModel] = useState('gpt-5.4');
|
||||
const [videoModel, setVideoModel] = useState('seedance-1-0-lite-t2v');
|
||||
const [videoModel, setVideoModel] = useState(DEFAULT_BOOSAPI_VIDEO_MODEL);
|
||||
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('comfy');
|
||||
const [style, setStyle] = useState(STYLE_PRESETS[0]);
|
||||
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
|
||||
@@ -348,7 +474,10 @@ export default function MoneyPrinter() {
|
||||
const [resolution, setResolution] = useState('1080p');
|
||||
const [requirements, setRequirements] = useState('画面要有科技感,突出统一网关、多模型、多模态和工作流能力。');
|
||||
const [comfyPrompt, setComfyPrompt] = useState('');
|
||||
const [comfyNodeMapping, setComfyNodeMapping] = useState('{\n "prompt": { "nodeId": "", "field": "prompt" },\n "negativePrompt": { "nodeId": "", "field": "text" },\n "ratio": { "nodeId": "", "field": "ratio" },\n "resolution": { "nodeId": "", "field": "resolution" },\n "duration": { "nodeId": "", "field": "duration" }\n}');
|
||||
const [comfyNodeMapping, setComfyNodeMapping] = useState('{\n "prompt": { "nodeId": "", "field": "prompt" },\n "negativePrompt": { "nodeId": "", "field": "text" },\n "model": { "nodeId": "", "field": "model" },\n "ratio": { "nodeId": "", "field": "ratio" },\n "resolution": { "nodeId": "", "field": "resolution" },\n "duration": { "nodeId": "", "field": "duration" }\n}');
|
||||
const [comfyApiModelDraft, setComfyApiModelDraft] = useState('');
|
||||
const comfyApiModelTouchedRef = useRef(false);
|
||||
const [comfyApiModels, setComfyApiModels] = useState<string[]>([]);
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [showAdvancedComfy, setShowAdvancedComfy] = useState(false);
|
||||
@@ -360,6 +489,9 @@ export default function MoneyPrinter() {
|
||||
const [cancellingIds, setCancellingIds] = useState<string[]>([]);
|
||||
const [jobFilter, setJobFilter] = useState<JobFilter>('all');
|
||||
const [error, setError] = useState('');
|
||||
const [billingError, setBillingError] = useState<unknown>(null);
|
||||
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 cloudDurationMin = status?.builtinCloudTemplate?.duration?.min ?? 3;
|
||||
const cloudDurationMax = status?.builtinCloudTemplate?.duration?.max ?? 12;
|
||||
@@ -405,14 +537,23 @@ export default function MoneyPrinter() {
|
||||
});
|
||||
}, [client, isCloudComfy]);
|
||||
|
||||
const loadComfyApiModels = useCallback(async () => {
|
||||
try {
|
||||
const payload = await client.getWorkflowModelFolder('video_models');
|
||||
setComfyApiModels(workflowModelNames(payload).filter(isComfyApiVideoModelName));
|
||||
} catch {
|
||||
setComfyApiModels([]);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadJobs(), loadWorkflows(), loadStatus()]).catch((err) => setError(err?.message || '短视频任务加载失败'));
|
||||
}, [loadJobs, loadWorkflows, loadStatus]);
|
||||
Promise.all([loadJobs(), loadWorkflows(), loadStatus(), loadComfyApiModels()])
|
||||
.catch((err) => setError(err?.message || '短视频任务加载失败'));
|
||||
}, [loadComfyApiModels, loadJobs, loadWorkflows, loadStatus]);
|
||||
|
||||
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
||||
const useBuiltinCloudTemplate = videoBackend === 'comfy' && isCloudComfy && templateId === BUILTIN_CLOUD_TEMPLATE_VALUE;
|
||||
const comfyHasTemplate = useBuiltinCloudTemplate || Boolean(templateId && selectedWorkflow);
|
||||
const canSubmit = Boolean(topic.trim()) && !submitting && (videoBackend !== 'comfy' || comfyHasTemplate);
|
||||
const ratioOptions = useBuiltinCloudTemplate ? cloudRatios : DEFAULT_CLOUD_RATIOS;
|
||||
const resolutionOptions = useBuiltinCloudTemplate ? cloudResolutions : DEFAULT_CLOUD_RESOLUTIONS;
|
||||
const durationMin = useBuiltinCloudTemplate ? cloudDurationMin : 5;
|
||||
@@ -433,6 +574,53 @@ export default function MoneyPrinter() {
|
||||
const currentTemplateLabel = useBuiltinCloudTemplate
|
||||
? '内置 Cloud Seedance'
|
||||
: selectedWorkflow?.name || (videoBackend === 'comfy' ? '未选择模板' : '不使用模板');
|
||||
const templateComfyApiModel = useBuiltinCloudTemplate
|
||||
? normalizeComfyApiVideoModelName(status?.builtinCloudTemplate?.model || DEFAULT_BOOSAPI_VIDEO_MODEL)
|
||||
: comfyApiModelFromWorkflow(selectedWorkflow);
|
||||
const configuredComfyApiModels = useMemo(
|
||||
() => uniqueNonEmpty(comfyApiModels.filter(isComfyApiVideoModelName)),
|
||||
[comfyApiModels],
|
||||
);
|
||||
const builtinCloudComfyApiModels = useMemo(
|
||||
() => uniqueNonEmpty((Array.isArray(status?.builtinCloudTemplate?.models) ? status.builtinCloudTemplate.models : [])
|
||||
.map((model: unknown) => normalizeComfyApiVideoModelName(String(model || '')))
|
||||
.filter(isComfyApiVideoModelName)),
|
||||
[status?.builtinCloudTemplate?.models],
|
||||
);
|
||||
const comfyApiModelCandidates = useMemo(
|
||||
() => uniqueNonEmpty([
|
||||
templateComfyApiModel,
|
||||
...configuredComfyApiModels,
|
||||
...(isCloudComfy ? builtinCloudComfyApiModels : []),
|
||||
].filter(isComfyApiVideoModelName)),
|
||||
[builtinCloudComfyApiModels, configuredComfyApiModels, isCloudComfy, templateComfyApiModel],
|
||||
);
|
||||
const fallbackComfyApiModel = comfyApiModelCandidates[0] || '';
|
||||
const comfyApiModel = normalizeComfyApiVideoModelName(comfyApiModelDraft.trim() || fallbackComfyApiModel);
|
||||
const comfyApiModelLabel = comfyApiModel || '未选择 Comfy API 模型';
|
||||
const hasConfiguredComfyApiVideoModels = configuredComfyApiModels.length > 0;
|
||||
const hasSelectedComfyApiModel = Boolean(comfyApiModel.trim());
|
||||
const canSubmit = Boolean(topic.trim())
|
||||
&& !submitting
|
||||
&& (videoBackend !== 'comfy' || (comfyHasTemplate && hasSelectedComfyApiModel));
|
||||
const comfyApiModelHint = templateComfyApiModel
|
||||
? (hasConfiguredComfyApiVideoModels
|
||||
? `默认使用模板内 Comfy API 模型 ${templateComfyApiModel};video_models 另有 ${configuredComfyApiModels.length} 个候选`
|
||||
: `已使用模板内 Comfy API 模型 ${templateComfyApiModel}`)
|
||||
: hasConfiguredComfyApiVideoModels
|
||||
? `已从 Comfy API video_models 读取 ${configuredComfyApiModels.length} 个视频模型`
|
||||
: builtinCloudComfyApiModels.length > 0
|
||||
? `已使用 Cloud 内置 Comfy API 模型候选 ${builtinCloudComfyApiModels.length} 个`
|
||||
: '未读取到 Comfy API 视频模型,请先在 Comfy 模板或 video_models 中配置模型';
|
||||
const comfyApiModelSourceLabel = templateComfyApiModel && comfyApiModel === templateComfyApiModel
|
||||
? '模板默认'
|
||||
: configuredComfyApiModels.includes(comfyApiModel)
|
||||
? 'Comfy video_models'
|
||||
: builtinCloudComfyApiModels.includes(comfyApiModel)
|
||||
? 'Cloud 内置候选'
|
||||
: comfyApiModel
|
||||
? '手动输入'
|
||||
: '未选择';
|
||||
const archivedAssetCount = useMemo(
|
||||
() => jobs.reduce((total, job) => total + jobAssets(job).filter((asset: any) => asset?.status !== 'failed').length, 0),
|
||||
[jobs],
|
||||
@@ -463,16 +651,18 @@ export default function MoneyPrinter() {
|
||||
if (!useBuiltinCloudTemplate) return;
|
||||
setDuration((current) => {
|
||||
const numeric = Number.isFinite(current) ? current : cloudDefaultDuration;
|
||||
return Math.max(cloudDurationMin, Math.min(cloudDurationMax, numeric));
|
||||
const defaultDuration = Math.max(cloudDurationMin, Math.min(cloudDurationMax, cloudDefaultDuration));
|
||||
const next = numeric < cloudDurationMin || numeric > cloudDurationMax ? defaultDuration : numeric;
|
||||
return Object.is(current, next) ? current : next;
|
||||
});
|
||||
setResolution((current) => cloudResolutions.includes(current) ? current : DEFAULT_BUILTIN_CLOUD_RESOLUTION);
|
||||
setRatio((current) => cloudRatios.includes(current) ? current : '9:16');
|
||||
}, [cloudDefaultDuration, cloudDurationMax, cloudDurationMin, cloudRatios, cloudResolutions, useBuiltinCloudTemplate]);
|
||||
}, [cloudDefaultDuration, cloudDurationMax, cloudDurationMin, cloudRatios, cloudResolutions, duration, useBuiltinCloudTemplate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (useBuiltinCloudTemplate) {
|
||||
setComfyPrompt('');
|
||||
setComfyNodeMapping('{\n "prompt": { "nodeId": "1", "field": "prompt" },\n "ratio": { "nodeId": "1", "field": "aspect_ratio" },\n "resolution": { "nodeId": "1", "field": "resolution" },\n "duration": { "nodeId": "1", "field": "duration" }\n}');
|
||||
setComfyNodeMapping('{\n "prompt": { "nodeId": "1", "field": "prompt" },\n "model": { "nodeId": "1", "field": "model" },\n "ratio": { "nodeId": "1", "field": "aspect_ratio" },\n "resolution": { "nodeId": "1", "field": "resolution" },\n "duration": { "nodeId": "1", "field": "duration" }\n}');
|
||||
return;
|
||||
}
|
||||
if (!selectedWorkflow || videoBackend !== 'comfy') return;
|
||||
@@ -482,6 +672,12 @@ export default function MoneyPrinter() {
|
||||
setComfyNodeMapping(JSON.stringify(mapping || inferMappingFromPrompt(prompt), null, 2));
|
||||
}, [selectedWorkflow?.publicId, videoBackend, useBuiltinCloudTemplate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoBackend !== 'comfy') return;
|
||||
if (comfyApiModelTouchedRef.current) return;
|
||||
setComfyApiModelDraft(fallbackComfyApiModel);
|
||||
}, [fallbackComfyApiModel, videoBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobs.some((job) => !terminal(job.status))) return;
|
||||
const timer = globalThis.setInterval(() => {
|
||||
@@ -498,6 +694,7 @@ export default function MoneyPrinter() {
|
||||
try {
|
||||
await refresh();
|
||||
await loadWorkflows();
|
||||
await loadComfyApiModels();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '短视频任务刷新失败');
|
||||
} finally {
|
||||
@@ -518,10 +715,14 @@ export default function MoneyPrinter() {
|
||||
if (!topic.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setBillingError(null);
|
||||
try {
|
||||
if (videoBackend === 'comfy' && !comfyHasTemplate) {
|
||||
throw new Error(isCloudComfy ? '请选择 Cloud 内置模板或一个真实视频工作流模板' : '当前 Comfy 后端需要选择一个真实视频工作流模板');
|
||||
}
|
||||
if (videoBackend === 'comfy' && !hasSelectedComfyApiModel) {
|
||||
throw new Error('请先选择 Comfy API 视频模型');
|
||||
}
|
||||
const nextDuration = useBuiltinCloudTemplate
|
||||
? Math.max(cloudDurationMin, Math.min(cloudDurationMax, Number(duration) || cloudDefaultDuration))
|
||||
: duration;
|
||||
@@ -533,7 +734,9 @@ export default function MoneyPrinter() {
|
||||
topic: topic.trim(),
|
||||
scriptModel: scriptModel.trim(),
|
||||
videoBackend,
|
||||
videoModel: videoModel.trim(),
|
||||
...(videoBackend === 'comfy'
|
||||
? { comfyApiModel: comfyApiModel.trim() || undefined }
|
||||
: { videoModel: videoModel.trim() }),
|
||||
style,
|
||||
audience: audienceText,
|
||||
duration: nextDuration,
|
||||
@@ -549,6 +752,7 @@ export default function MoneyPrinter() {
|
||||
});
|
||||
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
|
||||
} catch (err: any) {
|
||||
setBillingError(isQuotaRechargeError(err) ? err : null);
|
||||
setError(err?.message || '短视频任务创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -597,6 +801,9 @@ export default function MoneyPrinter() {
|
||||
setComfyPrompt(request.comfyPrompt ? JSON.stringify(request.comfyPrompt, null, 2) : comfyPrompt);
|
||||
setComfyNodeMapping(request.comfyNodeMapping ? JSON.stringify(request.comfyNodeMapping, null, 2) : comfyNodeMapping);
|
||||
setTemplateId(request.templateId || templateId);
|
||||
const restoredComfyApiModel = normalizeComfyApiVideoModelName(request.comfyApiModel || request.comfy_api_model || request.apiModel || request.api_model || '');
|
||||
comfyApiModelTouchedRef.current = Boolean(String(restoredComfyApiModel).trim());
|
||||
setComfyApiModelDraft(restoredComfyApiModel);
|
||||
setShowAdvancedComfy(Boolean(request.comfyPrompt || request.comfyNodeMapping));
|
||||
};
|
||||
|
||||
@@ -632,7 +839,11 @@ export default function MoneyPrinter() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||
{billingError ? (
|
||||
<BillingRechargeAlert error={billingError} />
|
||||
) : error ? (
|
||||
<div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="money-printer-summary">
|
||||
<div className="money-printer-summary-card">
|
||||
@@ -643,7 +854,7 @@ export default function MoneyPrinter() {
|
||||
<div className="money-printer-summary-card">
|
||||
<span>视频模板</span>
|
||||
<strong>{videoBackend === 'comfy' ? currentTemplateLabel : videoModel || '默认模型'}</strong>
|
||||
<em>{videoBackend === 'comfy' ? `${videoTemplateCount} 个视频模板可选` : '直接调用 /v1/videos'}</em>
|
||||
<em>{videoBackend === 'comfy' ? `Comfy API 模型:${comfyApiModelLabel}` : '直接调用 /v1/videos'}</em>
|
||||
</div>
|
||||
<div className="money-printer-summary-card">
|
||||
<span>队列</span>
|
||||
@@ -712,15 +923,37 @@ export default function MoneyPrinter() {
|
||||
<option value="comfy">Comfy API 模板</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="money-printer-field">
|
||||
<span>视频模型</span>
|
||||
<input
|
||||
value={videoModel}
|
||||
onChange={(event) => setVideoModel(event.target.value)}
|
||||
disabled={videoBackend === 'comfy'}
|
||||
title={videoBackend === 'comfy' ? 'Comfy API 模式使用所选工作流模板里的模型' : undefined}
|
||||
/>
|
||||
</label>
|
||||
{videoBackend === 'comfy' ? (
|
||||
<label className="money-printer-field">
|
||||
<span>Comfy API 模型</span>
|
||||
<input
|
||||
list="money-printer-comfy-api-models"
|
||||
value={comfyApiModelDraft}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
comfyApiModelTouchedRef.current = Boolean(nextValue.trim());
|
||||
setComfyApiModelDraft(nextValue);
|
||||
}}
|
||||
placeholder={fallbackComfyApiModel || '先在 Comfy API 模型候选中选择'}
|
||||
/>
|
||||
<datalist id="money-printer-comfy-api-models">
|
||||
{comfyApiModelCandidates.map((model) => (
|
||||
<option key={model} value={model} />
|
||||
))}
|
||||
</datalist>
|
||||
<em className="money-printer-field-hint">
|
||||
{comfyApiModelHint}
|
||||
</em>
|
||||
</label>
|
||||
) : (
|
||||
<label className="money-printer-field">
|
||||
<span>视频模型</span>
|
||||
<input
|
||||
value={videoModel}
|
||||
onChange={(event) => setVideoModel(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="money-printer-field">
|
||||
<span>时长</span>
|
||||
<input type="number" min={durationMin} max={durationMax} value={duration} onChange={(event) => setDuration(Number(event.target.value || durationMin))} />
|
||||
@@ -758,6 +991,14 @@ export default function MoneyPrinter() {
|
||||
|
||||
{videoBackend === 'comfy' && (
|
||||
<div className="money-printer-comfy-box">
|
||||
<div className="money-printer-comfy-production-brief">
|
||||
<span>创作生产平台</span>
|
||||
<strong>Comfy API 模型会随任务提交</strong>
|
||||
<p>
|
||||
当前模型来源:{comfyApiModelSourceLabel}
|
||||
{comfyApiModel ? ` · ${comfyApiModel}` : ' · 提交前必须选择真实视频模型'}。任务生成后会进入队列并归档到工作空间。
|
||||
</p>
|
||||
</div>
|
||||
<div className="money-printer-section-head">
|
||||
<div>
|
||||
<h2>Comfy API 工作流</h2>
|
||||
@@ -781,7 +1022,7 @@ export default function MoneyPrinter() {
|
||||
<strong>内置 Cloud Seedance 文生视频</strong>
|
||||
<span>
|
||||
当前后端为 Cloud,使用官方 {status?.builtinCloudTemplate?.nodeType || 'ByteDanceTextToVideoNode'}
|
||||
{status?.builtinCloudTemplate?.model ? ` / ${status.builtinCloudTemplate.model}` : ''}。时长 {cloudDurationMin}-{cloudDurationMax} 秒,若账号无余额会直接显示余额不足。
|
||||
{comfyApiModel ? ` / ${comfyApiModel}` : ' / 等待 Comfy API 模型'}。时长 {cloudDurationMin}-{cloudDurationMax} 秒,若账号无余额会直接显示余额不足。
|
||||
</span>
|
||||
</div>
|
||||
) : selectedWorkflow ? (
|
||||
@@ -790,6 +1031,8 @@ export default function MoneyPrinter() {
|
||||
<span>
|
||||
{workflowLooksVideoCapable(selectedWorkflow) ? '已识别为视频工作流' : '未识别视频关键词,请确认这是 Comfy 视频模板'}
|
||||
{' · '}
|
||||
{comfyApiModel ? `API 模型 ${comfyApiModel}` : '未选择 Comfy API 模型'}
|
||||
{' · '}
|
||||
{mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '系统会按节点结构自动推断提示词和参数映射'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -802,11 +1045,16 @@ export default function MoneyPrinter() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost money-printer-advanced-toggle"
|
||||
aria-expanded={showAdvancedComfy}
|
||||
aria-controls="money-printer-template-debug"
|
||||
onClick={() => setShowAdvancedComfy((prev) => !prev)}
|
||||
>
|
||||
{showAdvancedComfy ? '收起模板调试' : '展开模板调试'}
|
||||
</button>
|
||||
<div className={`anim-collapse ${showAdvancedComfy ? 'is-open' : ''}`.trim()}>
|
||||
<div
|
||||
id="money-printer-template-debug"
|
||||
className={`anim-collapse ${showAdvancedComfy ? 'is-open' : ''}`.trim()}
|
||||
>
|
||||
<div className="anim-collapse-inner">
|
||||
<label className="money-printer-field">
|
||||
<span>Comfy prompt JSON</span>
|
||||
@@ -837,9 +1085,11 @@ export default function MoneyPrinter() {
|
||||
{filterOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
id={`money-printer-filter-tab-${option.key}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={jobFilter === option.key}
|
||||
aria-controls={`money-printer-filter-panel-${option.key}`}
|
||||
className={jobFilter === option.key ? 'is-active' : ''}
|
||||
onClick={() => setJobFilter(option.key)}
|
||||
>
|
||||
@@ -849,7 +1099,12 @@ export default function MoneyPrinter() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="money-printer-job-list">
|
||||
<div
|
||||
id={activeJobFilterPanelId}
|
||||
className="money-printer-job-list"
|
||||
role="tabpanel"
|
||||
aria-labelledby={activeJobFilterTabId}
|
||||
>
|
||||
{jobs.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: 220 }}>
|
||||
<div className="empty-state-title">暂无短视频任务</div>
|
||||
@@ -866,6 +1121,12 @@ export default function MoneyPrinter() {
|
||||
const spec = jobSpecLabel(job);
|
||||
const template = jobTemplateLabel(job, workflows);
|
||||
const cancelling = cancellingIds.includes(job.id);
|
||||
const promptId = jobPromptId(job);
|
||||
const promptText = jobPromptText(job);
|
||||
const keyName = jobKeyName(job);
|
||||
const keyId = jobKeyId(job);
|
||||
const traceModel = jobModelLabel(job);
|
||||
const tracePrompt = jobTracePrompt(job);
|
||||
return (
|
||||
<article key={job.id} className={`money-printer-job-card is-${statusTone(job.status)}`}>
|
||||
<div className="money-printer-job-head">
|
||||
@@ -881,20 +1142,60 @@ export default function MoneyPrinter() {
|
||||
<span>{jobBackendLabel(job)}</span>
|
||||
<span>{jobModelLabel(job)}</span>
|
||||
{template && <span>{template}</span>}
|
||||
{promptId && <span>Prompt {String(promptId).slice(0, 12)}</span>}
|
||||
{keyName && <span>{keyName}</span>}
|
||||
{spec && <span>{spec}</span>}
|
||||
{job.result?.videoUrl && <a href={job.result.videoUrl} target="_blank" rel="noreferrer">远端视频</a>}
|
||||
</div>
|
||||
{planValue(job, 'hook') && <p className="money-printer-hook">{planValue(job, 'hook')}</p>}
|
||||
{promptText && <p className="money-printer-hook">视频提示词:{String(promptText).slice(0, 160)}</p>}
|
||||
{job.error && <div className="money-printer-job-error">{job.error}</div>}
|
||||
{assets.length > 0 && (
|
||||
<div className="money-printer-assets">
|
||||
{assets.map((asset: any) => (
|
||||
<a key={asset.publicId || asset.remoteUrl} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId || '')}`}>
|
||||
工作空间 · {asset.publicId || asset.status}
|
||||
<a
|
||||
key={asset.publicId || asset.remoteUrl || asset.status}
|
||||
href={assetHref(asset, workspacePath)}
|
||||
target={asset.remoteUrl ? '_blank' : undefined}
|
||||
rel={asset.remoteUrl ? 'noreferrer' : undefined}
|
||||
>
|
||||
{assetLabel(asset)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="creation-trace-links" aria-label="任务追踪">
|
||||
<Link
|
||||
to={buildCreationUsageHref({
|
||||
audience,
|
||||
query: audience === 'user' ? jobTraceQuery(job) : undefined,
|
||||
model: traceModel,
|
||||
prompt: tracePrompt,
|
||||
taskId: job.id,
|
||||
keyId,
|
||||
keyName,
|
||||
status: job.status,
|
||||
})}
|
||||
>
|
||||
{audience === 'user' ? '使用记录' : '使用日志'}
|
||||
</Link>
|
||||
<Link
|
||||
to={buildCreationFailureHref({
|
||||
audience,
|
||||
query: audience === 'user' ? jobTraceQuery(job) : undefined,
|
||||
model: traceModel,
|
||||
prompt: tracePrompt,
|
||||
taskId: job.id,
|
||||
keyId,
|
||||
keyName,
|
||||
})}
|
||||
>
|
||||
失败记录
|
||||
</Link>
|
||||
<Link to={buildCreationModelTesterHref(traceModel)}>
|
||||
模型测试器
|
||||
</Link>
|
||||
</div>
|
||||
<div className="money-printer-job-buttons">
|
||||
{!terminal(job.status) && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void cancel(job.id)} disabled={cancelling}>
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type MonitorSite = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
description: string;
|
||||
requiresLinuxDoOAuth?: boolean;
|
||||
};
|
||||
|
||||
type MonitorConfig = {
|
||||
ldohCookieConfigured: boolean;
|
||||
ldohCookieMasked?: string;
|
||||
};
|
||||
|
||||
const MONITOR_SITES: MonitorSite[] = [
|
||||
{
|
||||
id: 'check-linux-do',
|
||||
name: 'check.linux.do',
|
||||
url: 'https://check.linux.do',
|
||||
description: 'LinuxDo 可用性监控',
|
||||
},
|
||||
{
|
||||
id: 'ldoh-105117',
|
||||
name: 'ldoh.105117.xyz',
|
||||
url: 'https://ldoh.105117.xyz',
|
||||
description: 'LDOH 监控面板',
|
||||
requiresLinuxDoOAuth: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Monitors() {
|
||||
const toast = useToast();
|
||||
const [activeSiteId, setActiveSiteId] = useState(MONITOR_SITES[0].id);
|
||||
const [reloadSeed, setReloadSeed] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [showFallbackHint, setShowFallbackHint] = useState(false);
|
||||
const [monitorConfig, setMonitorConfig] = useState<MonitorConfig>({ ldohCookieConfigured: false });
|
||||
const [cookieInput, setCookieInput] = useState('');
|
||||
const [savingCookie, setSavingCookie] = useState(false);
|
||||
|
||||
const activeSite = useMemo(
|
||||
() => MONITOR_SITES.find((site) => site.id === activeSiteId) || MONITOR_SITES[0],
|
||||
[activeSiteId],
|
||||
);
|
||||
|
||||
const loadMonitorConfig = async () => {
|
||||
try {
|
||||
const res = await api.getMonitorConfig();
|
||||
setMonitorConfig({
|
||||
ldohCookieConfigured: !!res?.ldohCookieConfigured,
|
||||
ldohCookieMasked: typeof res?.ldohCookieMasked === 'string' ? res.ldohCookieMasked : '',
|
||||
});
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '加载监控配置失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadMonitorConfig();
|
||||
// Set HttpOnly monitor auth cookie for iframe proxy.
|
||||
void api.initMonitorSession().catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setShowFallbackHint(false);
|
||||
const timer = window.setTimeout(() => {
|
||||
setShowFallbackHint(true);
|
||||
}, 4500);
|
||||
void api.initMonitorSession().catch(() => {});
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [activeSiteId, reloadSeed]);
|
||||
|
||||
const usingCookieProxy = activeSite.id === 'ldoh-105117' && monitorConfig.ldohCookieConfigured;
|
||||
const oauthHintPresence = useAnimatedVisibility(Boolean(activeSite.requiresLinuxDoOAuth), 220);
|
||||
const fallbackHintPresence = useAnimatedVisibility(showFallbackHint && !loaded, 180);
|
||||
const directSiteUrl = `${activeSite.url.replace(/\/$/, '')}/`;
|
||||
const iframeUrl = usingCookieProxy ? '/monitor-proxy/ldoh/' : directSiteUrl;
|
||||
const ldohOauthUrl = `${directSiteUrl}api/oauth/initiate?returnTo=%2F`;
|
||||
|
||||
const handleSaveCookie = async () => {
|
||||
setSavingCookie(true);
|
||||
try {
|
||||
await api.updateMonitorConfig({ ldohCookie: cookieInput.trim() || null });
|
||||
await loadMonitorConfig();
|
||||
setCookieInput('');
|
||||
setReloadSeed((prev) => prev + 1);
|
||||
toast.success('LDOH Cookie 已更新');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存 Cookie 失败');
|
||||
} finally {
|
||||
setSavingCookie(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackHint = usingCookieProxy
|
||||
? '代理模式已启用:若仍无法加载,请检查 Cookie 是否过期后重新保存。'
|
||||
: '当前站点可能禁止 iframe 内嵌,或 OAuth 跨站 Cookie 受限。建议先新窗口授权再回到此页刷新。';
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in monitor-page">
|
||||
<div className="monitor-toolbar page-header">
|
||||
<div>
|
||||
<h2 className="page-title">{tr('监控内嵌')}</h2>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
在 BoosAPI 内查看外部站点监控页面。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={() => setReloadSeed((prev) => prev + 1)}
|
||||
data-tooltip="重新加载当前站点"
|
||||
aria-label="重新加载当前站点"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.open(directSiteUrl, '_blank', 'noopener,noreferrer')}
|
||||
data-tooltip="在新窗口直接打开目标站点"
|
||||
aria-label="在新窗口直接打开目标站点"
|
||||
>
|
||||
新窗口打开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="monitor-tabs card">
|
||||
{MONITOR_SITES.map((site) => (
|
||||
<button
|
||||
key={site.id}
|
||||
type="button"
|
||||
className={`monitor-tab ${site.id === activeSite.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveSiteId(site.id)}
|
||||
>
|
||||
<span>{site.name}</span>
|
||||
<span className="monitor-tab-desc">{site.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{oauthHintPresence.shouldRender && (
|
||||
<div className={`monitor-oauth-hint card panel-presence ${oauthHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{usingCookieProxy ? '已启用 Cookie 代理模式' : '该站点需要 LinuxDo OAuth 授权'}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.65 }}>
|
||||
{!usingCookieProxy && (
|
||||
<>
|
||||
1. 先点击“授权登录(新窗口)”完成 LinuxDo 登录。<br />
|
||||
2. 回来后把 `ld_auth_session` 填到下方,启用 Cookie 代理内嵌。<br />
|
||||
</>
|
||||
)}
|
||||
{usingCookieProxy && (
|
||||
<>
|
||||
当前使用服务端代理访问,不依赖跨站第三方 Cookie。<br />
|
||||
已保存 Cookie:{monitorConfig.ldohCookieMasked || '(已配置)'}<br />
|
||||
</>
|
||||
)}
|
||||
Cookie 过期后请重新粘贴保存。
|
||||
</div>
|
||||
|
||||
<div className="monitor-cookie-row">
|
||||
<input
|
||||
value={cookieInput}
|
||||
onChange={(e) => setCookieInput(e.target.value)}
|
||||
placeholder="粘贴 ld_auth_session 或 ld_auth_session=xxx"
|
||||
className="monitor-cookie-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveCookie}
|
||||
disabled={savingCookie}
|
||||
>
|
||||
{savingCookie ? '保存中...' : (cookieInput.trim() ? '保存 Cookie' : '清空 Cookie')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={() => window.open(ldohOauthUrl, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
授权登录(新窗口)
|
||||
</button>
|
||||
{usingCookieProxy && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={() => window.open('/monitor-proxy/ldoh/', '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
通过代理打开
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="monitor-frame-shell card">
|
||||
{fallbackHintPresence.shouldRender && (
|
||||
<div className={`monitor-hint panel-presence ${fallbackHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
{fallbackHint}
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
key={`${activeSite.id}-${reloadSeed}-${usingCookieProxy ? 'proxy' : 'direct'}`}
|
||||
src={iframeUrl}
|
||||
title={`monitor-${activeSite.id}`}
|
||||
className="monitor-iframe"
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -398,7 +398,10 @@ export default function NotificationSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '16px 20px', opacity: runtime.telegramEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
|
||||
<div
|
||||
className="notification-settings-dense-grid"
|
||||
style={{ opacity: runtime.telegramEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}
|
||||
>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram API Base URL</div>
|
||||
<input
|
||||
@@ -473,7 +476,10 @@ export default function NotificationSettings() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '16px 20px', opacity: runtime.smtpEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
|
||||
<div
|
||||
className="notification-settings-dense-grid"
|
||||
style={{ opacity: runtime.smtpEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}
|
||||
>
|
||||
{/* Host */}
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>SMTP 服务器</div>
|
||||
@@ -486,7 +492,7 @@ export default function NotificationSettings() {
|
||||
/>
|
||||
</div>
|
||||
{/* Port & Secure */}
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-end' }}>
|
||||
<div className="notification-settings-inline-row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>端口</div>
|
||||
<input
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,572 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useOptionalI18n } from '../i18n.js';
|
||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||
import { formatDateTimeMinuteLocal } from './helpers/checkinLogTime.js';
|
||||
import { buildRouteDiagnosticHref } from './helpers/routeDiagnosticLink.js';
|
||||
import { buildRouteHealthScore } from './token-routes/routeHealth.js';
|
||||
import type { RouteSummaryRow } from './token-routes/types.js';
|
||||
import { resolveRouteTitle } from './token-routes/utils.js';
|
||||
|
||||
type LoadState = {
|
||||
dashboard: any | null;
|
||||
insights: any | null;
|
||||
routes: RouteSummaryRow[];
|
||||
tasks: any[];
|
||||
events: any[];
|
||||
};
|
||||
|
||||
type Tone = 'success' | 'warning' | 'danger' | 'muted' | 'primary';
|
||||
type Translator = (text: string) => string;
|
||||
|
||||
const initialState: LoadState = {
|
||||
dashboard: null,
|
||||
insights: null,
|
||||
routes: [],
|
||||
tasks: [],
|
||||
events: [],
|
||||
};
|
||||
|
||||
function safeNumber(value: unknown): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
if (!Number.isFinite(value)) return '0.0%';
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const identityT: Translator = (text) => text;
|
||||
|
||||
function formatLatency(value: unknown, t: Translator = identityT): string {
|
||||
const ms = safeNumber(value);
|
||||
if (ms <= 0) return t('暂无');
|
||||
return `${Math.round(ms).toLocaleString()}ms`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: unknown): string {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? '--' : formatDateTimeMinuteLocal(value.toISOString());
|
||||
}
|
||||
const raw = typeof value === 'string' ? value : '';
|
||||
if (!raw.trim()) return '--';
|
||||
return formatDateTimeMinuteLocal(raw);
|
||||
}
|
||||
|
||||
function statusTone(status: string | null | undefined): Tone {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (normalized === 'failed' || normalized === 'error' || normalized === 'cancelled') return 'danger';
|
||||
if (normalized === 'running' || normalized === 'pending') return 'warning';
|
||||
if (normalized === 'succeeded' || normalized === 'success' || normalized === 'completed') return 'success';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function statusLabel(status: string | null | undefined, t: Translator = identityT): string {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (normalized === 'failed') return t('失败');
|
||||
if (normalized === 'error') return t('异常');
|
||||
if (normalized === 'cancelled') return t('已取消');
|
||||
if (normalized === 'running') return t('运行中');
|
||||
if (normalized === 'pending') return t('等待中');
|
||||
if (normalized === 'succeeded' || normalized === 'success' || normalized === 'completed') return t('成功');
|
||||
return normalized || t('未知');
|
||||
}
|
||||
|
||||
function translateRouteHealthReason(reason: string, t: Translator): string {
|
||||
const text = String(reason || '');
|
||||
if (!text) return '';
|
||||
const ratioChannel = text.match(/^(\d+)\/(\d+) 通道可用$/);
|
||||
if (ratioChannel) return `${ratioChannel[1]}/${ratioChannel[2]} ${t('通道可用')}`;
|
||||
const ratioCandidate = text.match(/^(\d+)\/(\d+) 候选可选$/);
|
||||
if (ratioCandidate) return `${ratioCandidate[1]}/${ratioCandidate[2]} ${t('候选可选')}`;
|
||||
const recentFailure = text.match(/^(\d+) 个候选近期失败或被避让$/);
|
||||
if (recentFailure) return `${recentFailure[1]} ${t('个候选近期失败或被避让')}`;
|
||||
const probability = text.match(/^最高选择概率 (\d+)%$/);
|
||||
if (probability) return `${t('最高选择概率')} ${probability[1]}%`;
|
||||
return t(text);
|
||||
}
|
||||
|
||||
function eventTone(level: string | null | undefined): Tone {
|
||||
if (level === 'error') return 'danger';
|
||||
if (level === 'warning') return 'warning';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
const OPS_LOG_CONTEXT_KEYS = ['siteId', 'from', 'to', 'q', 'model', 'downstreamKeyId', 'traceId'] as const;
|
||||
|
||||
function buildOpsLogContextSearch(search: string): string {
|
||||
const source = new URLSearchParams(search);
|
||||
const params = new URLSearchParams();
|
||||
OPS_LOG_CONTEXT_KEYS.forEach((key) => {
|
||||
const value = source.get(key);
|
||||
if (value) params.set(key, value);
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function buildLogsHref(status?: 'success' | 'failed', contextSearch = '') {
|
||||
const params = new URLSearchParams(contextSearch);
|
||||
if (status) params.set('status', status);
|
||||
const query = params.toString();
|
||||
return query ? `/logs?${query}` : '/logs';
|
||||
}
|
||||
|
||||
function buildSiteLogsHref(siteId: unknown, status?: 'success' | 'failed', contextSearch = '') {
|
||||
const params = new URLSearchParams(contextSearch);
|
||||
params.set('siteId', String(siteId));
|
||||
if (status) params.set('status', status);
|
||||
const query = params.toString();
|
||||
return query ? `/logs?${query}` : '/logs';
|
||||
}
|
||||
|
||||
function buildOpsRouteDiagnosticHref(route: RouteSummaryRow) {
|
||||
return buildRouteDiagnosticHref(route.modelPattern || route.displayName, route.id);
|
||||
}
|
||||
|
||||
export default function OpsMonitor() {
|
||||
const { t } = useOptionalI18n();
|
||||
const location = useLocation();
|
||||
const logContextSearch = useMemo(() => buildOpsLogContextSearch(location.search), [location.search]);
|
||||
const [state, setState] = useState<LoadState>(initialState);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastLoadedAt, setLastLoadedAt] = useState<string>('');
|
||||
const [errorSourceCount, setErrorSourceCount] = useState(0);
|
||||
|
||||
const load = useCallback(async (refresh = false) => {
|
||||
if (refresh) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
setErrorSourceCount(0);
|
||||
|
||||
const [dashboardResult, insightsResult, routesResult, tasksResult, eventsResult] = await Promise.allSettled([
|
||||
api.getDashboardSnapshot(refresh ? { refresh: true } : undefined),
|
||||
api.getDashboardInsights(refresh ? { refresh: true } : undefined),
|
||||
api.getRoutesSummary(),
|
||||
api.getTasks(40),
|
||||
api.getEvents('limit=12'),
|
||||
]);
|
||||
|
||||
const next: LoadState = {
|
||||
dashboard: dashboardResult.status === 'fulfilled' ? dashboardResult.value : null,
|
||||
insights: insightsResult.status === 'fulfilled' ? insightsResult.value : null,
|
||||
routes: routesResult.status === 'fulfilled' && Array.isArray(routesResult.value) ? routesResult.value : [],
|
||||
tasks: tasksResult.status === 'fulfilled' && Array.isArray((tasksResult.value as any)?.tasks)
|
||||
? (tasksResult.value as any).tasks
|
||||
: [],
|
||||
events: eventsResult.status === 'fulfilled'
|
||||
? Array.isArray((eventsResult.value as any)?.data)
|
||||
? (eventsResult.value as any).data
|
||||
: Array.isArray(eventsResult.value)
|
||||
? eventsResult.value
|
||||
: []
|
||||
: [],
|
||||
};
|
||||
|
||||
setState(next);
|
||||
setLastLoadedAt(formatDateTime(new Date().toISOString()));
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
|
||||
const failed = [dashboardResult, insightsResult, routesResult, tasksResult, eventsResult]
|
||||
.filter((result) => result.status === 'rejected').length;
|
||||
setErrorSourceCount(failed);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, [load]);
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
const dashboard = state.dashboard || {};
|
||||
const insights = state.insights || {};
|
||||
const proxy24hSuccess = safeNumber(dashboard?.proxy24h?.success);
|
||||
const proxy24hTotal = safeNumber(dashboard?.proxy24h?.total);
|
||||
const failed24h = Math.max(0, proxy24hTotal - proxy24hSuccess);
|
||||
const successRate = proxy24hTotal > 0 ? (proxy24hSuccess / proxy24hTotal) * 100 : 100;
|
||||
const requestsPerMinute = safeNumber(dashboard?.performance?.requestsPerMinute);
|
||||
const tokensPerMinute = safeNumber(dashboard?.performance?.tokensPerMinute);
|
||||
const activeAccounts = safeNumber(dashboard?.activeAccounts);
|
||||
const totalAccounts = safeNumber(dashboard?.totalAccounts);
|
||||
const activeAccountRate = totalAccounts > 0 ? (activeAccounts / totalAccounts) * 100 : 0;
|
||||
const siteAvailability = Array.isArray(insights?.siteAvailability) ? insights.siteAvailability : [];
|
||||
const activeSites = siteAvailability.filter((site: any) => safeNumber(site?.totalRequests) > 0);
|
||||
const riskySites = activeSites
|
||||
.filter((site: any) => {
|
||||
const availability = typeof site?.availabilityPercent === 'number' ? site.availabilityPercent : 100;
|
||||
return availability < 99 || safeNumber(site?.failedCount) > 0 || safeNumber(site?.averageLatencyMs) > 1800;
|
||||
})
|
||||
.sort((a: any, b: any) => {
|
||||
const aScore = (100 - (typeof a?.availabilityPercent === 'number' ? a.availabilityPercent : 100)) * 20
|
||||
+ safeNumber(a?.failedCount) * 5
|
||||
+ Math.max(0, safeNumber(a?.averageLatencyMs) - 1200) / 100;
|
||||
const bScore = (100 - (typeof b?.availabilityPercent === 'number' ? b.availabilityPercent : 100)) * 20
|
||||
+ safeNumber(b?.failedCount) * 5
|
||||
+ Math.max(0, safeNumber(b?.averageLatencyMs) - 1200) / 100;
|
||||
return bScore - aScore;
|
||||
});
|
||||
const avgSiteLatency = activeSites.length > 0
|
||||
? Math.round(activeSites.reduce((sum: number, site: any) => sum + safeNumber(site?.averageLatencyMs), 0) / activeSites.length)
|
||||
: 0;
|
||||
const routeItems = state.routes
|
||||
.map((route) => ({
|
||||
route,
|
||||
title: resolveRouteTitle(route),
|
||||
health: buildRouteHealthScore(route),
|
||||
}))
|
||||
.sort((a, b) => a.health.score - b.health.score);
|
||||
const riskyRoutes = routeItems.filter((item) => item.health.tone === 'danger' || item.health.tone === 'warning');
|
||||
const activeTasks = state.tasks.filter((task) => ['pending', 'running'].includes(String(task?.status || '').toLowerCase()));
|
||||
const failedTasks = state.tasks.filter((task) => ['failed', 'error'].includes(String(task?.status || '').toLowerCase()));
|
||||
const errorEvents = state.events.filter((event) => event?.level === 'error');
|
||||
const warningEvents = state.events.filter((event) => event?.level === 'warning');
|
||||
const overallTone: Tone = failed24h > 0 || riskyRoutes.some((item) => item.health.tone === 'danger') || errorEvents.length > 0
|
||||
? 'danger'
|
||||
: successRate < 99 || riskySites.length > 0 || warningEvents.length > 0
|
||||
? 'warning'
|
||||
: 'success';
|
||||
const overallLabel = overallTone === 'success' ? t('健康') : overallTone === 'warning' ? t('需关注') : t('异常');
|
||||
|
||||
return {
|
||||
proxy24hTotal,
|
||||
proxy24hSuccess,
|
||||
failed24h,
|
||||
successRate,
|
||||
requestsPerMinute,
|
||||
tokensPerMinute,
|
||||
activeAccounts,
|
||||
totalAccounts,
|
||||
activeAccountRate,
|
||||
activeSites,
|
||||
riskySites,
|
||||
avgSiteLatency,
|
||||
routeItems,
|
||||
riskyRoutes,
|
||||
activeTasks,
|
||||
failedTasks,
|
||||
errorEvents,
|
||||
warningEvents,
|
||||
overallTone,
|
||||
overallLabel,
|
||||
};
|
||||
}, [state, t]);
|
||||
|
||||
const queue = useMemo(() => {
|
||||
const items: Array<{
|
||||
key: string;
|
||||
source: string;
|
||||
title: string;
|
||||
metric: string;
|
||||
detail: string;
|
||||
tone: Tone;
|
||||
href: string;
|
||||
action: string;
|
||||
}> = [];
|
||||
|
||||
if (metrics.failed24h > 0) {
|
||||
items.push({
|
||||
key: 'failed-logs',
|
||||
source: t('请求错误'),
|
||||
title: t('最近 24h 存在失败请求'),
|
||||
metric: `${Math.round(metrics.failed24h).toLocaleString()} ${t('条')}`,
|
||||
detail: `${t('成功率')} ${formatPercent(metrics.successRate)}, ${t('先确认错误是否集中在单个站点、模型或账号。')}`,
|
||||
tone: metrics.successRate < 95 ? 'danger' : 'warning',
|
||||
href: buildLogsHref('failed', logContextSearch),
|
||||
action: t('查看失败日志'),
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.riskyRoutes[0]) {
|
||||
const item = metrics.riskyRoutes[0];
|
||||
items.push({
|
||||
key: `route-${item.route.id}`,
|
||||
source: t('路由健康'),
|
||||
title: item.title,
|
||||
metric: `${t(item.health.label)} ${item.health.score}`,
|
||||
detail: item.health.reasons.slice(0, 3).map((reason) => translateRouteHealthReason(reason, t)).join(t(';')) || t('路由候选需要确认。'),
|
||||
tone: item.health.tone === 'danger' ? 'danger' : 'warning',
|
||||
href: buildOpsRouteDiagnosticHref(item.route),
|
||||
action: t('诊断路由'),
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.riskySites[0]) {
|
||||
const site = metrics.riskySites[0];
|
||||
items.push({
|
||||
key: `site-${site.siteId}`,
|
||||
source: t('站点健康'),
|
||||
title: site.siteName || `${t('站点')} ${site.siteId}`,
|
||||
metric: typeof site.availabilityPercent === 'number' ? formatPercent(site.availabilityPercent) : t('暂无可用率'),
|
||||
detail: `${safeNumber(site.failedCount)} ${t('次失败')}, ${t('平均响应')} ${formatLatency(site.averageLatencyMs, t)}.`,
|
||||
tone: (typeof site.availabilityPercent === 'number' && site.availabilityPercent < 95) ? 'danger' : 'warning',
|
||||
href: buildSiteLogsHref(site.siteId, 'failed', logContextSearch),
|
||||
action: t('查看站点日志'),
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.failedTasks[0]) {
|
||||
const task = metrics.failedTasks[0];
|
||||
items.push({
|
||||
key: `task-${task.id}`,
|
||||
source: t('后台任务'),
|
||||
title: task.title || task.taskType || task.type || task.id,
|
||||
metric: statusLabel(task.status, t),
|
||||
detail: task.error || task.message || t('任务失败,需要进入对应业务页重试或查看运行日志。'),
|
||||
tone: 'danger',
|
||||
href: '/events?type=runtime',
|
||||
action: t('查看运行日志'),
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
items.push({
|
||||
key: 'clear',
|
||||
source: t('值班队列'),
|
||||
title: t('暂无高优先级事项'),
|
||||
metric: metrics.overallLabel,
|
||||
detail: t('请求、路由、站点和后台任务没有触发高风险阈值。'),
|
||||
tone: 'success',
|
||||
href: buildLogsHref(undefined, logContextSearch),
|
||||
action: t('继续观察'),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [logContextSearch, metrics, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in admin-ops-page ops-monitor-page">
|
||||
<div className="page-header">
|
||||
<div className="page-header-copy">
|
||||
<div className="page-title">{t('运维监控')}</div>
|
||||
<div className="page-subtitle">{t('正在整理平台运行数据。')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ops-monitor-skeleton-grid">
|
||||
{[0, 1, 2, 3].map((item) => <div key={item} className="skeleton" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in admin-ops-page ops-monitor-page">
|
||||
<div className="page-header ops-monitor-header">
|
||||
<div className="page-header-copy">
|
||||
<div className="ops-monitor-kicker">
|
||||
<span className="ops-monitor-live-dot" data-tone={metrics.overallTone} />
|
||||
<span>{metrics.overallLabel}</span>
|
||||
{lastLoadedAt && <em>{t('刷新:')}{lastLoadedAt}</em>}
|
||||
</div>
|
||||
<div className="page-title">{t('运维监控')}</div>
|
||||
<div className="page-subtitle">
|
||||
{t('聚合请求吞吐、路由健康、站点风险、后台任务和运行日志,作为值班排障的第一屏。')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void load(true)} disabled={refreshing}>
|
||||
{refreshing ? t('刷新中...') : t('刷新')}
|
||||
</button>
|
||||
<Link className="btn btn-primary" to={buildLogsHref(undefined, logContextSearch)}>{t('日志台')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorSourceCount > 0 && (
|
||||
<div className="ops-monitor-inline-alert">
|
||||
{errorSourceCount} {t('个数据源暂时不可用,已展示可获取的数据')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="ops-monitor-kpi-grid" aria-label={t('核心指标')}>
|
||||
<article className="ops-monitor-kpi" data-tone={metrics.overallTone}>
|
||||
<span>{t('运行状态')}</span>
|
||||
<strong>{metrics.overallLabel}</strong>
|
||||
<em>{t('成功率')} {formatPercent(metrics.successRate)}</em>
|
||||
</article>
|
||||
<article className="ops-monitor-kpi" data-tone={metrics.failed24h > 0 ? 'warning' : 'success'}>
|
||||
<span>{t('24h 失败')}</span>
|
||||
<strong>{Math.round(metrics.failed24h).toLocaleString()}</strong>
|
||||
<em>{Math.round(metrics.proxy24hSuccess).toLocaleString()} / {Math.round(metrics.proxy24hTotal).toLocaleString()}</em>
|
||||
</article>
|
||||
<article className="ops-monitor-kpi" data-tone={metrics.requestsPerMinute > 0 ? 'primary' : 'muted'}>
|
||||
<span>{t('实时吞吐')}</span>
|
||||
<strong>{Math.round(metrics.requestsPerMinute).toLocaleString()} RPM</strong>
|
||||
<em>{formatCompactTokenMetric(metrics.tokensPerMinute)} TPM</em>
|
||||
</article>
|
||||
<article className="ops-monitor-kpi" data-tone={metrics.riskyRoutes.length > 0 ? 'warning' : 'success'}>
|
||||
<span>{t('路由风险')}</span>
|
||||
<strong>{metrics.riskyRoutes.length}</strong>
|
||||
<em>{metrics.routeItems.length} {t('条路由快照')}</em>
|
||||
</article>
|
||||
<article className="ops-monitor-kpi" data-tone={metrics.riskySites.length > 0 ? 'warning' : 'success'}>
|
||||
<span>{t('站点风险')}</span>
|
||||
<strong>{metrics.riskySites.length}</strong>
|
||||
<em>{metrics.activeSites.length} {t('个活跃站点')} · {t('均值')} {formatLatency(metrics.avgSiteLatency, t)}</em>
|
||||
</article>
|
||||
<article className="ops-monitor-kpi" data-tone={metrics.activeAccountRate >= 80 ? 'success' : 'warning'}>
|
||||
<span>{t('账户启用')}</span>
|
||||
<strong>{metrics.activeAccounts}/{metrics.totalAccounts}</strong>
|
||||
<em>{formatPercent(metrics.activeAccountRate)}</em>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="ops-monitor-layout">
|
||||
<div className="ops-monitor-main-stack">
|
||||
<section className="card ops-monitor-panel ops-monitor-log-center" aria-label={t('日志中心')}>
|
||||
<div className="ops-monitor-panel-head">
|
||||
<div>
|
||||
<span>{t('日志中心')}</span>
|
||||
<strong>{t('请求日志、运行日志和任务排障集中在这里')}</strong>
|
||||
</div>
|
||||
<div className="ops-monitor-panel-actions">
|
||||
<Link className="btn btn-ghost" to={buildLogsHref(undefined, logContextSearch)}>{t('使用日志')}</Link>
|
||||
<Link className="btn btn-ghost" to="/events">{t('运行日志')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ops-monitor-log-grid">
|
||||
<Link
|
||||
className="ops-monitor-log-card"
|
||||
data-tone={metrics.failed24h > 0 ? (metrics.successRate < 95 ? 'danger' : 'warning') : 'success'}
|
||||
to={buildLogsHref('failed', logContextSearch)}
|
||||
>
|
||||
<span>{t('失败请求')}</span>
|
||||
<strong>{Math.round(metrics.failed24h).toLocaleString()}</strong>
|
||||
<em>24h {t('成功率')} {formatPercent(metrics.successRate)}</em>
|
||||
</Link>
|
||||
<Link className="ops-monitor-log-card" data-tone="primary" to={buildLogsHref(undefined, logContextSearch)}>
|
||||
<span>{t('全部请求')}</span>
|
||||
<strong>{Math.round(metrics.proxy24hTotal).toLocaleString()}</strong>
|
||||
<em>{Math.round(metrics.proxy24hSuccess).toLocaleString()} {t('条成功')}</em>
|
||||
</Link>
|
||||
<Link
|
||||
className="ops-monitor-log-card"
|
||||
data-tone={metrics.errorEvents.length + metrics.warningEvents.length > 0 ? 'warning' : 'success'}
|
||||
to="/events"
|
||||
>
|
||||
<span>{t('运行日志')}</span>
|
||||
<strong>{metrics.errorEvents.length + metrics.warningEvents.length}</strong>
|
||||
<em>{t('错误')} {metrics.errorEvents.length} · {t('警告')} {metrics.warningEvents.length}</em>
|
||||
</Link>
|
||||
<Link
|
||||
className="ops-monitor-log-card"
|
||||
data-tone={metrics.activeTasks.length > 0 ? 'warning' : 'muted'}
|
||||
to="/events?type=runtime"
|
||||
>
|
||||
<span>{t('任务运行')}</span>
|
||||
<strong>{metrics.activeTasks.length}</strong>
|
||||
<em>{metrics.failedTasks.length} {t('个失败任务')}</em>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card ops-monitor-panel">
|
||||
<div className="ops-monitor-panel-head">
|
||||
<div>
|
||||
<span>{t('优先队列')}</span>
|
||||
<strong>{queue.length === 1 && queue[0].key === 'clear' ? t('当前平稳') : t('按风险收敛')}</strong>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" to="/ops">{t('值班台')}</Link>
|
||||
</div>
|
||||
<div className="ops-monitor-queue">
|
||||
{queue.map((item, index) => (
|
||||
<article key={item.key} className="ops-monitor-queue-row" data-tone={item.tone}>
|
||||
<div className="ops-monitor-queue-rank">{String(index + 1).padStart(2, '0')}</div>
|
||||
<div className="ops-monitor-queue-copy">
|
||||
<div>
|
||||
<span>{item.source}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<em>{item.metric}</em>
|
||||
</div>
|
||||
<p>{item.detail}</p>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" to={item.href}>{item.action}</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card ops-monitor-panel">
|
||||
<div className="ops-monitor-panel-head">
|
||||
<div>
|
||||
<span>{t('路由与站点')}</span>
|
||||
<strong>{t('最需要看的对象')}</strong>
|
||||
</div>
|
||||
<div className="ops-monitor-panel-actions">
|
||||
<Link className="btn btn-ghost" to="/routes">{t('路由')}</Link>
|
||||
<Link className="btn btn-ghost" to="/sites">{t('站点')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ops-monitor-split-list">
|
||||
<div>
|
||||
<div className="ops-monitor-list-title">{t('路由健康')}</div>
|
||||
{metrics.routeItems.slice(0, 5).map((item) => (
|
||||
<Link key={item.route.id} className="ops-monitor-mini-row" data-tone={item.health.tone === 'good' ? 'success' : item.health.tone} to={buildOpsRouteDiagnosticHref(item.route)}>
|
||||
<span>{item.title}</span>
|
||||
<strong>{t(item.health.label)} {item.health.score}</strong>
|
||||
</Link>
|
||||
))}
|
||||
{metrics.routeItems.length === 0 && <div className="ops-monitor-empty">{t('暂无路由快照')}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="ops-monitor-list-title">{t('站点健康')}</div>
|
||||
{metrics.riskySites.slice(0, 5).map((site: any) => (
|
||||
<Link key={site.siteId} className="ops-monitor-mini-row" data-tone="warning" to={buildSiteLogsHref(site.siteId, undefined, logContextSearch)}>
|
||||
<span>{site.siteName || `${t('站点')} ${site.siteId}`}</span>
|
||||
<strong>{typeof site.availabilityPercent === 'number' ? formatPercent(site.availabilityPercent) : t('暂无可用率')}</strong>
|
||||
</Link>
|
||||
))}
|
||||
{metrics.riskySites.length === 0 && <div className="ops-monitor-empty">{t('站点暂无风险项')}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="ops-monitor-side-stack">
|
||||
<section className="card ops-monitor-panel">
|
||||
<div className="ops-monitor-panel-head">
|
||||
<div>
|
||||
<span>{t('后台任务')}</span>
|
||||
<strong>{metrics.activeTasks.length} {t('个进行中')}</strong>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" to="/events?type=runtime">{t('日志')}</Link>
|
||||
</div>
|
||||
<div className="ops-monitor-task-list">
|
||||
{state.tasks.slice(0, 6).map((task) => (
|
||||
<div key={task.id} className="ops-monitor-task-row" data-tone={statusTone(task.status)}>
|
||||
<div>
|
||||
<strong>{task.title || task.taskType || task.type || task.id}</strong>
|
||||
<span>{task.message || task.error || formatDateTime(task.updatedAt || task.updated_at || task.createdAt || task.created_at)}</span>
|
||||
</div>
|
||||
<em>{statusLabel(task.status, t)}</em>
|
||||
</div>
|
||||
))}
|
||||
{state.tasks.length === 0 && <div className="ops-monitor-empty">{t('暂无后台任务')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card ops-monitor-panel">
|
||||
<div className="ops-monitor-panel-head">
|
||||
<div>
|
||||
<span>{t('运行日志')}</span>
|
||||
<strong>{metrics.errorEvents.length + metrics.warningEvents.length} {t('条需关注')}</strong>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" to="/events">{t('全部')}</Link>
|
||||
</div>
|
||||
<div className="ops-monitor-event-list">
|
||||
{state.events.slice(0, 6).map((event) => (
|
||||
<Link key={`${event.type}-${event.id}-${event.createdAt || event.created_at}`} className="ops-monitor-event-row" data-tone={eventTone(event.level)} to="/events">
|
||||
<span>{event.type || 'runtime'}</span>
|
||||
<strong>{event.title ? t(String(event.title)) : t('运行日志')}</strong>
|
||||
<em>{event.message || formatDateTime(event.createdAt || event.created_at)}</em>
|
||||
</Link>
|
||||
))}
|
||||
{state.events.length === 0 && <div className="ops-monitor-empty">{t('暂无运行日志')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
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 PictureAgent from './PictureAgent.js';
|
||||
|
||||
const { apiMock, authSessionMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
fetchSessionResponse: vi.fn(),
|
||||
},
|
||||
authSessionMock: {
|
||||
clearSession: vi.fn(),
|
||||
getSessionToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
fetchSessionResponse: apiMock.fetchSessionResponse,
|
||||
}));
|
||||
|
||||
vi.mock('../authSession.js', () => ({
|
||||
clearSession: authSessionMock.clearSession,
|
||||
getSessionToken: authSessionMock.getSessionToken,
|
||||
}));
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => (
|
||||
typeof child === 'string' ? child : 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.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes(label)
|
||||
));
|
||||
}
|
||||
|
||||
function findTextarea(root: ReactTestRenderer) {
|
||||
return root.root.find((node) => node.type === 'textarea');
|
||||
}
|
||||
|
||||
function linkTarget(node: ReactTestInstance): string {
|
||||
return String(node.props.href || node.props.to || '');
|
||||
}
|
||||
|
||||
describe('PictureAgent job progress dialog', () => {
|
||||
let documentListeners!: Map<string, EventListener[]>;
|
||||
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn>; isConnected: boolean };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
documentListeners = new Map();
|
||||
|
||||
class MockHTMLElement {
|
||||
isConnected = true;
|
||||
focus = vi.fn();
|
||||
}
|
||||
|
||||
activeElementMock = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn>; isConnected: boolean };
|
||||
vi.stubGlobal('HTMLElement', MockHTMLElement);
|
||||
vi.stubGlobal('Node', MockHTMLElement);
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
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,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
},
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
});
|
||||
|
||||
authSessionMock.getSessionToken.mockReturnValue('user-token');
|
||||
apiMock.fetchSessionResponse.mockImplementation(async (_audience: string, url: string, options?: RequestInit) => {
|
||||
if (url === '/api/picture-agent/status') {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
mode: 'local',
|
||||
upstreamLabel: '测试图片路由',
|
||||
keyName: 'test-key',
|
||||
});
|
||||
}
|
||||
if (url === '/api/picture-agent/models') {
|
||||
return jsonResponse({
|
||||
models: ['gpt-image-2'],
|
||||
defaultModel: 'gpt-image-2',
|
||||
});
|
||||
}
|
||||
if (url === '/api/picture-agent/jobs' && options?.method === 'POST') {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
job: {
|
||||
id: 'pic-new-submit',
|
||||
status: 'running',
|
||||
model: 'gpt-image-2',
|
||||
message: '任务已提交,等待路由',
|
||||
progress: 1,
|
||||
progressTotal: 4,
|
||||
result: {
|
||||
keyId: 42,
|
||||
keyName: 'test-key',
|
||||
prompt: '自动打开弹窗的测试提示词',
|
||||
model: 'gpt-image-2',
|
||||
mode: 'generate',
|
||||
},
|
||||
createdAt: '2026-06-08T02:04:00Z',
|
||||
updatedAt: '2026-06-08T02:04:00Z',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.startsWith('/api/picture-agent/jobs')) {
|
||||
return jsonResponse({
|
||||
jobs: [
|
||||
{
|
||||
id: 'pic-running-1',
|
||||
status: 'running',
|
||||
model: 'gpt-image-2',
|
||||
message: '霓虹玻璃机器人',
|
||||
progress: 2,
|
||||
progressTotal: 4,
|
||||
createdAt: '2026-06-08T02:00:00Z',
|
||||
updatedAt: '2026-06-08T02:01:00Z',
|
||||
},
|
||||
{
|
||||
id: 'pic-done-1',
|
||||
status: 'succeeded',
|
||||
model: 'gpt-image-2',
|
||||
progress: 4,
|
||||
progressTotal: 4,
|
||||
result: {
|
||||
keyId: 42,
|
||||
keyName: 'test-key',
|
||||
prompt: '完成的插画',
|
||||
model: 'gpt-image-2',
|
||||
mode: 'generate',
|
||||
images: [{ url: 'https://example.com/result.png' }],
|
||||
},
|
||||
createdAt: '2026-06-08T01:56:00Z',
|
||||
updatedAt: '2026-06-08T01:59:00Z',
|
||||
finishedAt: '2026-06-08T01:59:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.startsWith('/api/picture-agent/templates')) {
|
||||
return jsonResponse({ templates: [] });
|
||||
}
|
||||
return jsonResponse({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens a readable progress dialog from queued image jobs and closes it without leaving scroll lock', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/picture-agent']}>
|
||||
<PictureAgent />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.fetchSessionResponse).toHaveBeenCalledWith('user', '/api/picture-agent/jobs?limit=20', { timeoutMs: 8_000 });
|
||||
expect(collectText(root.root)).toContain('正在处理最新任务 · 1 个进行中 · 2 条记录');
|
||||
|
||||
await act(async () => {
|
||||
findButton(root, '查看进度').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
const dialogText = collectText(dialog);
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(dialog.props['aria-labelledby']).toBe('picture-agent-job-dialog-title');
|
||||
expect(dialogText).toContain('生图进度');
|
||||
expect(dialogText).toContain('进行中1');
|
||||
expect(dialogText).toContain('已完成1');
|
||||
expect(dialogText).toContain('失败/取消0');
|
||||
expect(dialogText).toContain('霓虹玻璃机器人');
|
||||
expect(dialogText).toContain('生成中50%');
|
||||
expect(dialogText).toContain('队列 #1');
|
||||
expect(dialogText).toContain('完成的插画');
|
||||
expect(dialogText).toContain('使用记录');
|
||||
expect(dialogText).toContain('模型测试器');
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
const traceLinks = dialog.findAll((node) => node.type === 'a');
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=%E5%AE%8C%E6%88%90%E7%9A%84%E6%8F%92%E7%94%BB&key_id=42&key=test-key&status=success')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=%E5%AE%8C%E6%88%90%E7%9A%84%E6%8F%92%E7%94%BB&key_id=42&key=test-key&status=failed')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=gpt-image-2')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link).startsWith('/logs'))).toBe(false);
|
||||
|
||||
const focusProgress = dialog.find((node) => (
|
||||
node.props.role === 'progressbar'
|
||||
&& node.props['aria-label'] === '当前焦点任务进度'
|
||||
));
|
||||
expect(focusProgress.props['aria-valuenow']).toBe(50);
|
||||
|
||||
await act(async () => {
|
||||
findButton(root, '关闭').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the progress dialog automatically after a generation job is submitted', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/picture-agent']}>
|
||||
<PictureAgent />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findTextarea(root).props.onChange({ target: { value: '自动打开弹窗的测试提示词' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(root, '生成图片').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const postCall = apiMock.fetchSessionResponse.mock.calls.find(([, url, options]) => (
|
||||
url === '/api/picture-agent/jobs'
|
||||
&& options?.method === 'POST'
|
||||
));
|
||||
expect(postCall).toBeTruthy();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
const dialogText = collectText(dialog);
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(dialogText).toContain('生图进度');
|
||||
expect(dialogText).toContain('自动打开弹窗的测试提示词');
|
||||
expect(dialogText).toContain('25%');
|
||||
expect(dialogText).toContain('取消任务');
|
||||
expect(dialogText).toContain('使用记录');
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('shows internal recharge guidance when a user picture task hits quota', async () => {
|
||||
apiMock.fetchSessionResponse.mockImplementation(async (_audience: string, url: string, options?: RequestInit) => {
|
||||
if (url === '/api/picture-agent/status') {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
mode: 'local',
|
||||
upstreamLabel: '测试图片路由',
|
||||
keyName: 'test-key',
|
||||
});
|
||||
}
|
||||
if (url === '/api/picture-agent/models') {
|
||||
return jsonResponse({
|
||||
models: ['gpt-image-2'],
|
||||
defaultModel: 'gpt-image-2',
|
||||
});
|
||||
}
|
||||
if (url === '/api/picture-agent/jobs' && options?.method === 'POST') {
|
||||
return jsonResponse({
|
||||
error: { message: '下游密钥成本额度已用完' },
|
||||
billing: {
|
||||
rechargePath: '/user/recharge',
|
||||
usagePath: '/user/usage',
|
||||
},
|
||||
}, 429);
|
||||
}
|
||||
if (url.startsWith('/api/picture-agent/jobs')) {
|
||||
return jsonResponse({ jobs: [] });
|
||||
}
|
||||
return jsonResponse({ success: true });
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/picture-agent']}>
|
||||
<PictureAgent />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findTextarea(root).props.onChange({ target: { value: '额度不足提示词' } });
|
||||
});
|
||||
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('links admin picture jobs back to internal logs and the model tester', async () => {
|
||||
authSessionMock.getSessionToken.mockImplementation((audience: string) => (
|
||||
audience === 'admin' ? 'admin-token' : null
|
||||
));
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/picture-agent']}>
|
||||
<PictureAgent />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButton(root, '查看进度').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
const traceLinks = dialog.findAll((node) => node.type === 'a');
|
||||
expect(apiMock.fetchSessionResponse).toHaveBeenCalledWith('admin', '/api/picture-agent/jobs?limit=20', { timeoutMs: 8_000 });
|
||||
expect(collectText(dialog)).toContain('使用日志');
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=gpt-image-2&status=success&downstreamKeyId=42')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=gpt-image-2&status=failed&downstreamKeyId=42')).toBe(true);
|
||||
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=gpt-image-2')).toBe(true);
|
||||
} finally {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+466
-125
@@ -1,7 +1,14 @@
|
||||
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { fetchSessionResponse } from '../api.js';
|
||||
import { clearSession, getSessionToken, type SessionAudience } from '../authSession.js';
|
||||
import { trapFocusInContainer } from '../components/focusTrap.js';
|
||||
import {
|
||||
buildCreationFailureHref,
|
||||
buildCreationModelTesterHref,
|
||||
buildCreationUsageHref,
|
||||
} from './helpers/creationTraceLinks.js';
|
||||
import { BillingRechargeAlert, isQuotaRechargeError } from './helpers/billingRecharge.js';
|
||||
|
||||
type AgentImage = {
|
||||
id: string;
|
||||
@@ -24,12 +31,16 @@ type PictureJob = {
|
||||
progress?: number;
|
||||
progressTotal?: number;
|
||||
result?: {
|
||||
keyId?: number | string | null;
|
||||
keyName?: string | null;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
mode?: PictureMode | string;
|
||||
response?: any;
|
||||
images?: any[];
|
||||
};
|
||||
keyId?: number | string | null;
|
||||
keyName?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
finishedAt?: string | null;
|
||||
@@ -142,6 +153,26 @@ async function parseError(response: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
async function parseStructuredError(response: Response) {
|
||||
const text = await response.text().catch(() => '');
|
||||
let message = `HTTP ${response.status}`;
|
||||
let payload: any = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
message = payload?.error?.message || payload?.error || payload?.message || text.slice(0, 180);
|
||||
} catch {
|
||||
message = text.slice(0, 180);
|
||||
}
|
||||
}
|
||||
const error = new Error(message) as any;
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
error.billing = payload?.billing || payload?.error?.billing || null;
|
||||
error.retryAfterSecs = payload?.retry_after_secs ?? null;
|
||||
return error;
|
||||
}
|
||||
|
||||
function getFilename(image: AgentImage) {
|
||||
const safeModel = image.model.replace(/[^a-z0-9_-]+/gi, '-').slice(0, 32) || 'image';
|
||||
return `shengtu-${safeModel}-${new Date(image.createdAt).toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`;
|
||||
@@ -169,6 +200,18 @@ function formatLocalTime(value: number | string | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDurationLabel(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (totalSeconds < 10) return '刚刚';
|
||||
if (totalSeconds < 60) return `${totalSeconds}秒`;
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (totalMinutes < 60) return seconds >= 10 ? `${totalMinutes}分${seconds}秒` : `${totalMinutes}分`;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return minutes ? `${hours}小时${minutes}分` : `${hours}小时`;
|
||||
}
|
||||
|
||||
function clampDimension(value: number) {
|
||||
if (!Number.isFinite(value)) return 1024;
|
||||
return Math.min(4096, Math.max(256, Math.round(value)));
|
||||
@@ -205,6 +248,90 @@ function jobProgressPercent(job: PictureJob) {
|
||||
return Math.round((progress / total) * 100);
|
||||
}
|
||||
|
||||
function jobStatusLabel(status: PictureJob['status']): string {
|
||||
if (status === 'pending') return '排队中';
|
||||
if (status === 'running') return '生成中';
|
||||
if (status === 'succeeded') return '已完成';
|
||||
if (status === 'failed') return '失败';
|
||||
if (status === 'cancelled') return '已取消';
|
||||
return '未知状态';
|
||||
}
|
||||
|
||||
function jobStageLabel(job: PictureJob): string {
|
||||
if (job.status === 'pending') return '等待调度';
|
||||
if (job.status === 'running') {
|
||||
const total = Math.max(1, Number(job.progressTotal || 4));
|
||||
const progress = Math.max(0, Number(job.progress || 0));
|
||||
if (progress <= 1) return '提交到模型';
|
||||
if (progress < total) return '模型生成中';
|
||||
return '结果回收中';
|
||||
}
|
||||
if (job.status === 'succeeded') return '结果可用';
|
||||
if (job.status === 'failed') return '需要处理';
|
||||
if (job.status === 'cancelled') return '已停止';
|
||||
return '状态同步';
|
||||
}
|
||||
|
||||
function jobTone(job: PictureJob): 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled' {
|
||||
if (job.status === 'running') return 'running';
|
||||
if (job.status === 'succeeded') return 'succeeded';
|
||||
if (job.status === 'failed') return 'failed';
|
||||
if (job.status === 'cancelled') return 'cancelled';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function isActiveJob(job: PictureJob) {
|
||||
return job.status === 'pending' || job.status === 'running';
|
||||
}
|
||||
|
||||
function isTerminalJob(job: PictureJob) {
|
||||
return job.status === 'succeeded' || job.status === 'failed' || job.status === 'cancelled';
|
||||
}
|
||||
|
||||
function jobResultText(job: PictureJob): string {
|
||||
if (job.status === 'failed') return job.error || '上游返回失败,请查看错误信息';
|
||||
if (job.status === 'cancelled') return '任务已取消,不会继续消耗队列资源';
|
||||
if (job.status === 'succeeded') return job.result?.images?.length ? `生成 ${job.result.images.length} 张图片` : '任务完成,结果已同步';
|
||||
return job.message || '等待图片模型返回结果';
|
||||
}
|
||||
|
||||
function jobRuntimeLabel(job: PictureJob): string {
|
||||
const startedAt = parseServerTime(job.createdAt);
|
||||
if (!Number.isFinite(startedAt)) return '耗时未知';
|
||||
const terminalAt = parseServerTime(job.finishedAt || job.updatedAt);
|
||||
const endedAt = isTerminalJob(job) && Number.isFinite(terminalAt) ? terminalAt : Date.now();
|
||||
return `${isTerminalJob(job) ? '耗时' : '已耗时'} ${formatDurationLabel(endedAt - startedAt)}`;
|
||||
}
|
||||
|
||||
function jobSyncLabel(job: PictureJob): string {
|
||||
const syncedAt = parseServerTime(job.updatedAt || job.createdAt);
|
||||
return Number.isFinite(syncedAt) ? `同步 ${formatLocalTime(syncedAt)}` : '同步时间未知';
|
||||
}
|
||||
|
||||
function jobPositionLabel(job: PictureJob, activeIndex: number): string {
|
||||
if (isActiveJob(job) && activeIndex >= 0) return `队列 #${activeIndex + 1}`;
|
||||
if (job.status === 'succeeded') return job.result?.images?.length ? `产物 ${job.result.images.length} 张` : '已归档';
|
||||
if (job.status === 'failed') return '失败已记录';
|
||||
if (job.status === 'cancelled') return '已停止';
|
||||
return jobStatusLabel(job.status);
|
||||
}
|
||||
|
||||
function jobTraceModel(job: PictureJob) {
|
||||
return job.result?.model || job.model || '';
|
||||
}
|
||||
|
||||
function jobTracePrompt(job: PictureJob) {
|
||||
return job.result?.prompt || job.message || job.id;
|
||||
}
|
||||
|
||||
function jobKeyId(job: PictureJob) {
|
||||
return job.result?.keyId || job.keyId || null;
|
||||
}
|
||||
|
||||
function jobKeyName(job: PictureJob) {
|
||||
return job.result?.keyName || job.keyName || '';
|
||||
}
|
||||
|
||||
function templateTitle(prompt: string, postNumber?: number) {
|
||||
const clean = prompt.split(/\s+/).join(' ').trim();
|
||||
let title = Array.from(clean).slice(0, 18).join('');
|
||||
@@ -291,6 +418,8 @@ export default function PictureAgent() {
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const token = getSessionToken(audience, localStorage);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const jobDialogRef = useRef<HTMLElement | null>(null);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const [status, setStatus] = useState<PictureAgentStatus | null>(null);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [model, setModel] = useState('');
|
||||
@@ -314,14 +443,21 @@ export default function PictureAgent() {
|
||||
const [jobDialogOpen, setJobDialogOpen] = useState(false);
|
||||
const [bootError, setBootError] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [billingError, setBillingError] = useState<unknown>(null);
|
||||
|
||||
const selectedImage = images.find((image) => image.id === selectedId) ?? images[0] ?? null;
|
||||
const activeJobs = jobs.filter((job) => job.status === 'pending' || job.status === 'running');
|
||||
const activeJobs = jobs.filter(isActiveJob);
|
||||
const primaryActiveJob = activeJobs[0] ?? null;
|
||||
const modeLabel = mode === 'edit' ? '图片编辑' : '文生图';
|
||||
const resultLabel = selectedImage?.mode === 'edit' ? '编辑结果' : '生成结果';
|
||||
const activeModeTabId = `picture-agent-mode-tab-${mode}`;
|
||||
const activeModePanelId = `picture-agent-mode-panel-${mode}`;
|
||||
const resolvedSize = buildSize(aspect, customWidth, customHeight);
|
||||
const latestJob = jobs[0] ?? null;
|
||||
const focusJob = primaryActiveJob ?? latestJob;
|
||||
const focusJobPercent = focusJob ? jobProgressPercent(focusJob) : 0;
|
||||
const focusJobTone = focusJob ? jobTone(focusJob) : 'pending';
|
||||
const focusJobQueueIndex = focusJob ? activeJobs.findIndex((job) => job.id === focusJob.id) : -1;
|
||||
const latestJobLabel = latestJob?.status === 'running'
|
||||
? '正在处理最新任务'
|
||||
: latestJob?.status === 'pending'
|
||||
@@ -333,8 +469,71 @@ export default function PictureAgent() {
|
||||
: latestJob?.status === 'succeeded'
|
||||
? '最新任务已完成'
|
||||
: '暂无最新任务';
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobDialogOpen || typeof document === 'undefined' || !document.body?.style) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [jobDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (jobDialogOpen) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
const focusDialog = () => {
|
||||
const dialog = jobDialogRef.current;
|
||||
if (!dialog) return;
|
||||
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && dialog.contains(currentActiveElement)) return;
|
||||
dialog.focus({ preventScroll: true });
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const animationFrame = window.requestAnimationFrame(focusDialog);
|
||||
return () => {
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}
|
||||
focusDialog();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
return undefined;
|
||||
}, [jobDialogOpen]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobDialogOpen || typeof document === 'undefined') return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setJobDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, jobDialogRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [jobDialogOpen]);
|
||||
const terminalJobs = useMemo(
|
||||
() => jobs.filter((job) => job.status === 'succeeded' || job.status === 'failed' || job.status === 'cancelled'),
|
||||
() => jobs.filter(isTerminalJob),
|
||||
[jobs],
|
||||
);
|
||||
const submitDisabledHint = useMemo(() => {
|
||||
@@ -522,6 +721,7 @@ export default function PictureAgent() {
|
||||
if (!canSubmit) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setBillingError(null);
|
||||
try {
|
||||
let response: Response;
|
||||
if (mode === 'edit') {
|
||||
@@ -549,10 +749,11 @@ export default function PictureAgent() {
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
if (!response.ok) throw await parseStructuredError(response);
|
||||
const payload = await response.json();
|
||||
if (mode === 'generate' && payload?.job) {
|
||||
setJobs((current) => [payload.job, ...current.filter((job) => job.id !== payload.job.id)].slice(0, 20));
|
||||
setJobDialogOpen(true);
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
@@ -571,6 +772,7 @@ export default function PictureAgent() {
|
||||
setImages((current) => [...nextImages, ...current].slice(0, 48));
|
||||
setSelectedId(nextImages[0]?.id || '');
|
||||
} catch (err: any) {
|
||||
setBillingError(isQuotaRechargeError(err) ? err : null);
|
||||
setError(err?.message || '图片生成失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -665,36 +867,97 @@ export default function PictureAgent() {
|
||||
const hint = staleRunningHint(job);
|
||||
const isActive = job.status === 'pending' || job.status === 'running';
|
||||
const isFinished = !isActive;
|
||||
const statusText = job.status === 'succeeded'
|
||||
? '完成'
|
||||
: job.status === 'failed'
|
||||
? '失败'
|
||||
: job.status === 'cancelled'
|
||||
? '已取消'
|
||||
: `${percent}%`;
|
||||
const tone = jobTone(job);
|
||||
const statusText = isActive ? `${percent}%` : jobStatusLabel(job.status);
|
||||
const activeIndex = activeJobs.findIndex((item) => item.id === job.id);
|
||||
const stepCount = Math.min(4, Math.max(1, Math.round(Number(job.progressTotal || 4))));
|
||||
const activeStepCount = job.status === 'succeeded'
|
||||
? stepCount
|
||||
: Math.min(stepCount, Math.max(1, Math.ceil((percent / 100) * stepCount)));
|
||||
const traceModel = jobTraceModel(job);
|
||||
const tracePrompt = jobTracePrompt(job);
|
||||
const keyId = jobKeyId(job);
|
||||
const keyName = jobKeyName(job);
|
||||
return (
|
||||
<div key={job.id} className={`picture-agent-job picture-agent-job-${job.status}`}>
|
||||
<div key={job.id} className={`picture-agent-job picture-agent-job-${tone}`} data-tone={tone}>
|
||||
<div className="picture-agent-job-rail" aria-hidden="true">
|
||||
<span />
|
||||
<em />
|
||||
</div>
|
||||
<div className="picture-agent-job-row picture-agent-job-row-top">
|
||||
<div className="picture-agent-job-title-wrap">
|
||||
<strong>{job.result?.prompt || job.message || '生图任务'}</strong>
|
||||
<span>{job.model || job.result?.model || '未知模型'}</span>
|
||||
</div>
|
||||
<span className={`picture-agent-job-status picture-agent-job-status-${job.status}`}>{statusText}</span>
|
||||
<span className={`picture-agent-job-status picture-agent-job-status-${tone}`}>{statusText}</span>
|
||||
</div>
|
||||
<div className="picture-agent-job-stage">
|
||||
<span>{jobStageLabel(job)}</span>
|
||||
<em>{jobStatusLabel(job.status)}</em>
|
||||
</div>
|
||||
<div className="picture-agent-job-ops" aria-label="任务运行信息">
|
||||
<span>{jobPositionLabel(job, activeIndex)}</span>
|
||||
<span>{jobRuntimeLabel(job)}</span>
|
||||
<span>{jobSyncLabel(job)}</span>
|
||||
</div>
|
||||
<div className="picture-agent-job-progress-line">
|
||||
<div className="picture-agent-job-progress-labels">
|
||||
<span>{job.message || job.id}</span>
|
||||
<span>{jobResultText(job)}</span>
|
||||
<em>{percent}%</em>
|
||||
</div>
|
||||
<div className="picture-agent-job-progress">
|
||||
<div
|
||||
className="picture-agent-job-progress"
|
||||
role="progressbar"
|
||||
aria-label={`${jobStatusLabel(job.status)}进度`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className="picture-agent-job-step-dots" aria-hidden="true">
|
||||
{Array.from({ length: stepCount }).map((_, index) => (
|
||||
<span key={index} className={index < activeStepCount ? 'is-active' : ''} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="picture-agent-job-meta">
|
||||
<span>{formatLocalTime(job.createdAt)}</span>
|
||||
{job.error ? <em>{job.error}</em> : <span>{isFinished ? '已结束' : '进行中'}</span>}
|
||||
</div>
|
||||
{hint ? <div className="picture-agent-job-hint">{hint}</div> : null}
|
||||
<div className="creation-trace-links" aria-label="任务追踪">
|
||||
<Link
|
||||
to={buildCreationUsageHref({
|
||||
audience,
|
||||
query: audience === 'user' ? tracePrompt : undefined,
|
||||
model: traceModel,
|
||||
prompt: tracePrompt,
|
||||
taskId: job.id,
|
||||
keyId,
|
||||
keyName,
|
||||
status: job.status,
|
||||
})}
|
||||
>
|
||||
{audience === 'user' ? '使用记录' : '使用日志'}
|
||||
</Link>
|
||||
<Link
|
||||
to={buildCreationFailureHref({
|
||||
audience,
|
||||
query: audience === 'user' ? tracePrompt : undefined,
|
||||
model: traceModel,
|
||||
prompt: tracePrompt,
|
||||
taskId: job.id,
|
||||
keyId,
|
||||
keyName,
|
||||
})}
|
||||
>
|
||||
失败记录
|
||||
</Link>
|
||||
<Link to={buildCreationModelTesterHref(traceModel)}>
|
||||
模型测试器
|
||||
</Link>
|
||||
</div>
|
||||
{isActive ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -747,124 +1010,150 @@ export default function PictureAgent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-tabs" aria-label="picture mode">
|
||||
<button className={mode === 'generate' ? 'active' : ''} onClick={() => setMode('generate')} type="button">文生图</button>
|
||||
<button className={mode === 'edit' ? 'active' : ''} onClick={() => setMode('edit')} type="button">图像编辑</button>
|
||||
<div className="picture-agent-tabs" role="tablist" aria-label="生图模式">
|
||||
<button
|
||||
id="picture-agent-mode-tab-generate"
|
||||
className={mode === 'generate' ? 'active' : ''}
|
||||
onClick={() => setMode('generate')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'generate'}
|
||||
aria-controls="picture-agent-mode-panel-generate"
|
||||
>
|
||||
文生图
|
||||
</button>
|
||||
<button
|
||||
id="picture-agent-mode-tab-edit"
|
||||
className={mode === 'edit' ? 'active' : ''}
|
||||
onClick={() => setMode('edit')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'edit'}
|
||||
aria-controls="picture-agent-mode-panel-edit"
|
||||
>
|
||||
图像编辑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="picture-agent-field">
|
||||
<span>提示词</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
rows={8}
|
||||
placeholder="描述主体、镜头、风格、材质、光线和构图"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="picture-agent-presets">
|
||||
{STYLE_PRESETS.map((preset) => (
|
||||
<button key={preset.label} type="button" onClick={() => applyPreset(preset.prompt)}>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-grid-fields">
|
||||
<div id={activeModePanelId} role="tabpanel" aria-labelledby={activeModeTabId}>
|
||||
<label className="picture-agent-field">
|
||||
<span>比例</span>
|
||||
<select
|
||||
value={aspect}
|
||||
onChange={(event) => {
|
||||
const nextAspect = event.target.value;
|
||||
setAspect(nextAspect);
|
||||
const option = ASPECT_OPTIONS.find((item) => item.value === nextAspect);
|
||||
if (option?.width && option?.height) {
|
||||
setCustomWidth(option.width);
|
||||
setCustomHeight(option.height);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ASPECT_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="picture-agent-field">
|
||||
<span>质量</span>
|
||||
<select value={quality} onChange={(event) => setQuality(event.target.value)}>
|
||||
{QUALITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-size-row">
|
||||
<label className="picture-agent-field">
|
||||
<span>宽度</span>
|
||||
<input
|
||||
type="number"
|
||||
min={256}
|
||||
max={4096}
|
||||
step={64}
|
||||
value={customWidth}
|
||||
disabled={aspect === 'auto'}
|
||||
onChange={(event) => {
|
||||
setAspect('custom');
|
||||
setCustomWidth(clampDimension(Number(event.target.value)));
|
||||
}}
|
||||
<span>提示词</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
rows={8}
|
||||
placeholder="描述主体、镜头、风格、材质、光线和构图"
|
||||
/>
|
||||
</label>
|
||||
<label className="picture-agent-field">
|
||||
<span>高度</span>
|
||||
<input
|
||||
type="number"
|
||||
min={256}
|
||||
max={4096}
|
||||
step={64}
|
||||
value={customHeight}
|
||||
disabled={aspect === 'auto'}
|
||||
onChange={(event) => {
|
||||
setAspect('custom');
|
||||
setCustomHeight(clampDimension(Number(event.target.value)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="picture-agent-size-preview">
|
||||
<span>提交尺寸</span>
|
||||
<strong>{resolvedSize}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'edit' && (
|
||||
<div className="picture-agent-upload">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => setSourceFile(event.target.files?.[0] || null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className="picture-agent-upload-bar">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => fileInputRef.current?.click()}>
|
||||
选择图片
|
||||
<div className="picture-agent-presets">
|
||||
{STYLE_PRESETS.map((preset) => (
|
||||
<button key={preset.label} type="button" onClick={() => applyPreset(preset.prompt)}>
|
||||
{preset.label}
|
||||
</button>
|
||||
{sourceFile && <span>{sourceFile.name}</span>}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-grid-fields">
|
||||
<label className="picture-agent-field">
|
||||
<span>比例</span>
|
||||
<select
|
||||
value={aspect}
|
||||
onChange={(event) => {
|
||||
const nextAspect = event.target.value;
|
||||
setAspect(nextAspect);
|
||||
const option = ASPECT_OPTIONS.find((item) => item.value === nextAspect);
|
||||
if (option?.width && option?.height) {
|
||||
setCustomWidth(option.width);
|
||||
setCustomHeight(option.height);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ASPECT_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="picture-agent-field">
|
||||
<span>质量</span>
|
||||
<select value={quality} onChange={(event) => setQuality(event.target.value)}>
|
||||
{QUALITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-size-row">
|
||||
<label className="picture-agent-field">
|
||||
<span>宽度</span>
|
||||
<input
|
||||
type="number"
|
||||
min={256}
|
||||
max={4096}
|
||||
step={64}
|
||||
value={customWidth}
|
||||
disabled={aspect === 'auto'}
|
||||
onChange={(event) => {
|
||||
setAspect('custom');
|
||||
setCustomWidth(clampDimension(Number(event.target.value)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="picture-agent-field">
|
||||
<span>高度</span>
|
||||
<input
|
||||
type="number"
|
||||
min={256}
|
||||
max={4096}
|
||||
step={64}
|
||||
value={customHeight}
|
||||
disabled={aspect === 'auto'}
|
||||
onChange={(event) => {
|
||||
setAspect('custom');
|
||||
setCustomHeight(clampDimension(Number(event.target.value)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="picture-agent-size-preview">
|
||||
<span>提交尺寸</span>
|
||||
<strong>{resolvedSize}</strong>
|
||||
</div>
|
||||
{sourcePreview ? (
|
||||
<img src={sourcePreview} alt="source" />
|
||||
) : (
|
||||
<button type="button" className="picture-agent-upload-empty" onClick={() => fileInputRef.current?.click()}>
|
||||
上传一张参考图
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{loading ? '提交中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
|
||||
</button>
|
||||
{!canSubmit && submitDisabledHint ? (
|
||||
<div className="picture-agent-submit-hint">{submitDisabledHint}</div>
|
||||
) : null}
|
||||
{mode === 'edit' && (
|
||||
<div className="picture-agent-upload">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => setSourceFile(event.target.files?.[0] || null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className="picture-agent-upload-bar">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => fileInputRef.current?.click()}>
|
||||
选择图片
|
||||
</button>
|
||||
{sourceFile && <span>{sourceFile.name}</span>}
|
||||
</div>
|
||||
{sourcePreview ? (
|
||||
<img src={sourcePreview} alt="source" />
|
||||
) : (
|
||||
<button type="button" className="picture-agent-upload-empty" onClick={() => fileInputRef.current?.click()}>
|
||||
上传一张参考图
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{billingError ? (
|
||||
<BillingRechargeAlert error={billingError} />
|
||||
) : error ? (
|
||||
<div className="alert alert-error">{error}</div>
|
||||
) : null}
|
||||
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{loading ? '提交中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
|
||||
</button>
|
||||
{!canSubmit && submitDisabledHint ? (
|
||||
<div className="picture-agent-submit-hint">{submitDisabledHint}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-job-summary">
|
||||
<div>
|
||||
@@ -1000,13 +1289,16 @@ export default function PictureAgent() {
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={jobDialogRef}
|
||||
className="picture-agent-job-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="picture-agent-job-dialog-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="picture-agent-job-dialog-head">
|
||||
<div>
|
||||
<div className="picture-agent-job-dialog-titleblock">
|
||||
<span className="picture-agent-job-dialog-kicker">任务队列</span>
|
||||
<strong id="picture-agent-job-dialog-title">生图进度</strong>
|
||||
<span>{latestJobLabel} · {jobs.length} 条记录</span>
|
||||
</div>
|
||||
@@ -1028,11 +1320,60 @@ export default function PictureAgent() {
|
||||
>
|
||||
{clearingJobs ? '清理中...' : '清空日志'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(false)}>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(false)} aria-label="关闭生图进度弹窗">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="picture-agent-job-dialog-overview" role="group" aria-label="生图任务概览">
|
||||
<div className="picture-agent-job-overview-card is-running">
|
||||
<span>进行中</span>
|
||||
<strong>{activeJobs.length}</strong>
|
||||
</div>
|
||||
<div className="picture-agent-job-overview-card is-succeeded">
|
||||
<span>已完成</span>
|
||||
<strong>{jobs.filter((job) => job.status === 'succeeded').length}</strong>
|
||||
</div>
|
||||
<div className="picture-agent-job-overview-card is-terminal">
|
||||
<span>失败/取消</span>
|
||||
<strong>{jobs.filter((job) => job.status === 'failed' || job.status === 'cancelled').length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{focusJob ? (
|
||||
<div className="picture-agent-job-focus" data-tone={focusJobTone} aria-live="polite">
|
||||
<div className="picture-agent-job-focus-main">
|
||||
<span className="picture-agent-job-focus-kicker">当前焦点</span>
|
||||
<strong>{focusJob.result?.prompt || focusJob.message || '生图任务'}</strong>
|
||||
<div className="picture-agent-job-focus-meta">
|
||||
<span>{focusJob.model || focusJob.result?.model || '未知模型'}</span>
|
||||
<span>{jobStageLabel(focusJob)}</span>
|
||||
<span>{formatLocalTime(focusJob.updatedAt || focusJob.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="picture-agent-job-focus-meter">
|
||||
<span>{jobStatusLabel(focusJob.status)}</span>
|
||||
<strong>{focusJobPercent}%</strong>
|
||||
</div>
|
||||
<div className="picture-agent-job-focus-ops" aria-label="当前焦点任务运行信息">
|
||||
<span>{jobPositionLabel(focusJob, focusJobQueueIndex)}</span>
|
||||
<span>{jobRuntimeLabel(focusJob)}</span>
|
||||
<span>{jobSyncLabel(focusJob)}</span>
|
||||
</div>
|
||||
<div
|
||||
className="picture-agent-job-focus-progress"
|
||||
role="progressbar"
|
||||
aria-label="当前焦点任务进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={focusJobPercent}
|
||||
>
|
||||
<span style={{ width: `${focusJobPercent}%` }} />
|
||||
</div>
|
||||
<div className="picture-agent-job-focus-note">
|
||||
{staleRunningHint(focusJob) || jobResultText(focusJob)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{jobs.length === 0 ? (
|
||||
<div className="picture-agent-job-empty">提交后会先进入队列,完成后会自动出现在这里和使用记录里。</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import ProgramLogs from './ProgramLogs.js';
|
||||
|
||||
@@ -25,6 +26,19 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
|
||||
}
|
||||
|
||||
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes(text)
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -32,6 +46,16 @@ async function flushMicrotasks() {
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||
resolve = innerResolve;
|
||||
reject = innerReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('ProgramLogs status label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -112,4 +136,393 @@ describe('ProgramLogs status label', () => {
|
||||
const statusBadge = statusCell.find((node) => node.type === 'span');
|
||||
expect(String(statusBadge.props.className || '')).toContain('badge-success');
|
||||
});
|
||||
|
||||
it('opens related trace and route targets from desktop rows', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
type: 'proxy',
|
||||
title: '代理追踪已捕获',
|
||||
message: 'trace detail ready',
|
||||
level: 'warning',
|
||||
read: true,
|
||||
relatedType: 'proxy_debug_trace',
|
||||
relatedId: 701,
|
||||
createdAt: '2026-03-04T06:43:03.000Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'proxy',
|
||||
title: '路由已命中',
|
||||
message: 'route detail ready',
|
||||
level: 'info',
|
||||
read: true,
|
||||
relatedType: 'route',
|
||||
relatedId: 42,
|
||||
createdAt: '2026-03-04T06:43:03.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/events']}>
|
||||
<LocationProbe />
|
||||
<ToastProvider>
|
||||
<ProgramLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const openButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '打开关联'
|
||||
));
|
||||
expect(openButtons).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
openButtons[0]!.props.onClick();
|
||||
});
|
||||
let locationProbe = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationProbe)).toBe('/logs?traceId=701');
|
||||
|
||||
await act(async () => {
|
||||
openButtons[1]!.props.onClick();
|
||||
});
|
||||
locationProbe = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationProbe)).toBe('/routes?routeId=42');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows human labels for aggregated business log types and hides read-only mark actions', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: -20000000040,
|
||||
type: 'proxy',
|
||||
title: '代理请求失败:gpt-test',
|
||||
message: '站点=主站 · HTTP 404',
|
||||
level: 'error',
|
||||
read: true,
|
||||
relatedType: 'proxy_log',
|
||||
relatedId: 40,
|
||||
createdAt: '2026-06-08T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: -10000000030,
|
||||
type: 'checkin',
|
||||
title: '签到成功:主站',
|
||||
message: 'alice · 奖励=+$0.0100',
|
||||
level: 'info',
|
||||
read: true,
|
||||
relatedType: 'checkin_log',
|
||||
relatedId: 30,
|
||||
createdAt: '2026-06-08T09:50:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/events']}>
|
||||
<ToastProvider>
|
||||
<ProgramLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('代理');
|
||||
expect(text).toContain('签到');
|
||||
expect(text).toContain('只读聚合');
|
||||
expect(text).toContain('只读');
|
||||
expect(text).not.toContain('proxy');
|
||||
expect(text).not.toContain('checkin');
|
||||
expect(findButtonByText(root.root, '全部已读').props.disabled).toBe(true);
|
||||
expect(findButtonByText(root.root, '清空日志').props.disabled).toBe(true);
|
||||
expect(root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).trim() === '标记已读'
|
||||
))).toHaveLength(0);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('opens token-derived event rows in the account token segment', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: -40000000020,
|
||||
type: 'token',
|
||||
title: '令牌已更新:默认 Key',
|
||||
message: '主站 · alice',
|
||||
level: 'info',
|
||||
read: true,
|
||||
relatedType: 'account_token',
|
||||
relatedId: 20,
|
||||
createdAt: '2026-06-08T09:40:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/events']}>
|
||||
<LocationProbe />
|
||||
<ToastProvider>
|
||||
<ProgramLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const openButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '打开关联'
|
||||
));
|
||||
await act(async () => {
|
||||
openButton.props.onClick();
|
||||
});
|
||||
|
||||
const locationProbe = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationProbe)).toBe('/accounts?segment=tokens&focusTokenId=20');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the status filter usable for derived health snapshots', async () => {
|
||||
apiMock.getEvents
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: -60000000001,
|
||||
type: 'status',
|
||||
title: '站点状态:主站',
|
||||
message: '平台=new-api · 状态=active',
|
||||
level: 'info',
|
||||
read: true,
|
||||
relatedType: 'site',
|
||||
relatedId: 1,
|
||||
createdAt: '2026-06-08T10:20:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/events']}>
|
||||
<ToastProvider>
|
||||
<ProgramLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const typeSelect = root.root.findByType(ModernSelect);
|
||||
await act(async () => {
|
||||
typeSelect.props.onChange('status');
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getEvents).toHaveBeenNthCalledWith(2, 'limit=50&offset=0&type=status');
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('站点状态:主站');
|
||||
expect(text).toContain('状态');
|
||||
expect(text).not.toContain('status');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('turns off unread filtering when switching to derived aggregate log types', async () => {
|
||||
apiMock.getEvents
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: -20000000040,
|
||||
type: 'proxy',
|
||||
title: '代理请求失败:gpt-test',
|
||||
message: '站点=主站 · HTTP 404',
|
||||
level: 'error',
|
||||
read: true,
|
||||
relatedType: 'proxy_log',
|
||||
relatedId: 40,
|
||||
createdAt: '2026-06-08T10:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/events']}>
|
||||
<ToastProvider>
|
||||
<ProgramLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const unreadCheckbox = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['aria-label'] === '仅看未读运行日志'
|
||||
));
|
||||
await act(async () => {
|
||||
unreadCheckbox.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getEvents).toHaveBeenNthCalledWith(2, 'limit=50&offset=0&read=false');
|
||||
|
||||
const typeSelect = root.root.findByType(ModernSelect);
|
||||
await act(async () => {
|
||||
typeSelect.props.onChange('proxy');
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getEvents).toHaveBeenNthCalledWith(3, 'limit=50&offset=0&type=proxy');
|
||||
const proxyUnreadCheckbox = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['aria-label'] === '仅看未读运行日志'
|
||||
));
|
||||
expect(proxyUnreadCheckbox.props.checked).toBe(false);
|
||||
expect(proxyUnreadCheckbox.props.disabled).toBe(true);
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('代理请求失败:gpt-test');
|
||||
expect(text).toContain('聚合日志不参与未读筛选');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps writable and runtime events operational while treating derived rows as read-only', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 12,
|
||||
type: 'status',
|
||||
title: '真实事件',
|
||||
message: '需要处理',
|
||||
level: 'warning',
|
||||
read: false,
|
||||
createdAt: '2026-06-08T10:30:00.000Z',
|
||||
},
|
||||
{
|
||||
id: -3,
|
||||
type: 'runtime',
|
||||
title: '运行日志',
|
||||
message: 'boosapi::routes',
|
||||
level: 'info',
|
||||
read: true,
|
||||
createdAt: '2026-06-08T10:29:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/events']}>
|
||||
<ToastProvider>
|
||||
<ProgramLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(findButtonByText(root.root, '全部已读').props.disabled).toBe(false);
|
||||
expect(findButtonByText(root.root, '清空日志').props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(root.root, '全部已读').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(apiMock.markAllEventsRead).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores stale event loads after changing filters', async () => {
|
||||
const initialLoad = deferred<unknown[]>();
|
||||
const filteredLoad = deferred<unknown[]>();
|
||||
apiMock.getEvents
|
||||
.mockReturnValueOnce(initialLoad.promise)
|
||||
.mockReturnValueOnce(filteredLoad.promise);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/events']}>
|
||||
<ToastProvider>
|
||||
<ProgramLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const typeSelect = root.root.findByType(ModernSelect);
|
||||
await act(async () => {
|
||||
typeSelect.props.onChange('proxy');
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getEvents).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.getEvents).toHaveBeenNthCalledWith(2, 'limit=50&offset=0&type=proxy');
|
||||
|
||||
await act(async () => {
|
||||
filteredLoad.resolve([
|
||||
{
|
||||
id: 11,
|
||||
type: 'proxy',
|
||||
title: '新筛选结果',
|
||||
message: '代理日志',
|
||||
level: 'info',
|
||||
read: true,
|
||||
createdAt: '2026-03-04T06:43:03.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('新筛选结果');
|
||||
|
||||
await act(async () => {
|
||||
initialLoad.resolve([
|
||||
{
|
||||
id: 10,
|
||||
type: 'status',
|
||||
title: '旧请求结果',
|
||||
message: '不应该覆盖筛选结果',
|
||||
level: 'warning',
|
||||
read: true,
|
||||
createdAt: '2026-03-04T06:43:03.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const pageText = collectText(root.root);
|
||||
expect(pageText).toContain('新筛选结果');
|
||||
expect(pageText).not.toContain('旧请求结果');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+189
-41
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import PageHeader from '../components/PageHeader.js';
|
||||
@@ -6,6 +7,7 @@ import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import { buildEventNavigationPath } from './helpers/navigationFocus.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
@@ -31,9 +33,38 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'proxy', label: '代理' },
|
||||
{ value: 'status', label: '状态' },
|
||||
{ value: 'site_notice', label: '站点公告' },
|
||||
{ value: 'user_login', label: '用户登录' },
|
||||
{ value: 'runtime', label: '运行日志' },
|
||||
];
|
||||
|
||||
const TYPE_LABEL_BY_VALUE = TYPE_OPTIONS.reduce<Record<string, string>>((labels, item) => {
|
||||
if (item.value) labels[item.value] = item.label;
|
||||
return labels;
|
||||
}, {});
|
||||
|
||||
const READ_ONLY_TYPE_VALUES = new Set([
|
||||
'checkin',
|
||||
'balance',
|
||||
'token',
|
||||
'proxy',
|
||||
'status',
|
||||
'site_notice',
|
||||
'runtime',
|
||||
]);
|
||||
|
||||
function typeLabel(type: string | null | undefined) {
|
||||
const value = (type || '').trim();
|
||||
return TYPE_LABEL_BY_VALUE[value] || value || '-';
|
||||
}
|
||||
|
||||
function isWritableEvent(row: ProgramEvent) {
|
||||
return row.id > 0;
|
||||
}
|
||||
|
||||
function isClearableEvent(row: ProgramEvent) {
|
||||
return isWritableEvent(row) || row.type === 'runtime';
|
||||
}
|
||||
|
||||
function levelLabel(level: string) {
|
||||
if (level === 'error') return { label: '错误', cls: 'badge-error' };
|
||||
if (level === 'warning') return { label: '警告', cls: 'badge-warning' };
|
||||
@@ -102,8 +133,41 @@ export default function ProgramLogs() {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const mountedRef = useRef(true);
|
||||
const loadRequestSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
loadRequestSeqRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const resetPagination = () => {
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
};
|
||||
|
||||
const updateFilterType = (nextValue: string) => {
|
||||
if (nextValue === filterType) return;
|
||||
loadRequestSeqRef.current += 1;
|
||||
resetPagination();
|
||||
if (READ_ONLY_TYPE_VALUES.has(nextValue)) {
|
||||
setOnlyUnread(false);
|
||||
}
|
||||
setFilterType(nextValue);
|
||||
};
|
||||
|
||||
const updateOnlyUnread = (checked: boolean) => {
|
||||
if (checked === onlyUnread) return;
|
||||
loadRequestSeqRef.current += 1;
|
||||
resetPagination();
|
||||
setOnlyUnread(checked);
|
||||
};
|
||||
|
||||
const load = async (silent = false, append = false) => {
|
||||
const requestSeq = ++loadRequestSeqRef.current;
|
||||
const isCurrentRequest = () => mountedRef.current && loadRequestSeqRef.current === requestSeq;
|
||||
|
||||
if (append) setLoadingMore(true);
|
||||
else if (silent) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
@@ -116,14 +180,17 @@ export default function ProgramLogs() {
|
||||
if (filterType) params.set('type', filterType);
|
||||
if (onlyUnread) params.set('read', 'false');
|
||||
const rows = await api.getEvents(params.toString());
|
||||
if (!isCurrentRequest()) return;
|
||||
const safeRows = Array.isArray(rows) ? rows : [];
|
||||
setEvents((prev) => (append ? [...prev, ...safeRows] : safeRows));
|
||||
const loaded = append ? nextOffset + safeRows.length : safeRows.length;
|
||||
setOffset(loaded);
|
||||
setHasMore(safeRows.length >= PAGE_SIZE);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '加载程序日志失败');
|
||||
if (!isCurrentRequest()) return;
|
||||
toast.error(e.message || '加载运行日志失败');
|
||||
} finally {
|
||||
if (!isCurrentRequest()) return;
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
setRefreshing(false);
|
||||
@@ -135,12 +202,32 @@ export default function ProgramLogs() {
|
||||
}, [filterType, onlyUnread]);
|
||||
|
||||
const visibleRows = useMemo(() => events, [events]);
|
||||
const writableRows = useMemo(() => visibleRows.filter(isWritableEvent), [visibleRows]);
|
||||
const clearableRows = useMemo(() => visibleRows.filter(isClearableEvent), [visibleRows]);
|
||||
const visibleWritableCount = writableRows.length;
|
||||
const visibleClearableCount = clearableRows.length;
|
||||
const onlyUnreadDisabled = READ_ONLY_TYPE_VALUES.has(filterType);
|
||||
const visibleUnreadWritableCount = useMemo(
|
||||
() => writableRows.filter((row) => !row.read).length,
|
||||
[writableRows],
|
||||
);
|
||||
const hasOnlyReadOnlyRows = visibleRows.length > 0 && visibleClearableCount === 0;
|
||||
|
||||
const getEventTargetPath = (row: ProgramEvent) => buildEventNavigationPath(row);
|
||||
const hasEventTarget = (row: ProgramEvent) => getEventTargetPath(row) !== '/events';
|
||||
const openEventTarget = (row: ProgramEvent) => {
|
||||
const targetPath = getEventTargetPath(row);
|
||||
if (targetPath === '/events') return;
|
||||
navigate(targetPath);
|
||||
};
|
||||
|
||||
const withRowLoading = async (id: number, fn: () => Promise<void>) => {
|
||||
if (!mountedRef.current) return;
|
||||
setRowLoading((prev) => ({ ...prev, [id]: true }));
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
if (!mountedRef.current) return;
|
||||
setRowLoading((prev) => ({ ...prev, [id]: false }));
|
||||
}
|
||||
};
|
||||
@@ -148,6 +235,7 @@ export default function ProgramLogs() {
|
||||
const markOneRead = async (id: number) => {
|
||||
await withRowLoading(id, async () => {
|
||||
await api.markEventRead(id);
|
||||
if (!mountedRef.current) return;
|
||||
setEvents((prev) => {
|
||||
if (onlyUnread) return prev.filter((item) => item.id !== id);
|
||||
return prev.map((item) => (item.id === id ? { ...item, read: true } : item));
|
||||
@@ -159,12 +247,15 @@ export default function ProgramLogs() {
|
||||
setMarkingAll(true);
|
||||
try {
|
||||
await api.markAllEventsRead();
|
||||
if (!mountedRef.current) return;
|
||||
if (onlyUnread) setEvents([]);
|
||||
else setEvents((prev) => prev.map((item) => ({ ...item, read: true })));
|
||||
toast.success('已标记全部为已读');
|
||||
} catch (e: any) {
|
||||
if (!mountedRef.current) return;
|
||||
toast.error(e.message || '标记失败');
|
||||
} finally {
|
||||
if (!mountedRef.current) return;
|
||||
setMarkingAll(false);
|
||||
}
|
||||
};
|
||||
@@ -173,13 +264,16 @@ export default function ProgramLogs() {
|
||||
setClearing(true);
|
||||
try {
|
||||
await api.clearEvents();
|
||||
if (!mountedRef.current) return;
|
||||
setEvents([]);
|
||||
setOffset(0);
|
||||
setHasMore(false);
|
||||
toast.success('日志已清空');
|
||||
} catch (e: any) {
|
||||
if (!mountedRef.current) return;
|
||||
toast.error(e.message || '清空失败');
|
||||
} finally {
|
||||
if (!mountedRef.current) return;
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
@@ -187,10 +281,11 @@ export default function ProgramLogs() {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<PageHeader
|
||||
title={tr('程序日志')}
|
||||
title={tr('运行日志')}
|
||||
actions={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(true)}
|
||||
disabled={refreshing}
|
||||
className="btn btn-ghost"
|
||||
@@ -199,17 +294,21 @@ export default function ProgramLogs() {
|
||||
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllRead}
|
||||
disabled={markingAll}
|
||||
disabled={markingAll || visibleUnreadWritableCount === 0}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
title={hasOnlyReadOnlyRows ? '当前列表为只读聚合日志,无需标记已读' : undefined}
|
||||
>
|
||||
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
disabled={clearing}
|
||||
disabled={clearing || visibleClearableCount === 0}
|
||||
className="btn btn-link btn-link-danger"
|
||||
title={hasOnlyReadOnlyRows ? '当前列表为只读聚合日志,请到关联页面管理原始记录' : undefined}
|
||||
>
|
||||
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空日志'}
|
||||
</button>
|
||||
@@ -222,13 +321,13 @@ export default function ProgramLogs() {
|
||||
mobileOpen={showFilters}
|
||||
onMobileOpen={() => setShowFilters(true)}
|
||||
onMobileClose={() => setShowFilters(false)}
|
||||
mobileTitle="筛选程序日志"
|
||||
mobileTitle="筛选运行日志"
|
||||
mobileContent={(
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={filterType}
|
||||
onChange={(nextValue) => setFilterType(nextValue)}
|
||||
onChange={updateFilterType}
|
||||
options={TYPE_OPTIONS.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
@@ -239,26 +338,34 @@ export default function ProgramLogs() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyUnread}
|
||||
onChange={(e) => {
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
setOnlyUnread(e.target.checked);
|
||||
}}
|
||||
disabled={onlyUnreadDisabled}
|
||||
onChange={(e) => updateOnlyUnread(e.target.checked)}
|
||||
aria-label="仅看未读运行日志"
|
||||
/>
|
||||
仅看未读
|
||||
</label>
|
||||
{onlyUnreadDisabled && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
当前类型为聚合日志,未读筛选已关闭。
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
共 {visibleRows.length} 条
|
||||
</div>
|
||||
{hasOnlyReadOnlyRows && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
当前为只读聚合日志,原始记录请通过关联页面管理。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
desktopContent={(
|
||||
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<div className="card program-logs-filter-bar">
|
||||
<div className="program-logs-type-filter">
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={filterType}
|
||||
onChange={(nextValue) => setFilterType(nextValue)}
|
||||
onChange={updateFilterType}
|
||||
options={TYPE_OPTIONS.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
@@ -271,23 +378,38 @@ export default function ProgramLogs() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyUnread}
|
||||
onChange={(e) => {
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
setOnlyUnread(e.target.checked);
|
||||
}}
|
||||
disabled={onlyUnreadDisabled}
|
||||
onChange={(e) => updateOnlyUnread(e.target.checked)}
|
||||
aria-label="仅看未读运行日志"
|
||||
/>
|
||||
仅看未读
|
||||
</label>
|
||||
|
||||
{onlyUnreadDisabled && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
聚合日志不参与未读筛选
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
共 {visibleRows.length} 条
|
||||
</div>
|
||||
|
||||
{hasOnlyReadOnlyRows && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
只读聚合,原始记录在关联页面管理
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
<div
|
||||
className="card"
|
||||
style={{ overflowX: 'auto' }}
|
||||
aria-busy={loading || refreshing || loadingMore}
|
||||
aria-live="polite"
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ padding: 20 }}>
|
||||
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
|
||||
@@ -309,22 +431,35 @@ export default function ProgramLogs() {
|
||||
</span>
|
||||
)}
|
||||
footerActions={(
|
||||
row.read ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markOneRead(row.id)}
|
||||
disabled={!!rowLoading[row.id]}
|
||||
className="btn btn-link btn-link-primary"
|
||||
>
|
||||
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
|
||||
</button>
|
||||
)
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{hasEventTarget(row) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEventTarget(row)}
|
||||
className="btn btn-link btn-link-primary"
|
||||
>
|
||||
打开关联
|
||||
</button>
|
||||
)}
|
||||
{!isWritableEvent(row) ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>只读</span>
|
||||
) : row.read ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markOneRead(row.id)}
|
||||
disabled={!!rowLoading[row.id]}
|
||||
className="btn btn-link btn-link-primary"
|
||||
>
|
||||
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<MobileField label="时间" value={formatDateTimeLocal(row.createdAt)} />
|
||||
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{row.type || '-'}</span>} />
|
||||
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{typeLabel(row.type)}</span>} />
|
||||
<MobileField label="级别" value={<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
|
||||
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.label}</span>} />
|
||||
<MobileField label="内容" value={row.message || '-'} stacked />
|
||||
@@ -336,7 +471,7 @@ export default function ProgramLogs() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无日志</div>
|
||||
<div className="empty-state-desc">当前筛选条件下没有程序日志。</div>
|
||||
<div className="empty-state-desc">当前筛选条件下没有运行日志。</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -349,7 +484,7 @@ export default function ProgramLogs() {
|
||||
<col style={{ width: 260 }} />
|
||||
<col />
|
||||
<col style={{ width: 110 }} />
|
||||
<col style={{ width: 140 }} />
|
||||
<col style={{ width: 180 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -373,7 +508,7 @@ export default function ProgramLogs() {
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{row.type || '-'}
|
||||
{typeLabel(row.type)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@@ -393,14 +528,26 @@ export default function ProgramLogs() {
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
{row.read ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
|
||||
{!isClearableEvent(row) ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>只读</span>
|
||||
) : row.read ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
|
||||
) : (
|
||||
<span className="badge badge-warning" style={{ fontSize: 11 }}>未读</span>
|
||||
)}
|
||||
{!row.read && (
|
||||
{hasEventTarget(row) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEventTarget(row)}
|
||||
className="btn btn-link btn-link-primary"
|
||||
>
|
||||
打开关联
|
||||
</button>
|
||||
)}
|
||||
{!row.read && isWritableEvent(row) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markOneRead(row.id)}
|
||||
disabled={!!rowLoading[row.id]}
|
||||
className="btn btn-link btn-link-primary"
|
||||
@@ -421,7 +568,7 @@ export default function ProgramLogs() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无日志</div>
|
||||
<div className="empty-state-desc">当前筛选条件下没有程序日志。</div>
|
||||
<div className="empty-state-desc">当前筛选条件下没有运行日志。</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -429,6 +576,7 @@ export default function ProgramLogs() {
|
||||
{!loading && visibleRows.length > 0 && hasMore && (
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => load(false, true)}
|
||||
disabled={loadingMore}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import ProxyLogs from './ProxyLogs.js';
|
||||
import { MODEL_TESTER_STORAGE_KEY } from './helpers/modelTesterSession.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
const { apiMock, navigateMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getProxyLogs: vi.fn(),
|
||||
getProxyLogsQuery: vi.fn(),
|
||||
@@ -17,12 +18,21 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getSites: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
},
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
@@ -62,6 +72,8 @@ function buildListResponse(overrides?: Partial<{
|
||||
latencyMs: 120,
|
||||
firstByteLatencyMs: 35,
|
||||
isStream: true,
|
||||
routeId: 77,
|
||||
channelId: 88,
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
@@ -78,6 +90,8 @@ function buildListResponse(overrides?: Partial<{
|
||||
downstreamKeyName: '移动端灰度',
|
||||
downstreamKeyGroupName: '项目A',
|
||||
downstreamKeyTags: ['VIP', '灰度'],
|
||||
requestContent: 'Explain the latency spike',
|
||||
responseContent: 'Latency spike came from upstream retry.',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
@@ -150,8 +164,8 @@ describe('ProxyLogs server-driven page', () => {
|
||||
summary: buildListResponse().summary,
|
||||
clientOptions: buildListResponse().clientOptions,
|
||||
sites: [
|
||||
{ id: 1, name: 'main-site', status: 'active' },
|
||||
{ id: 2, name: 'backup-site', status: 'active' },
|
||||
{ id: 9, name: 'main-site', status: 'active' },
|
||||
{ id: 12, name: 'backup-site', status: 'active' },
|
||||
],
|
||||
});
|
||||
apiMock.getProxyLogDetail.mockResolvedValue({
|
||||
@@ -179,6 +193,8 @@ describe('ProxyLogs server-driven page', () => {
|
||||
downstreamKeyName: '移动端灰度',
|
||||
downstreamKeyGroupName: '项目A',
|
||||
downstreamKeyTags: ['VIP', '灰度'],
|
||||
requestContent: 'Explain the latency spike',
|
||||
responseContent: 'Latency spike came from upstream retry.',
|
||||
billingDetails: {
|
||||
breakdown: {
|
||||
inputPerMillion: 1,
|
||||
@@ -226,9 +242,18 @@ describe('ProxyLogs server-driven page', () => {
|
||||
apiMock.getProxyDebugTraceDetail.mockResolvedValue({
|
||||
trace: {
|
||||
id: 701,
|
||||
requestedModel: 'gpt-4o',
|
||||
requestedModel: null,
|
||||
sessionId: 'sess-debug-1',
|
||||
downstreamPath: '/v1/responses',
|
||||
finalUpstreamPath: '/responses',
|
||||
selectedRouteId: 77,
|
||||
selectedChannelId: 910,
|
||||
requestHeadersJson: '{\n "authorization": "Bearer demo"\n}',
|
||||
requestBodyJson: JSON.stringify({
|
||||
model: 'gpt-4o',
|
||||
input: 'Replay the exact raw request',
|
||||
stream: true,
|
||||
}),
|
||||
},
|
||||
attempts: [],
|
||||
});
|
||||
@@ -283,6 +308,33 @@ describe('ProxyLogs server-driven page', () => {
|
||||
expect(text).toContain('下游 Key: 移动端灰度');
|
||||
expect(text).toContain('流式');
|
||||
expect(text).toContain('首字');
|
||||
|
||||
const autoRefreshButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('自动刷新')
|
||||
));
|
||||
expect(autoRefreshButton.props['aria-pressed']).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
autoRefreshButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const pressedAutoRefreshButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('自动刷新中')
|
||||
));
|
||||
expect(pressedAutoRefreshButton.props['aria-pressed']).toBe(true);
|
||||
|
||||
const currentPageButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& String(node.props.className || '').includes('pagination-btn')
|
||||
&& node.props['aria-current'] === 'page'
|
||||
&& collectText(node).trim() === '1'
|
||||
));
|
||||
expect(currentPageButtons.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
@@ -290,6 +342,374 @@ describe('ProxyLogs server-driven page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('passes downstream key route filters into server-driven log queries', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs?status=failed&downstreamKeyId=17']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getProxyLogs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
downstreamKeyId: 17,
|
||||
status: 'failed',
|
||||
}));
|
||||
expect(apiMock.getProxyLogsMeta).toHaveBeenCalledWith(expect.objectContaining({
|
||||
downstreamKeyId: 17,
|
||||
status: 'failed',
|
||||
}));
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('replays a proxy log into ModelTester session storage', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root.root.find((node) => (
|
||||
node.type === 'tr'
|
||||
&& node.props['data-testid'] === 'proxy-log-row-101'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const replayButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '回放到测试器'
|
||||
));
|
||||
expect(collectText(root.root)).toContain('用原请求复现响应');
|
||||
|
||||
await act(async () => {
|
||||
replayButton.props.onClick({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const storageWrite = vi.mocked(globalThis.localStorage.setItem).mock.calls.find(
|
||||
([key]) => key === MODEL_TESTER_STORAGE_KEY,
|
||||
);
|
||||
expect(storageWrite).toBeTruthy();
|
||||
const session = JSON.parse(storageWrite![1]);
|
||||
|
||||
expect(session.inputs.model).toBe('gpt-4o');
|
||||
expect(session.inputs.protocol).toBe('openai');
|
||||
expect(session.inputs.stream).toBe(true);
|
||||
expect(session.showDebugPanel).toBe(true);
|
||||
expect(session.pendingPayload).toMatchObject({
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o',
|
||||
stream: true,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Explain the latency spike',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(session.replaySource).toEqual({
|
||||
kind: 'proxy_log',
|
||||
label: '代理日志回放',
|
||||
model: 'gpt-4o',
|
||||
protocol: 'openai',
|
||||
sourceId: '101',
|
||||
summary: '来自成功使用日志',
|
||||
createdAt: '2026-03-09 16:00:00',
|
||||
});
|
||||
expect(session.messages[0]).toMatchObject({
|
||||
role: 'user',
|
||||
content: 'Explain the latency spike',
|
||||
});
|
||||
expect(session.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Latency spike came from upstream retry.',
|
||||
status: 'complete',
|
||||
});
|
||||
|
||||
const diagnoseButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '诊断路由'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
diagnoseButton.props.onClick({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith('/routes?diagnosticModel=gpt-4o&routeId=77');
|
||||
expect(collectText(root.root)).toContain('打开命中路由上下文');
|
||||
|
||||
const copyReportButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '复制报告'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
copyReportButton.props.onClick({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(globalThis.navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('代理日志排障报告'),
|
||||
);
|
||||
const copiedReport = vi.mocked(globalThis.navigator.clipboard.writeText).mock.calls.at(-1)?.[0] || '';
|
||||
expect(copiedReport).toContain('日志 ID:101');
|
||||
expect(copiedReport).toContain('模型:gpt-4o');
|
||||
expect(copiedReport).toContain('日志详情:/logs?logId=101');
|
||||
expect(copiedReport).toContain('路由诊断:/routes?diagnosticModel=gpt-4o&routeId=77');
|
||||
expect(copiedReport).toContain('耗时:120ms / 首字 35ms / 流式');
|
||||
expect(copiedReport).toContain('站点:main-site');
|
||||
expect(copiedReport).toContain('下游 Key:下游 Key: 移动端灰度');
|
||||
expect(copiedReport).toContain('请求摘要:Explain the latency spike');
|
||||
expect(copiedReport).toContain('响应摘要:Latency spike came from upstream retry.');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('replays saved JSON request bodies into ModelTester raw request mode', async () => {
|
||||
apiMock.getProxyLogDetail.mockResolvedValueOnce({
|
||||
id: 101,
|
||||
createdAt: '2026-03-09 16:00:00',
|
||||
modelRequested: 'gpt-4o-mini-hidden',
|
||||
modelActual: 'gpt-4o-mini-hidden',
|
||||
status: 'failed',
|
||||
latencyMs: 120,
|
||||
firstByteLatencyMs: 35,
|
||||
isStream: false,
|
||||
promptTokens: 10,
|
||||
completionTokens: 0,
|
||||
totalTokens: 10,
|
||||
retryCount: 0,
|
||||
estimatedCost: 0.12,
|
||||
errorMessage: 'downstream: /v1/chat/completions upstream: /v1/chat/completions',
|
||||
username: 'tester',
|
||||
siteName: 'main-site',
|
||||
channelId: 77,
|
||||
requestContent: JSON.stringify({
|
||||
model: 'gpt-4o-mini-hidden',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Keep it short.' },
|
||||
{ role: 'user', content: 'Replay this raw payload.' },
|
||||
],
|
||||
}),
|
||||
responseContent: '',
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root.root.find((node) => (
|
||||
node.type === 'tr'
|
||||
&& node.props['data-testid'] === 'proxy-log-row-101'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const replayButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '回放到测试器'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
replayButton.props.onClick({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const storageWrite = vi.mocked(globalThis.localStorage.setItem).mock.calls.find(
|
||||
([key]) => key === MODEL_TESTER_STORAGE_KEY,
|
||||
);
|
||||
expect(storageWrite).toBeTruthy();
|
||||
const session = JSON.parse(storageWrite![1]);
|
||||
|
||||
expect(session.inputs.model).toBe('gpt-4o-mini-hidden');
|
||||
expect(session.customRequestMode).toBe(true);
|
||||
expect(session.activeDebugTab).toBe('request');
|
||||
expect(session.forcedChannelId).toBe(77);
|
||||
expect(session.customRequestBody).toContain('"Replay this raw payload."');
|
||||
expect(session.modeState.extraJson).toBe(session.customRequestBody);
|
||||
expect(session.pendingPayload).toMatchObject({
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: true,
|
||||
rawMode: true,
|
||||
forcedChannelId: 77,
|
||||
});
|
||||
expect(session.pendingPayload.rawJsonText).toContain('"gpt-4o-mini-hidden"');
|
||||
expect(session.messages.map((message: any) => message.content)).toEqual([
|
||||
'Keep it short.',
|
||||
'Replay this raw payload.',
|
||||
]);
|
||||
expect(navigateMock).toHaveBeenCalledWith('/model-tester');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('replays Gemini proxy logs through the standard native ModelTester path', async () => {
|
||||
apiMock.getProxyLogDetail.mockResolvedValueOnce({
|
||||
id: 101,
|
||||
createdAt: '2026-03-09 16:00:00',
|
||||
modelRequested: 'stepfun-ai/step-3.7-flash',
|
||||
modelActual: 'stepfun-ai/step-3.7-flash',
|
||||
status: 'success',
|
||||
latencyMs: 560,
|
||||
firstByteLatencyMs: 120,
|
||||
isStream: false,
|
||||
promptTokens: 10,
|
||||
completionTokens: 8,
|
||||
totalTokens: 18,
|
||||
retryCount: 0,
|
||||
estimatedCost: 0.02,
|
||||
errorMessage: null,
|
||||
username: 'tester',
|
||||
siteName: 'main-site',
|
||||
channelId: 77,
|
||||
requestContent: JSON.stringify({
|
||||
model: 'stepfun-ai/step-3.7-flash',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: '验证 Gemini native 日志回放。' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
responseContent: '',
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root.root.find((node) => (
|
||||
node.type === 'tr'
|
||||
&& node.props['data-testid'] === 'proxy-log-row-101'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const replayButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '回放到测试器'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
replayButton.props.onClick({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const storageWrite = vi.mocked(globalThis.localStorage.setItem).mock.calls.find(
|
||||
([key]) => key === MODEL_TESTER_STORAGE_KEY,
|
||||
);
|
||||
expect(storageWrite).toBeTruthy();
|
||||
const session = JSON.parse(storageWrite![1]);
|
||||
|
||||
expect(session.inputs.model).toBe('stepfun-ai/step-3.7-flash');
|
||||
expect(session.inputs.protocol).toBe('gemini');
|
||||
expect(session.inputs.stream).toBe(false);
|
||||
expect(session.customRequestMode).toBe(true);
|
||||
expect(session.forcedChannelId).toBe(77);
|
||||
expect(session.pendingPayload).toMatchObject({
|
||||
method: 'POST',
|
||||
path: '/v1beta/models/stepfun-ai%2Fstep-3.7-flash:generateContent',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: true,
|
||||
rawMode: true,
|
||||
forcedChannelId: 77,
|
||||
});
|
||||
expect(session.pendingPayload.path).not.toContain('/gemini/v1beta');
|
||||
expect(session.pendingPayload.rawJsonText).toContain('"stepfun-ai/step-3.7-flash"');
|
||||
expect(session.replaySource).toMatchObject({
|
||||
kind: 'proxy_log',
|
||||
model: 'stepfun-ai/step-3.7-flash',
|
||||
protocol: 'gemini',
|
||||
sourceId: '101',
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith('/model-tester');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows proxy debug traces inline and edits settings through the modal', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
@@ -407,6 +827,13 @@ describe('ProxyLogs server-driven page', () => {
|
||||
&& collectText(node).trim() === '查看详情'
|
||||
));
|
||||
expect(detailButtons).toHaveLength(5);
|
||||
const firstCurrentPages = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& String(node.props.className || '').includes('pagination-btn')
|
||||
&& node.props['aria-current'] === 'page'
|
||||
&& collectText(node).trim() === '1'
|
||||
));
|
||||
expect(firstCurrentPages.length).toBeGreaterThan(0);
|
||||
|
||||
const nextPageButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
@@ -421,6 +848,13 @@ describe('ProxyLogs server-driven page', () => {
|
||||
expect(collectText(root.root)).toContain('显示第 6 - 7 条,共 7 条');
|
||||
expect(collectText(root.root)).toContain('sess-debug-6');
|
||||
expect(collectText(root.root)).not.toContain('sess-debug-1');
|
||||
const secondCurrentPage = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& String(node.props.className || '').includes('pagination-btn')
|
||||
&& node.props['aria-current'] === 'page'
|
||||
&& collectText(node).trim() === '2'
|
||||
));
|
||||
expect(secondCurrentPage).toBeTruthy();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
@@ -783,6 +1217,208 @@ describe('ProxyLogs server-driven page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('replays saved trace request body into ModelTester custom request mode', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const viewDetailButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '查看详情'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
viewDetailButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandBodyButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& node.props['aria-label'] === '展开原始下游请求体'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
expandBodyButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const diagnoseTraceButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& node.props['aria-label'] === '诊断原始下游请求路由'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
diagnoseTraceButton.props.onClick({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith('/routes?diagnosticModel=gpt-4o&routeId=77');
|
||||
|
||||
const copyTraceReportButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '复制追踪报告'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
copyTraceReportButton.props.onClick({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const copiedTraceReport = vi.mocked(globalThis.navigator.clipboard.writeText).mock.calls.at(-1)?.[0] || '';
|
||||
expect(copiedTraceReport).toContain('代理调试追踪排障报告');
|
||||
expect(copiedTraceReport).toContain('Trace ID:701');
|
||||
expect(copiedTraceReport).toContain('Session:sess-debug-1');
|
||||
expect(copiedTraceReport).toContain('模型:gpt-4o');
|
||||
expect(copiedTraceReport).toContain('追踪详情:/logs?traceId=701');
|
||||
expect(copiedTraceReport).toContain('路由诊断:/routes?diagnosticModel=gpt-4o&routeId=77');
|
||||
expect(copiedTraceReport).toContain('选中路由:77');
|
||||
expect(copiedTraceReport).toContain('下游路径:/v1/responses');
|
||||
expect(copiedTraceReport).toContain('最终上游路径:/responses');
|
||||
expect(copiedTraceReport).toContain('选中通道:910');
|
||||
expect(copiedTraceReport).toContain('请求体摘要:{"model":"gpt-4o","input":"Replay the exact raw request","stream":true}');
|
||||
|
||||
const replayBodyButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& node.props['aria-label'] === '原样回放原始下游请求体'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
replayBodyButton.props.onClick({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const storageWrite = vi.mocked(globalThis.localStorage.setItem).mock.calls.find(
|
||||
([key]) => key === MODEL_TESTER_STORAGE_KEY,
|
||||
);
|
||||
expect(storageWrite).toBeTruthy();
|
||||
const session = JSON.parse(storageWrite![1]);
|
||||
|
||||
expect(session.inputs.model).toBe('gpt-4o');
|
||||
expect(session.inputs.protocol).toBe('responses');
|
||||
expect(session.inputs.stream).toBe(true);
|
||||
expect(session.forcedChannelId).toBe(910);
|
||||
expect(session.customRequestMode).toBe(true);
|
||||
expect(session.activeDebugTab).toBe('request');
|
||||
expect(session.replaySource).toEqual({
|
||||
kind: 'proxy_debug_trace',
|
||||
label: '调试追踪原样回放',
|
||||
model: 'gpt-4o',
|
||||
protocol: 'responses',
|
||||
sourceId: '701',
|
||||
routeId: '77',
|
||||
sessionId: 'sess-debug-1',
|
||||
summary: '来自原始下游请求体',
|
||||
});
|
||||
expect(JSON.parse(session.customRequestBody)).toEqual({
|
||||
model: 'gpt-4o',
|
||||
input: 'Replay the exact raw request',
|
||||
stream: true,
|
||||
});
|
||||
expect(session.messages[0]).toMatchObject({
|
||||
role: 'user',
|
||||
content: '从代理调试追踪回放:sess-debug-1',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('opens selected route detail when a trace has no model but records the route id', async () => {
|
||||
apiMock.getProxyDebugTraceDetail.mockResolvedValue({
|
||||
trace: {
|
||||
id: 701,
|
||||
requestedModel: null,
|
||||
sessionId: 'sess-debug-1',
|
||||
downstreamPath: '/v1/responses',
|
||||
finalUpstreamPath: '/responses',
|
||||
selectedRouteId: 77,
|
||||
selectedChannelId: 910,
|
||||
requestHeadersJson: '{}',
|
||||
requestBodyJson: JSON.stringify({
|
||||
input: 'No model persisted here',
|
||||
}),
|
||||
},
|
||||
attempts: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const viewDetailButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '查看详情'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
viewDetailButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandBodyButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& node.props['aria-label'] === '展开原始下游请求体'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
expandBodyButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const diagnoseTraceButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& node.props['aria-label'] === '诊断原始下游请求路由'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
diagnoseTraceButton.props.onClick({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith('/routes?routeId=77');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the model badge sized to the model name in desktop rows', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
@@ -886,11 +1522,17 @@ describe('ProxyLogs server-driven page', () => {
|
||||
const failedTab = root!.root.findAll((node) => (
|
||||
node.type === 'button' && collectText(node).includes('失败')
|
||||
))[0];
|
||||
expect(failedTab.props['aria-pressed']).toBe(false);
|
||||
await act(async () => {
|
||||
failedTab.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const pressedFailedTab = root!.root.findAll((node) => (
|
||||
node.type === 'button' && collectText(node).includes('失败')
|
||||
))[0];
|
||||
expect(pressedFailedTab.props['aria-pressed']).toBe(true);
|
||||
|
||||
const selects = root!.root.findAllByType(ModernSelect);
|
||||
const clientSelect = selects.find((node) => node.props.placeholder === '全部客户端');
|
||||
expect(clientSelect).toBeDefined();
|
||||
@@ -976,6 +1618,103 @@ describe('ProxyLogs server-driven page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('hydrates deep links for a proxy log and debug trace detail', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs?logId=101&traceId=701']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
expect(apiMock.getProxyLogDetail).toHaveBeenCalledWith(101);
|
||||
expect(apiMock.getProxyDebugTraceDetail).toHaveBeenCalledWith(701);
|
||||
expect(collectText(root.root)).toContain('日志详情');
|
||||
expect(collectText(root.root)).toContain('原始下游请求体');
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a triage context card for failed log deep links', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs?status=failed&q=gpt-4o-mini&siteId=9&traceId=701']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
expect(collectText(root.root)).toContain('失败日志排障');
|
||||
});
|
||||
|
||||
const contextCard = root.root.find((node) => (
|
||||
node.type === 'section'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('proxy-logs-triage-context')
|
||||
));
|
||||
expect(contextCard.props['data-tone']).toBe('danger');
|
||||
|
||||
const contextText = collectText(contextCard);
|
||||
expect(contextText).toContain('排障上下文');
|
||||
expect(contextText).toContain('失败日志排障');
|
||||
expect(contextText).toContain('当前只看失败请求');
|
||||
expect(contextText).toContain('失败日志');
|
||||
expect(contextText).toContain('模型 gpt-4o-mini');
|
||||
expect(contextText).toContain('main-site');
|
||||
expect(contextText).toContain('追踪 #701');
|
||||
expect(contextText).toContain('匹配日志12');
|
||||
expect(contextText).toContain('失败4');
|
||||
expect(contextText).toContain('失败率33.3%');
|
||||
expect(contextText).toContain('Tokens15');
|
||||
|
||||
const links = contextCard.findAll((node) => node.type === 'a');
|
||||
expect(links.some((link) => link.props.href === '/routes?diagnosticModel=gpt-4o-mini' || link.props.to === '/routes?diagnosticModel=gpt-4o-mini')).toBe(true);
|
||||
expect(links.some((link) => link.props.href === '/model-tester?model=gpt-4o-mini' || link.props.to === '/model-tester?model=gpt-4o-mini')).toBe(true);
|
||||
expect(links.some((link) => link.props.href === '/logs?traceId=701' || link.props.to === '/logs?traceId=701')).toBe(true);
|
||||
|
||||
const copyButton = contextCard.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '复制范围摘要'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
copyButton.props.onClick({ stopPropagation: vi.fn() });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const copied = vi.mocked(globalThis.navigator.clipboard.writeText).mock.calls.at(-1)?.[0] || '';
|
||||
expect(copied).toContain('日志排障上下文');
|
||||
expect(copied).toContain('状态:失败日志');
|
||||
expect(copied).toContain('搜索:gpt-4o-mini');
|
||||
expect(copied).toContain('站点:main-site');
|
||||
expect(copied).toContain('调试追踪:/logs?traceId=701');
|
||||
expect(copied).toContain('路由诊断:/routes?diagnosticModel=gpt-4o-mini');
|
||||
expect(copied).toContain('模型测试器:/model-tester?model=gpt-4o-mini');
|
||||
expect(copied).toContain('失败率:33.3%');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders unknown usage as -- instead of 0 in the server-driven table', async () => {
|
||||
apiMock.getProxyLogs.mockResolvedValue(buildListResponse({
|
||||
items: [
|
||||
|
||||
+1272
-193
File diff suppressed because it is too large
Load Diff
+25
-10
@@ -1733,7 +1733,7 @@ export default function Settings() {
|
||||
checked={runtime.logCleanupProgramLogsEnabled}
|
||||
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupProgramLogsEnabled: e.target.checked }))}
|
||||
/>
|
||||
清理程序日志
|
||||
清理运行日志
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||
@@ -1893,6 +1893,8 @@ export default function Settings() {
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
aria-expanded={showPayloadRulesEditor}
|
||||
aria-controls="settings-payload-rules-advanced-json"
|
||||
onClick={() => setShowPayloadRulesEditor((prev) => !prev)}
|
||||
>
|
||||
{showPayloadRulesEditor ? '收起高级 JSON 编辑' : '展开高级 JSON 编辑'}
|
||||
@@ -2035,7 +2037,10 @@ export default function Settings() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`anim-collapse ${showPayloadRulesEditor ? 'is-open' : ''}`.trim()}>
|
||||
<div
|
||||
id="settings-payload-rules-advanced-json"
|
||||
className={`anim-collapse ${showPayloadRulesEditor ? 'is-open' : ''}`.trim()}
|
||||
>
|
||||
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
|
||||
<div style={settingsModernFieldCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
@@ -2283,7 +2288,7 @@ export default function Settings() {
|
||||
style={{
|
||||
...inputStyle,
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
minWidth: 0,
|
||||
marginBottom: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
@@ -2371,7 +2376,7 @@ export default function Settings() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12, maxWidth: 280 }}>
|
||||
<div style={{ marginBottom: 12, maxWidth: 280, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
无实测/配置/目录价时默认单价
|
||||
</div>
|
||||
@@ -2390,7 +2395,7 @@ export default function Settings() {
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12, maxWidth: 420 }}>
|
||||
<div style={{ marginBottom: 12, maxWidth: 420, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
普通失败冷却上限
|
||||
</div>
|
||||
@@ -2410,9 +2415,9 @@ export default function Settings() {
|
||||
: prev.routeFailureCooldownMaxValue,
|
||||
}));
|
||||
}}
|
||||
style={{ ...inputStyle, flex: '1 1 180px', marginBottom: 0 }}
|
||||
style={{ ...inputStyle, flex: '1 1 min(180px, 100%)', minWidth: 0, marginBottom: 0 }}
|
||||
/>
|
||||
<div style={{ width: 132, minWidth: 132 }}>
|
||||
<div style={{ flex: '1 1 min(132px, 100%)', minWidth: 0, maxWidth: 180 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={runtime.routeFailureCooldownMaxUnit}
|
||||
@@ -2469,6 +2474,8 @@ export default function Settings() {
|
||||
onClick={() => setShowAdvancedRouting((prev) => !prev)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
aria-expanded={showAdvancedRouting}
|
||||
aria-controls="settings-advanced-routing-weights"
|
||||
>
|
||||
{showAdvancedRouting ? '收起高级参数' : '展开高级参数'}
|
||||
</button>
|
||||
@@ -2520,7 +2527,10 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`anim-collapse ${showAdvancedRouting ? 'is-open' : ''}`.trim()}>
|
||||
<div
|
||||
id="settings-advanced-routing-weights"
|
||||
className={`anim-collapse ${showAdvancedRouting ? 'is-open' : ''}`.trim()}
|
||||
>
|
||||
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
|
||||
{([
|
||||
@@ -2670,7 +2680,7 @@ export default function Settings() {
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
或从当前可用模型中选择:
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 120, overflowY: 'auto', border: '1px solid var(--color-border)', padding: 8, borderRadius: 4 }}>
|
||||
<div className="settings-available-models-list">
|
||||
{availableModels.map((model) => {
|
||||
const isAllowed = allowedModels.includes(model);
|
||||
return (
|
||||
@@ -2820,13 +2830,18 @@ export default function Settings() {
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
aria-expanded={showShorthandOptional}
|
||||
aria-controls="settings-shorthand-optional-fields"
|
||||
onClick={() => setShowShorthandOptional((prev) => !prev)}
|
||||
>
|
||||
{showShorthandOptional ? '收起端口/库名' : '展开端口/库名'}
|
||||
</button>
|
||||
</div>
|
||||
{showShorthandOptional && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 10, marginBottom: 8 }}>
|
||||
<div
|
||||
id="settings-shorthand-optional-fields"
|
||||
style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 10, marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
value={shorthandConnection.port}
|
||||
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, port: e.target.value }))}
|
||||
|
||||
@@ -186,7 +186,6 @@ export default function SiteAnnouncements() {
|
||||
onClick={() => load(true)}
|
||||
disabled={refreshing}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
|
||||
</button>
|
||||
@@ -201,14 +200,14 @@ export default function SiteAnnouncements() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="site-announcements-layout" style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 360px) 1fr', gap: 14, alignItems: 'start' }}>
|
||||
<div className="card site-announcements-form-card" style={{ padding: 18 }}>
|
||||
<h3 className="site-announcements-section-title" style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>
|
||||
<div className="site-announcements-layout">
|
||||
<div className="card site-announcements-form-card">
|
||||
<h3 className="site-announcements-section-title">
|
||||
{form.id ? '编辑公告' : '发布公告'}
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>标题</span>
|
||||
<div className="site-announcements-form-grid">
|
||||
<label className="site-announcements-field">
|
||||
<span>标题</span>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
|
||||
@@ -216,8 +215,8 @@ export default function SiteAnnouncements() {
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>公告内容</span>
|
||||
<label className="site-announcements-field">
|
||||
<span>公告内容</span>
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(event) => setForm((current) => ({ ...current, content: event.target.value }))}
|
||||
@@ -226,9 +225,9 @@ export default function SiteAnnouncements() {
|
||||
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>级别</span>
|
||||
<div className="site-announcements-form-split">
|
||||
<label className="site-announcements-field">
|
||||
<span>级别</span>
|
||||
<select
|
||||
value={form.level}
|
||||
onChange={(event) => setForm((current) => ({ ...current, level: event.target.value as AnnouncementLevel }))}
|
||||
@@ -239,8 +238,8 @@ export default function SiteAnnouncements() {
|
||||
<option value="error">紧急</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>状态</span>
|
||||
<label className="site-announcements-field">
|
||||
<span>状态</span>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(event) => setForm((current) => ({ ...current, status: event.target.value as AnnouncementStatus }))}
|
||||
@@ -252,7 +251,7 @@ export default function SiteAnnouncements() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<div className="site-announcements-form-actions">
|
||||
{form.id && (
|
||||
<button type="button" className="btn btn-ghost" onClick={resetForm}>
|
||||
取消编辑
|
||||
@@ -265,29 +264,19 @@ export default function SiteAnnouncements() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card site-announcements-list-card" style={{ padding: 0 }}>
|
||||
<div
|
||||
className="site-announcements-list-head"
|
||||
style={{
|
||||
padding: '14px 18px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>公告列表</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
||||
<div className="card site-announcements-list-card">
|
||||
<div className="site-announcements-list-head">
|
||||
<div>公告列表</div>
|
||||
<div>
|
||||
共 {rows.length} 条,已发布 {publishedCount} 条
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<div className="site-announcements-list-state">
|
||||
<span className="spinner spinner-sm" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div style={{ padding: 32, textAlign: 'center' }}>
|
||||
<div className="site-announcements-list-state">
|
||||
<div className="empty-state-title">暂无公告</div>
|
||||
<div className="empty-state-desc">发布后普通用户可以在用户面板查看。</div>
|
||||
</div>
|
||||
@@ -299,15 +288,12 @@ export default function SiteAnnouncements() {
|
||||
<div
|
||||
key={row.id}
|
||||
className={`site-announcements-row animate-slide-up stagger-${Math.min(index + 1, 5)}`}
|
||||
style={{
|
||||
padding: '16px 18px',
|
||||
borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)',
|
||||
}}
|
||||
data-last={index === rows.length - 1 ? 'true' : undefined}
|
||||
>
|
||||
<div className="site-announcements-row-head" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div>
|
||||
<div style={{ marginTop: 5, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div className="site-announcements-row-head">
|
||||
<div className="site-announcements-row-title-block">
|
||||
<div className="site-announcements-row-title">{row.title}</div>
|
||||
<div className="site-announcements-badges">
|
||||
<span className={`badge ${level === 'error' ? 'badge-error' : level === 'warning' ? 'badge-warning' : 'badge-info'}`}>
|
||||
{levelLabels[level]}
|
||||
</span>
|
||||
@@ -317,7 +303,7 @@ export default function SiteAnnouncements() {
|
||||
<span className="badge badge-muted">已读 {row.readCount || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="site-announcements-row-actions" style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<div className="site-announcements-row-actions">
|
||||
<button type="button" className="btn btn-link btn-link-primary" onClick={() => editRow(row)}>
|
||||
编辑
|
||||
</button>
|
||||
@@ -327,7 +313,7 @@ export default function SiteAnnouncements() {
|
||||
</div>
|
||||
</div>
|
||||
<SiteAnnouncementContent content={row.content} />
|
||||
<div className="site-announcements-meta" style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<div className="site-announcements-meta">
|
||||
发布时间:{row.publishedAt || row.firstSeenAt || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user