Merge pull request #49 from cita-777/codex/mobile-responsive
Codex/mobile responsive
This commit is contained in:
@@ -9,6 +9,7 @@ data/*
|
||||
logs/
|
||||
tmp/*
|
||||
!tmp/.gitkeep
|
||||
.worktrees/
|
||||
|
||||
# Local desktop/debug artifacts
|
||||
/.tmp-*
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('App mobile layout', () => {
|
||||
it('sets data-layout attribute when switching layouts', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('useIsMobile(768)');
|
||||
expect(source).toContain("document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('App mobile sidebar', () => {
|
||||
it('renders hamburger trigger for mobile navigation', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('aria-label="Open navigation"');
|
||||
expect(source).toContain('MobileDrawer');
|
||||
expect(source).toContain('mobile-nav');
|
||||
});
|
||||
});
|
||||
+68
-25
@@ -16,6 +16,8 @@ import { I18nProvider, useI18n } from './i18n.js';
|
||||
import { resolveLoginErrorMessage } from './loginError.js';
|
||||
import { SITE_DOCS_URL } from './docsLink.js';
|
||||
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
|
||||
import { useIsMobile } from './components/useIsMobile.js';
|
||||
import { MobileDrawer } from './components/MobileDrawer.js';
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
|
||||
const Sites = lazy(() => import('./pages/Sites.js'));
|
||||
const Accounts = lazy(() => import('./pages/Accounts.js'));
|
||||
@@ -377,12 +379,14 @@ function AppShell() {
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160);
|
||||
const userMenuPresence = useAnimatedVisibility(showUserMenu, 160);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const notifBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const latestTaskEventIdRef = useRef(0);
|
||||
const toast = useToast();
|
||||
const isMobile = useIsMobile(768);
|
||||
const resolvedTheme: 'light' | 'dark' = themeMode === 'system'
|
||||
? (systemPrefersDark ? 'dark' : 'light')
|
||||
: themeMode;
|
||||
@@ -415,6 +419,10 @@ function AppShell() {
|
||||
}
|
||||
}, [resolvedTheme, themeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
@@ -545,6 +553,17 @@ function AppShell() {
|
||||
return (
|
||||
<>
|
||||
<header className="topbar">
|
||||
{isMobile && (
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
aria-label="Open navigation"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="topbar-logo">
|
||||
<img src="/logo.png" alt="Metapi" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||
<span className="topbar-logo-text">Metapi</span>
|
||||
@@ -667,32 +686,56 @@ function AppShell() {
|
||||
</header>
|
||||
|
||||
<div className="app-layout">
|
||||
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
{sidebarGroups.map((group) => (
|
||||
<div key={group.label} className="sidebar-group">
|
||||
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/' || item.to === '/settings'}
|
||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
||||
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!sidebarCollapsed && <span>{t(item.label)}</span>}
|
||||
</NavLink>
|
||||
{isMobile ? (
|
||||
<MobileDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
<nav className="mobile-nav">
|
||||
{sidebarGroups.map((group) => (
|
||||
<div key={group.label} className="mobile-nav-group">
|
||||
<div className="mobile-nav-label">{t(group.label)}</div>
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/' || item.to === '/settings'}
|
||||
className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{t(item.label)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
|
||||
</button>
|
||||
</aside>
|
||||
</nav>
|
||||
</MobileDrawer>
|
||||
) : (
|
||||
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
{sidebarGroups.map((group) => (
|
||||
<div key={group.label} className="sidebar-group">
|
||||
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/' || item.to === '/settings'}
|
||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
||||
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!sidebarCollapsed && <span>{t(item.label)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className="main-content">
|
||||
<PageTransition>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
type MobileCardProps = {
|
||||
title: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type MobileFieldProps = {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
};
|
||||
|
||||
export function MobileCard({ title, actions, children }: MobileCardProps) {
|
||||
return (
|
||||
<div className="mobile-card">
|
||||
<div className="mobile-card-header">
|
||||
<div className="mobile-card-title">{title}</div>
|
||||
{actions ? <div className="mobile-card-actions">{actions}</div> : null}
|
||||
</div>
|
||||
<div className="mobile-card-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileField({ label, value }: MobileFieldProps) {
|
||||
return (
|
||||
<div className="mobile-field">
|
||||
<div className="mobile-field-label">{label}</div>
|
||||
<div className="mobile-field-value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
type MobileDrawerProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function MobileDrawer({ open, onClose, children }: MobileDrawerProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="mobile-drawer-root">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-drawer-backdrop"
|
||||
onClick={onClose}
|
||||
aria-label="Close navigation"
|
||||
/>
|
||||
<div className="mobile-drawer-panel" role="dialog" aria-modal="true">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MobileDrawer };
|
||||
export default MobileDrawer;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { create } from 'react-test-renderer';
|
||||
import { MobileCard, MobileField } from './MobileCard.js';
|
||||
|
||||
describe('MobileCard', () => {
|
||||
it('renders title and fields', () => {
|
||||
const root = create(
|
||||
<MobileCard title="CardTitle">
|
||||
<MobileField label="Status" value="OK" />
|
||||
</MobileCard>,
|
||||
);
|
||||
|
||||
const text = root.root.findAll(() => true)
|
||||
.flatMap((instance) => instance.children)
|
||||
.filter((child): child is string => typeof child === 'string')
|
||||
.join('');
|
||||
|
||||
expect(text).toContain('CardTitle');
|
||||
expect(text).toContain('Status');
|
||||
expect(text).toContain('OK');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { create } from 'react-test-renderer';
|
||||
import MobileDrawer from './MobileDrawer.js';
|
||||
|
||||
describe('MobileDrawer', () => {
|
||||
it('renders content when open and closes on backdrop click', () => {
|
||||
const onClose = vi.fn();
|
||||
const root = create(
|
||||
<MobileDrawer open onClose={onClose}>
|
||||
<div>DrawerContent</div>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
|
||||
const text = root.root.findAll(() => true)
|
||||
.flatMap((instance) => instance.children)
|
||||
.filter((child): child is string => typeof child === 'string')
|
||||
.join('');
|
||||
|
||||
expect(text).toContain('DrawerContent');
|
||||
|
||||
const backdrop = root.root.find((node) => node.props?.className === 'mobile-drawer-backdrop');
|
||||
backdrop.props.onClick();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(() => (
|
||||
typeof window !== 'undefined' ? window.innerWidth < breakpoint : false
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const update = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@@ -964,6 +964,142 @@ body {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ===== Mobile Card ===== */
|
||||
.mobile-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-light);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.mobile-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mobile-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.mobile-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mobile-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-field {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-field-label {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mobile-field-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mobile-card-extra {
|
||||
border-top: 1px dashed var(--color-border-light);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ===== Mobile Drawer ===== */
|
||||
.mobile-drawer-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 220;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-drawer-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 15, 15, 0.4);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mobile-drawer-panel {
|
||||
position: relative;
|
||||
width: min(82vw, 320px);
|
||||
height: 100%;
|
||||
background: var(--color-bg-card);
|
||||
border-right: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mobile-filter-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mobile-filter-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ===== Mobile Actions Bar ===== */
|
||||
.mobile-actions-bar {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 210;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-light);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.mobile-actions-info {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.mobile-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ===== Stat Card (New API style) ===== */
|
||||
.stat-card {
|
||||
background: var(--color-bg-card);
|
||||
@@ -3486,6 +3622,33 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.mobile-actions-bar .btn {
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Responsive Adjustments ===== */
|
||||
@media (max-width: 900px) {
|
||||
.topbar-search-trigger {
|
||||
|
||||
+227
-2
@@ -4,6 +4,8 @@ import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
@@ -76,6 +78,8 @@ export default function Accounts() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
|
||||
const [expandedAccountIds, setExpandedAccountIds] = useState<number[]>([]);
|
||||
const isMobile = useIsMobile(768);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||
const [loginForm, setLoginForm] = useState(createLoginForm);
|
||||
@@ -538,6 +542,14 @@ export default function Accounts() {
|
||||
setSelectedAccountIds(visibleAccounts.map((account) => account.id));
|
||||
};
|
||||
|
||||
const toggleAccountDetails = (accountId: number) => {
|
||||
setExpandedAccountIds((current) => (
|
||||
current.includes(accountId)
|
||||
? current.filter((id) => id !== accountId)
|
||||
: [...current, accountId]
|
||||
));
|
||||
};
|
||||
|
||||
const runBatchAccountAction = async (action: 'enable' | 'disable' | 'delete' | 'refreshBalance') => {
|
||||
if (selectedAccountIds.length === 0) return;
|
||||
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedAccountIds.length} 个账号?`)) return;
|
||||
@@ -783,7 +795,7 @@ export default function Accounts() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
|
||||
{!isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedAccountIds.length} 项</span>
|
||||
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
@@ -801,6 +813,26 @@ export default function Accounts() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
|
||||
<div className="mobile-actions-bar">
|
||||
<span className="mobile-actions-info">已选 {selectedAccountIds.length} 项</span>
|
||||
<div className="mobile-actions-row">
|
||||
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量刷新余额
|
||||
</button>
|
||||
<button onClick={() => runBatchAccountAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量启用
|
||||
</button>
|
||||
<button onClick={() => runBatchAccountAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量禁用
|
||||
</button>
|
||||
<button onClick={() => runBatchAccountAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSegment === 'tokens' ? (
|
||||
<TokensPanel embedded onEmbeddedActionsChange={setEmbeddedTokenActions} />
|
||||
) : (
|
||||
@@ -1303,7 +1335,199 @@ export default function Accounts() {
|
||||
|
||||
<div className="card">
|
||||
{visibleAccounts.length > 0 ? (
|
||||
<table className="data-table accounts-table">
|
||||
isMobile ? (
|
||||
<div className="mobile-card-list">
|
||||
{visibleAccounts.map((a: any) => {
|
||||
const capabilities = resolveAccountCapabilities(a);
|
||||
const connectionMode = resolveAccountCredentialMode(a);
|
||||
const health = resolveRuntimeHealth(a);
|
||||
const isExpanded = expandedAccountIds.includes(a.id);
|
||||
const hintMessage = (a.status === 'expired' && !capabilities.proxyOnly)
|
||||
? '账号已过期,请重新绑定'
|
||||
: (health.reason || '-');
|
||||
return (
|
||||
<MobileCard
|
||||
key={a.id}
|
||||
title={resolveAccountDisplayName(a)}
|
||||
actions={(
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择账号 ${resolveAccountDisplayName(a)}`}
|
||||
checked={selectedAccountIds.includes(a.id)}
|
||||
onChange={(event) => toggleAccountSelection(a.id, event.target.checked)}
|
||||
/>
|
||||
<span className={`badge ${connectionMode === 'apikey' ? 'badge-warning' : 'badge-info'}`} style={{ fontSize: 10 }}>
|
||||
{connectionMode === 'apikey' ? 'API Key' : 'Session'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<MobileField
|
||||
label="余额"
|
||||
value={(
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>${(a.balance || 0).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, color: (a.todayReward || 0) > 0 ? 'var(--color-success)' : 'var(--color-text-muted)', fontWeight: 500 }}>
|
||||
+{(a.todayReward || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<MobileField
|
||||
label="已用"
|
||||
value={(
|
||||
<div>
|
||||
<div>${(a.balanceUsed || 0).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, color: (a.todaySpend || 0) > 0 ? 'var(--color-danger)' : 'var(--color-text-muted)', fontWeight: 500 }}>
|
||||
-{(a.todaySpend || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
<MobileField
|
||||
label="站点"
|
||||
value={a.site?.url ? (
|
||||
<a
|
||||
href={a.site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="badge-link"
|
||||
>
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{a.site?.name || '-'}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{a.site?.name || '-'}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField
|
||||
label="签到"
|
||||
value={capabilities.canCheckin ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`checkin-toggle-badge ${a.checkinEnabled ? 'is-on' : 'is-off'}`}
|
||||
onClick={() => handleToggleCheckin(a)}
|
||||
disabled={!!actionLoading[`checkin-toggle-${a.id}`]}
|
||||
data-tooltip={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
|
||||
aria-label={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
|
||||
>
|
||||
{actionLoading[`checkin-toggle-${a.id}`]
|
||||
? <span className="spinner spinner-sm" />
|
||||
: (a.checkinEnabled ? '开启' : '关闭')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
不支持
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField
|
||||
label="账号状态"
|
||||
value={a.status === 'expired' ? '已过期' : (a.status || '-')}
|
||||
/>
|
||||
<MobileField
|
||||
label="提示"
|
||||
value={hintMessage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAccountDetails(a.id)}
|
||||
className="btn btn-link"
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTogglePin(a)}
|
||||
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
|
||||
className={`btn btn-link ${a.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{actionLoading[`pin-toggle-${a.id}`] ? <span className="spinner spinner-sm" /> : (a.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'up')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'down')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{capabilities.canRefreshBalance && (
|
||||
<button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]} className="btn btn-link btn-link-primary">
|
||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
||||
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
||||
</button>
|
||||
{capabilities.canCheckin && (
|
||||
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
|
||||
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'expired' && !capabilities.proxyOnly && (
|
||||
<button
|
||||
onClick={() => openRebindPanel(a)}
|
||||
className="btn btn-link btn-link-warning"
|
||||
>
|
||||
重新绑定
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
|
||||
编辑
|
||||
</button>
|
||||
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
|
||||
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table accounts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}>
|
||||
@@ -1490,6 +1714,7 @@ export default function Accounts() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
|
||||
+113
-10
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { formatCheckinLogTime } from './helpers/checkinLogTime.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
@@ -19,6 +22,9 @@ export default function CheckinLog() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [triggering, setTriggering] = useState(false);
|
||||
const [filter, setFilter] = useState<LogFilter>('all');
|
||||
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const isMobile = useIsMobile(768);
|
||||
const toast = useToast();
|
||||
|
||||
const load = async () => {
|
||||
@@ -82,6 +88,19 @@ export default function CheckinLog() {
|
||||
return reason;
|
||||
};
|
||||
|
||||
const filterTabs = (
|
||||
<div className="tabs" style={{ marginBottom: 0 }}>
|
||||
{(['all', 'success', 'failed', 'skipped'] as const).map((tab) => (
|
||||
<button key={tab} className={`tab ${filter === tab ? 'active' : ''}`} onClick={() => setFilter(tab)}>
|
||||
{tab === 'all' && `全部 (${logs.length})`}
|
||||
{tab === 'success' && `成功 (${countBy('success')})`}
|
||||
{tab === 'failed' && `失败 (${countBy('failed')})`}
|
||||
{tab === 'skipped' && `跳过 (${countBy('skipped')})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="page-header">
|
||||
@@ -102,16 +121,27 @@ export default function CheckinLog() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tabs" style={{ marginBottom: 0 }}>
|
||||
{(['all', 'success', 'failed', 'skipped'] as const).map((tab) => (
|
||||
<button key={tab} className={`tab ${filter === tab ? 'active' : ''}`} onClick={() => setFilter(tab)}>
|
||||
{tab === 'all' && `全部 (${logs.length})`}
|
||||
{tab === 'success' && `成功 (${countBy('success')})`}
|
||||
{tab === 'failed' && `失败 (${countBy('failed')})`}
|
||||
{tab === 'skipped' && `跳过 (${countBy('skipped')})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<div className="mobile-filter-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={() => setShowFilters(true)}
|
||||
>
|
||||
筛选
|
||||
</button>
|
||||
</div>
|
||||
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
|
||||
<div className="mobile-filter-panel">
|
||||
{filterTabs}
|
||||
</div>
|
||||
</MobileDrawer>
|
||||
</>
|
||||
) : (
|
||||
filterTabs
|
||||
)}
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto', borderTopLeftRadius: 0, borderTopRightRadius: 0 }}>
|
||||
{loading ? (
|
||||
@@ -127,6 +157,79 @@ export default function CheckinLog() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="mobile-card-list">
|
||||
{filtered.map((log: any) => {
|
||||
const status = getStatus(log);
|
||||
const reason = getFailureReason(log);
|
||||
const isExpanded = expandedLogId === (log.checkin_logs?.id || log.id);
|
||||
const logId = log.checkin_logs?.id || log.id;
|
||||
return (
|
||||
<MobileCard
|
||||
key={logId}
|
||||
title={log.accounts?.username || '未知'}
|
||||
actions={(
|
||||
<span className={`badge ${statusClass(status)}`} style={{ fontSize: 10 }}>
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<MobileField label="时间" value={formatCheckinLogTime(log.checkin_logs?.createdAt || log.createdAt)} />
|
||||
<MobileField
|
||||
label="站点"
|
||||
value={log.sites?.url ? (
|
||||
<a
|
||||
href={log.sites.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="badge-link"
|
||||
>
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{log.sites?.name || '-'}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{log.sites?.name || '-'}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField
|
||||
label="分类"
|
||||
value={reason ? (
|
||||
<span className="badge badge-info" data-tooltip={reason.detailHint}>
|
||||
{reason.title}
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-muted">-</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField label="奖励" value={log.checkin_logs?.reward || '-'} />
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
<MobileField
|
||||
label="信息"
|
||||
value={log.checkin_logs?.message || log.message}
|
||||
/>
|
||||
<MobileField
|
||||
label="建议"
|
||||
value={reason?.actionHint || '-'}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => setExpandedLogId(isExpanded ? null : logId)}
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
</div>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
|
||||
+113
-22
@@ -9,6 +9,9 @@ import {
|
||||
} from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { ModelBadge } from '../components/BrandIcon.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js';
|
||||
@@ -123,6 +126,8 @@ export default function ProxyLogs() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const isMobile = useIsMobile(768);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -214,6 +219,35 @@ export default function ProxyLogs() {
|
||||
}
|
||||
}, [expanded, loadDetail]);
|
||||
|
||||
const filterControls = (
|
||||
<>
|
||||
<div className="pill-tabs">
|
||||
{([
|
||||
{ key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount },
|
||||
{ key: 'success' as ProxyLogStatusFilter, label: '成功', count: summary.successCount },
|
||||
{ key: 'failed' as ProxyLogStatusFilter, label: '失败', count: summary.failedCount },
|
||||
]).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`pill-tab ${statusFilter === tab.key ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setStatusFilter(tab.key);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{tab.label} <span style={{ fontVariantNumeric: 'tabular-nums', opacity: 0.7 }}>{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="toolbar-search" style={{ maxWidth: 280 }}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||
@@ -234,32 +268,29 @@ export default function ProxyLogs() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar" style={{ marginBottom: 12 }}>
|
||||
<div className="pill-tabs">
|
||||
{([
|
||||
{ key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount },
|
||||
{ key: 'success' as ProxyLogStatusFilter, label: '成功', count: summary.successCount },
|
||||
{ key: 'failed' as ProxyLogStatusFilter, label: '失败', count: summary.failedCount },
|
||||
]).map((tab) => (
|
||||
{isMobile ? (
|
||||
<>
|
||||
<div className="mobile-filter-row">
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`pill-tab ${statusFilter === tab.key ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setStatusFilter(tab.key);
|
||||
setPage(1);
|
||||
}}
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={() => setShowFilters(true)}
|
||||
>
|
||||
{tab.label} <span style={{ fontVariantNumeric: 'tabular-nums', opacity: 0.7 }}>{tab.count}</span>
|
||||
筛选
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
|
||||
<div className="mobile-filter-panel">
|
||||
{filterControls}
|
||||
</div>
|
||||
</MobileDrawer>
|
||||
</>
|
||||
) : (
|
||||
<div className="toolbar" style={{ marginBottom: 12 }}>
|
||||
{filterControls}
|
||||
</div>
|
||||
<div className="toolbar-search" style={{ maxWidth: 280 }}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{loading ? (
|
||||
@@ -275,6 +306,66 @@ export default function ProxyLogs() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="mobile-card-list">
|
||||
{logs.map((log) => {
|
||||
const detailState = detailById[log.id];
|
||||
const detail = detailState?.data;
|
||||
const detailLog: ProxyLogRenderItem = detail ? { ...log, ...detail } : log;
|
||||
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
|
||||
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
|
||||
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
|
||||
const isExpanded = expanded === log.id;
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
key={log.id}
|
||||
title={detailLog.modelRequested || 'unknown'}
|
||||
actions={(
|
||||
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 10 }}>
|
||||
{log.status === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
||||
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
|
||||
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
|
||||
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
|
||||
<MobileField
|
||||
label="花费"
|
||||
value={typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
<MobileField label="重试" value={log.retryCount > 0 ? log.retryCount : 0} />
|
||||
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
|
||||
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
|
||||
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
|
||||
{billingProcessLines.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{billingProcessLines.map((line, index) => (
|
||||
<span key={`${log.id}-billing-mobile-${index}`}>{line}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail && pathMeta.errorMessage.trim().length > 0 && (
|
||||
<div style={{ color: 'var(--color-danger)' }}>{pathMeta.errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => handleToggleExpand(log.id)}
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
</div>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
|
||||
+180
-2
@@ -3,7 +3,9 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
||||
import { tr } from '../i18n.js';
|
||||
@@ -78,6 +80,8 @@ export default function Sites() {
|
||||
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
|
||||
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
||||
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
|
||||
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
|
||||
const isMobile = useIsMobile(768);
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||
const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
|
||||
const lastEditorRef = useRef<SiteEditorState | null>(null);
|
||||
@@ -327,6 +331,14 @@ export default function Sites() {
|
||||
setSelectedSiteIds(sortedSites.map((site) => site.id));
|
||||
};
|
||||
|
||||
const toggleSiteDetails = (siteId: number) => {
|
||||
setExpandedSiteIds((current) => (
|
||||
current.includes(siteId)
|
||||
? current.filter((id) => id !== siteId)
|
||||
: [...current, siteId]
|
||||
));
|
||||
};
|
||||
|
||||
const runBatchAction = async (action: 'enable' | 'disable' | 'delete' | 'enableSystemProxy' | 'disableSystemProxy') => {
|
||||
if (selectedSiteIds.length === 0) return;
|
||||
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedSiteIds.length} 个站点?`)) return;
|
||||
@@ -383,7 +395,7 @@ export default function Sites() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSiteIds.length > 0 && (
|
||||
{!isMobile && selectedSiteIds.length > 0 && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedSiteIds.length} 项</span>
|
||||
<button
|
||||
@@ -415,6 +427,40 @@ export default function Sites() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && selectedSiteIds.length > 0 && (
|
||||
<div className="mobile-actions-bar">
|
||||
<span className="mobile-actions-info">已选 {selectedSiteIds.length} 项</span>
|
||||
<div className="mobile-actions-row">
|
||||
<button
|
||||
data-testid="sites-batch-enable-system-proxy"
|
||||
onClick={() => runBatchAction('enableSystemProxy')}
|
||||
disabled={batchActionLoading}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
批量开启系统代理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runBatchAction('disableSystemProxy')}
|
||||
disabled={batchActionLoading}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
批量关闭系统代理
|
||||
</button>
|
||||
<button onClick={() => runBatchAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量启用
|
||||
</button>
|
||||
<button onClick={() => runBatchAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量禁用
|
||||
</button>
|
||||
<button onClick={() => runBatchAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
||||
</div>
|
||||
@@ -555,7 +601,138 @@ export default function Sites() {
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{sites.length > 0 ? (
|
||||
<table className="data-table sites-table">
|
||||
isMobile ? (
|
||||
<div className="mobile-card-list">
|
||||
{sortedSites.map((site) => {
|
||||
const isExpanded = expandedSiteIds.includes(site.id);
|
||||
return (
|
||||
<MobileCard
|
||||
key={site.id}
|
||||
title={site.name || '-'}
|
||||
actions={(
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择站点 ${site.name || site.id}`}
|
||||
checked={selectedSiteIds.includes(site.id)}
|
||||
onChange={(event) => toggleSiteSelection(site.id, event.target.checked)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<MobileField
|
||||
label="状态"
|
||||
value={(
|
||||
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
|
||||
{site.status === 'disabled' ? '禁用' : '启用'}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField
|
||||
label="平台"
|
||||
value={(
|
||||
<span className={`badge ${platformColors[site.platform || ''] || 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
{site.platform || '-'}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField label="余额" value={`$${(site.totalBalance || 0).toFixed(2)}`} />
|
||||
<MobileField label="权重" value={(site.globalWeight || 1).toFixed(2)} />
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
<MobileField
|
||||
label="系统代理"
|
||||
value={(
|
||||
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
{site.useSystemProxy ? '已开启' : '未开启'}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField
|
||||
label="外部签到站URL"
|
||||
value={site.externalCheckinUrl ? (
|
||||
<a
|
||||
href={site.externalCheckinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sites-url-link"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-primary)',
|
||||
textDecoration: 'underline',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{site.externalCheckinUrl}
|
||||
</a>
|
||||
) : '-'}
|
||||
/>
|
||||
<MobileField
|
||||
label="创建时间"
|
||||
value={formatDateTimeLocal(site.createdAt)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSiteDetails(site.id)}
|
||||
className="btn btn-link"
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTogglePin(site)}
|
||||
disabled={pinningSiteId === site.id}
|
||||
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'up')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'down')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEdit(site)}
|
||||
className="btn btn-link btn-link-primary"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleStatus(site)}
|
||||
disabled={togglingSiteId === site.id}
|
||||
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
|
||||
>
|
||||
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(site)}
|
||||
disabled={deleting === site.id}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table sites-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}>
|
||||
@@ -720,6 +897,7 @@ export default function Sites() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
+276
-202
@@ -21,7 +21,10 @@ import { api } from '../api.js';
|
||||
import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey, type BrandInfo } from '../components/BrandIcon.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import {
|
||||
buildRouteModelCandidatesIndex,
|
||||
@@ -708,6 +711,7 @@ export default function TokenRoutes() {
|
||||
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
|
||||
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
|
||||
const [filterCollapsed, setFilterCollapsed] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
|
||||
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
|
||||
|
||||
@@ -729,6 +733,8 @@ export default function TokenRoutes() {
|
||||
const [decisionAutoSkipped, setDecisionAutoSkipped] = useState(false);
|
||||
const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK);
|
||||
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
|
||||
const [expandedRouteIds, setExpandedRouteIds] = useState<number[]>([]);
|
||||
const isMobile = useIsMobile(768);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -743,6 +749,217 @@ export default function TokenRoutes() {
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const openFilters = () => {
|
||||
setFilterCollapsed(false);
|
||||
setShowFilters(true);
|
||||
};
|
||||
|
||||
const closeFilters = () => {
|
||||
setFilterCollapsed(true);
|
||||
setShowFilters(false);
|
||||
};
|
||||
|
||||
const renderFilterPanelContent = () => (
|
||||
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
品牌
|
||||
{activeBrand && <button onClick={() => setActiveBrand(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div className={`filter-item ${!activeBrand ? 'active' : ''}`} onClick={() => setActiveBrand(null)}>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
✦
|
||||
</span>
|
||||
全部品牌
|
||||
<span className="filter-item-count">{routes.length}</span>
|
||||
</div>
|
||||
|
||||
{brandList.list.map(([brandName, { count, brand }]) => (
|
||||
<div
|
||||
key={brandName}
|
||||
className={`filter-item ${activeBrand === brandName ? 'active' : ''}`}
|
||||
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' }}>
|
||||
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{brandList.otherCount > 0 && (
|
||||
<div
|
||||
className={`filter-item ${activeBrand === '__other__' ? 'active' : ''}`}
|
||||
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10 }}
|
||||
>
|
||||
?
|
||||
</span>
|
||||
其他
|
||||
<span className="filter-item-count">{brandList.otherCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('群组')}
|
||||
{activeGroupFilter !== null && <button onClick={() => setActiveGroupFilter(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`filter-item ${activeGroupFilter === '__all__' ? 'active' : ''}`}
|
||||
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
◎
|
||||
</span>
|
||||
{tr('全部群组')}
|
||||
<span className="filter-item-count">{groupRouteList.length}</span>
|
||||
</div>
|
||||
|
||||
{groupRouteList.map((groupRoute) => (
|
||||
<div
|
||||
key={groupRoute.id}
|
||||
className={`filter-item ${activeGroupFilter === groupRoute.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{groupRoute.icon.kind === 'brand' ? (
|
||||
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
||||
) : groupRoute.icon.kind === 'text' ? (
|
||||
<span style={{ fontSize: 12, lineHeight: 1 }}>{groupRoute.icon.value}</span>
|
||||
) : groupRoute.brand ? (
|
||||
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
||||
) : (
|
||||
<InlineBrandIcon model={groupRoute.modelPattern} size={14} />
|
||||
)}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{groupRoute.title}</span>
|
||||
<span className="filter-item-count">{groupRoute.channelCount}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{siteList.length > 0 && (
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
站点
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div className={`filter-item ${!activeSite ? 'active' : ''}`} onClick={() => setActiveSite(null)}>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
⚡
|
||||
</span>
|
||||
全部站点
|
||||
<span className="filter-item-count">{routes.length}</span>
|
||||
</div>
|
||||
|
||||
{siteList.map(([siteName, { count }]) => (
|
||||
<div
|
||||
key={siteName}
|
||||
className={`filter-item ${activeSite === siteName ? 'active' : ''}`}
|
||||
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: hashColor(siteName), color: 'white', fontSize: 9, borderRadius: 4 }}
|
||||
>
|
||||
{siteAvatarLetters(siteName)}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{siteName}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
接口能力
|
||||
{activeEndpointType && <button onClick={() => setActiveEndpointType(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div className={`filter-item ${!activeEndpointType ? 'active' : ''}`} onClick={() => setActiveEndpointType(null)}>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
⚙
|
||||
</span>
|
||||
全部能力
|
||||
<span className="filter-item-count">{routes.length}</span>
|
||||
</div>
|
||||
|
||||
{endpointTypeList.map(([endpointType, count]) => (
|
||||
<div
|
||||
key={endpointType}
|
||||
className={`filter-item ${activeEndpointType === endpointType ? 'active' : ''}`}
|
||||
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 10,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const iconModel = resolveEndpointTypeIconModel(endpointType);
|
||||
if (!iconModel) return <span style={{ fontSize: 10 }}>⚙</span>;
|
||||
return <InlineBrandIcon model={iconModel} size={14} />;
|
||||
})()}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{endpointType}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{endpointTypeList.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '4px 10px 0' }}>
|
||||
暂无接口能力数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: 12,
|
||||
padding: '6px 10px',
|
||||
marginTop: 8,
|
||||
justifyContent: 'center',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
onClick={closeFilters}
|
||||
>
|
||||
{tr('收起')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const loadRouteDecisions = async (
|
||||
routeRows: RouteRow[],
|
||||
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
|
||||
@@ -1401,205 +1618,13 @@ export default function TokenRoutes() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||
{filterPanelPresence.shouldRender && (
|
||||
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
品牌
|
||||
{activeBrand && <button onClick={() => setActiveBrand(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div className={`filter-item ${!activeBrand ? 'active' : ''}`} onClick={() => setActiveBrand(null)}>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
✦
|
||||
</span>
|
||||
全部品牌
|
||||
<span className="filter-item-count">{routes.length}</span>
|
||||
</div>
|
||||
|
||||
{brandList.list.map(([brandName, { count, brand }]) => (
|
||||
<div
|
||||
key={brandName}
|
||||
className={`filter-item ${activeBrand === brandName ? 'active' : ''}`}
|
||||
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' }}>
|
||||
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{brandList.otherCount > 0 && (
|
||||
<div
|
||||
className={`filter-item ${activeBrand === '__other__' ? 'active' : ''}`}
|
||||
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10 }}
|
||||
>
|
||||
?
|
||||
</span>
|
||||
其他
|
||||
<span className="filter-item-count">{brandList.otherCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && filterPanelPresence.shouldRender && renderFilterPanelContent()}
|
||||
{isMobile && (
|
||||
<MobileDrawer open={showFilters} onClose={closeFilters}>
|
||||
<div className="mobile-filter-panel">
|
||||
{filterPanelPresence.shouldRender && renderFilterPanelContent()}
|
||||
</div>
|
||||
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('群组')}
|
||||
{activeGroupFilter !== null && <button onClick={() => setActiveGroupFilter(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`filter-item ${activeGroupFilter === '__all__' ? 'active' : ''}`}
|
||||
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
◎
|
||||
</span>
|
||||
{tr('全部群组')}
|
||||
<span className="filter-item-count">{groupRouteList.length}</span>
|
||||
</div>
|
||||
|
||||
{groupRouteList.map((groupRoute) => (
|
||||
<div
|
||||
key={groupRoute.id}
|
||||
className={`filter-item ${activeGroupFilter === groupRoute.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{groupRoute.icon.kind === 'brand' ? (
|
||||
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
||||
) : groupRoute.icon.kind === 'text' ? (
|
||||
<span style={{ fontSize: 12, lineHeight: 1 }}>{groupRoute.icon.value}</span>
|
||||
) : groupRoute.brand ? (
|
||||
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
||||
) : (
|
||||
<InlineBrandIcon model={groupRoute.modelPattern} size={14} />
|
||||
)}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{groupRoute.title}</span>
|
||||
<span className="filter-item-count">{groupRoute.channelCount}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{siteList.length > 0 && (
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
站点
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div className={`filter-item ${!activeSite ? 'active' : ''}`} onClick={() => setActiveSite(null)}>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
⚡
|
||||
</span>
|
||||
全部站点
|
||||
<span className="filter-item-count">{routes.length}</span>
|
||||
</div>
|
||||
|
||||
{siteList.map(([siteName, { count }]) => (
|
||||
<div
|
||||
key={siteName}
|
||||
className={`filter-item ${activeSite === siteName ? 'active' : ''}`}
|
||||
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: hashColor(siteName), color: 'white', fontSize: 9, borderRadius: 4 }}
|
||||
>
|
||||
{siteAvatarLetters(siteName)}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{siteName}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
接口能力
|
||||
{activeEndpointType && <button onClick={() => setActiveEndpointType(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div className={`filter-item ${!activeEndpointType ? 'active' : ''}`} onClick={() => setActiveEndpointType(null)}>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
⚙
|
||||
</span>
|
||||
全部能力
|
||||
<span className="filter-item-count">{routes.length}</span>
|
||||
</div>
|
||||
|
||||
{endpointTypeList.map(([endpointType, count]) => (
|
||||
<div
|
||||
key={endpointType}
|
||||
className={`filter-item ${activeEndpointType === endpointType ? 'active' : ''}`}
|
||||
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 10,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const iconModel = resolveEndpointTypeIconModel(endpointType);
|
||||
if (!iconModel) return <span style={{ fontSize: 10 }}>⚙</span>;
|
||||
return <InlineBrandIcon model={iconModel} size={14} />;
|
||||
})()}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{endpointType}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{endpointTypeList.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '4px 10px 0' }}>
|
||||
暂无接口能力数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: 12,
|
||||
padding: '6px 10px',
|
||||
marginTop: 8,
|
||||
justifyContent: 'center',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
onClick={() => setFilterCollapsed(true)}
|
||||
>
|
||||
{tr('收起')}
|
||||
</button>
|
||||
</div>
|
||||
</MobileDrawer>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -1627,7 +1652,15 @@ export default function TokenRoutes() {
|
||||
</div>
|
||||
|
||||
<div className="page-actions" style={{ flexWrap: 'wrap' }}>
|
||||
{filterCollapsed && (
|
||||
{isMobile ? (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
onClick={openFilters}
|
||||
>
|
||||
{tr('筛选')}
|
||||
</button>
|
||||
) : (filterCollapsed && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
@@ -1635,7 +1668,7 @@ export default function TokenRoutes() {
|
||||
>
|
||||
{tr('筛选')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleRefreshRouteDecisions}
|
||||
@@ -1878,7 +1911,10 @@ export default function TokenRoutes() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div
|
||||
className={isMobile ? 'mobile-card-list' : ''}
|
||||
style={isMobile ? undefined : { display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
>
|
||||
{visibleRoutes.map((route, i) => {
|
||||
const candidateView = getRouteCandidateView(route.id);
|
||||
const routeCandidates = candidateView.routeCandidates;
|
||||
@@ -1949,7 +1985,7 @@ export default function TokenRoutes() {
|
||||
const routeIcon = resolveRouteIcon(route);
|
||||
const routeBrand = routeBrandById.get(route.id) || null;
|
||||
|
||||
return (
|
||||
const routeCard = (
|
||||
<div
|
||||
key={route.id}
|
||||
className={`card animate-slide-up stagger-${Math.min(i + 1, 5)}`}
|
||||
@@ -2327,6 +2363,44 @@ export default function TokenRoutes() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const isExpanded = expandedRouteIds.includes(route.id);
|
||||
|
||||
return isMobile ? (
|
||||
<MobileCard
|
||||
key={route.id}
|
||||
title={resolveRouteTitle(route)}
|
||||
actions={(
|
||||
<span className={`badge ${route.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 10 }}>
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<MobileField label="模型" value={route.modelPattern} />
|
||||
<MobileField label="通道" value={route.channels?.length || 0} />
|
||||
<MobileField label="状态" value={route.enabled ? tr('启用') : tr('禁用')} />
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
{routeCard}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => setExpandedRouteIds((prev) => (
|
||||
prev.includes(route.id)
|
||||
? prev.filter((id) => id !== route.id)
|
||||
: [...prev, route.id]
|
||||
))}
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
</div>
|
||||
</MobileCard>
|
||||
) : (
|
||||
routeCard
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
|
||||
|
||||
+133
-2
@@ -5,6 +5,8 @@ import CenteredModal from '../components/CenteredModal.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js';
|
||||
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
import { tr } from '../i18n.js';
|
||||
@@ -105,6 +107,7 @@ function isTruthyFlag(input: string | null): boolean {
|
||||
export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile(768);
|
||||
const initialCreateForm = {
|
||||
accountId: 0,
|
||||
name: '',
|
||||
@@ -726,7 +729,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
) : null}
|
||||
</CenteredModal>
|
||||
|
||||
{selectedTokenIds.length > 0 && (
|
||||
{!isMobile && selectedTokenIds.length > 0 && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedTokenIds.length} 项</span>
|
||||
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
@@ -741,6 +744,23 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && selectedTokenIds.length > 0 && (
|
||||
<div className="mobile-actions-bar">
|
||||
<span className="mobile-actions-info">已选 {selectedTokenIds.length} 项</span>
|
||||
<div className="mobile-actions-row">
|
||||
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量启用
|
||||
</button>
|
||||
<button onClick={() => runBatchTokenAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量禁用
|
||||
</button>
|
||||
<button data-testid="tokens-batch-delete" onClick={() => runBatchTokenAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CenteredModal
|
||||
open={showAdd}
|
||||
onClose={handleToggleAdd}
|
||||
@@ -868,7 +888,117 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
<div className="skeleton" style={{ width: '100%', height: 34 }} />
|
||||
</div>
|
||||
) : tokens.length > 0 ? (
|
||||
<table className="data-table token-table">
|
||||
isMobile ? (
|
||||
<div className="mobile-card-list">
|
||||
{tokens.map((token: any) => {
|
||||
const loadingPrefix = `token-${token.id}`;
|
||||
return (
|
||||
<MobileCard
|
||||
key={token.id}
|
||||
title={token.name || '-'}
|
||||
actions={(
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择令牌 ${token.name || token.id}`}
|
||||
checked={selectedTokenIds.includes(token.id)}
|
||||
onChange={(event) => toggleTokenSelection(token.id, event.target.checked)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<MobileField
|
||||
label="令牌值"
|
||||
value={<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{token.tokenMasked || '***'}</span>}
|
||||
/>
|
||||
<MobileField
|
||||
label="来源站点"
|
||||
value={token.site?.url ? (
|
||||
<a
|
||||
href={token.site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="badge-link"
|
||||
>
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{token.site?.name || 'unknown'}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{token.site?.name || 'unknown'}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField label="账号" value={token.account?.username || `account-${token.accountId}`} />
|
||||
<MobileField
|
||||
label="状态"
|
||||
value={(
|
||||
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
{token.enabled ? '启用' : '禁用'}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField
|
||||
label="默认"
|
||||
value={token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}>默认</span> : '-'}
|
||||
/>
|
||||
<MobileField label="更新时间" value={formatDateTimeLocal(token.updatedAt)} />
|
||||
<div className="mobile-card-actions">
|
||||
{!token.isDefault && (
|
||||
<button
|
||||
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
|
||||
await api.setDefaultAccountToken(token.id);
|
||||
toast.success('默认令牌已更新');
|
||||
await load();
|
||||
})}
|
||||
disabled={!!rowLoading[`${loadingPrefix}-default`]}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCopyToken(token.id, token.name || '')}
|
||||
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
|
||||
className="btn btn-link btn-link-primary"
|
||||
data-testid={`token-copy-${token.id}`}
|
||||
>
|
||||
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditPanel(token)}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
|
||||
await api.updateAccountToken(token.id, { enabled: !token.enabled });
|
||||
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
|
||||
await load();
|
||||
})}
|
||||
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
|
||||
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => withRowLoading(`${loadingPrefix}-delete`, async () => {
|
||||
await api.deleteAccountToken(token.id);
|
||||
toast.success('令牌已删除');
|
||||
await load();
|
||||
})}
|
||||
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
{rowLoading[`${loadingPrefix}-delete`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table token-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}>
|
||||
@@ -998,6 +1128,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="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" /></svg>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Accounts mobile layout', () => {
|
||||
it('includes hints field in mobile expanded section', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Accounts.tsx'), 'utf8');
|
||||
expect(source).toContain('label="提示"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('ProxyLogs mobile layout', () => {
|
||||
it('renders mobile cards for proxy logs', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProxyLogs.tsx'), 'utf8');
|
||||
expect(source).toContain('MobileCard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Mobile actions bar styles', () => {
|
||||
it('defines .mobile-actions-bar in index.css', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
expect(css).toContain('.mobile-actions-bar');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Sites mobile layout', () => {
|
||||
it('includes mobile-card usage in Sites page', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Sites.tsx'), 'utf8');
|
||||
expect(source).toContain('mobile-card');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user