1
0
forked from boos/BoosAPI

feat: convert logs and routes pages to mobile cards

This commit is contained in:
cita
2026-03-10 15:48:10 +08:00
parent b8ebf98b68
commit f01379f7a3
4 changed files with 197 additions and 2 deletions
+77
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
import { MobileCard, MobileField } from '../components/MobileCard.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 +21,8 @@ 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 isMobile = useIsMobile(768);
const toast = useToast();
const load = async () => {
@@ -127,6 +131,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>
+63
View File
@@ -9,6 +9,8 @@ import {
} from '../api.js';
import { useToast } from '../components/Toast.js';
import { ModelBadge } from '../components/BrandIcon.js';
import { MobileCard, MobileField } from '../components/MobileCard.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 +125,7 @@ export default function ProxyLogs() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
const isMobile = useIsMobile(768);
const toast = useToast();
useEffect(() => {
@@ -275,6 +278,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>
+47 -2
View File
@@ -21,7 +21,9 @@ 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 { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js';
import {
buildRouteModelCandidatesIndex,
@@ -729,6 +731,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, {
@@ -1878,7 +1882,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 +1956,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 +2334,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 && (
+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');
});
});