Merge pull request #49 from cita-777/codex/mobile-responsive

Codex/mobile responsive
This commit is contained in:
Cita
2026-03-10 16:29:52 +08:00
committed by GitHub
20 changed files with 1464 additions and 265 deletions
+1
View File
@@ -9,6 +9,7 @@ data/*
logs/
tmp/*
!tmp/.gitkeep
.worktrees/
# Local desktop/debug artifacts
/.tmp-*
+12
View File
@@ -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');");
});
});
+13
View File
@@ -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
View File
@@ -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>
+33
View File
@@ -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>
);
}
+28
View File
@@ -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;
+22
View File
@@ -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');
});
});
+25
View File
@@ -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);
});
});
+17
View File
@@ -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;
}
+163
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+10
View File
@@ -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="提示"');
});
});
+10
View File
@@ -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');
});
});
+10
View File
@@ -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');
});
});
+10
View File
@@ -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');
});
});