feat: convert core pages to mobile cards

This commit is contained in:
cita
2026-03-10 15:27:28 +08:00
parent 37e325d3cf
commit b23c5ece83
4 changed files with 469 additions and 3 deletions
+198
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;
@@ -1303,6 +1315,191 @@ export default function Accounts() {
<div className="card">
{visibleAccounts.length > 0 ? (
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);
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 || '-')}
/>
</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>
@@ -1490,6 +1687,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>
+144
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;
@@ -555,6 +567,137 @@ export default function Sites() {
<div className="card" style={{ overflowX: 'auto' }}>
{sites.length > 0 ? (
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>
@@ -720,6 +863,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">
+114
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: '',
@@ -868,6 +871,116 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<div className="skeleton" style={{ width: '100%', height: 34 }} />
</div>
) : tokens.length > 0 ? (
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>
@@ -998,6 +1111,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('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');
});
});