forked from boos/BoosAPI
feat: convert logs and routes pages to mobile cards
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { formatCheckinLogTime } from './helpers/checkinLogTime.js';
|
import { formatCheckinLogTime } from './helpers/checkinLogTime.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ export default function CheckinLog() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [triggering, setTriggering] = useState(false);
|
const [triggering, setTriggering] = useState(false);
|
||||||
const [filter, setFilter] = useState<LogFilter>('all');
|
const [filter, setFilter] = useState<LogFilter>('all');
|
||||||
|
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -127,6 +131,79 @@ export default function CheckinLog() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from '../api.js';
|
} from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { ModelBadge } from '../components/BrandIcon.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 { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js';
|
import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js';
|
||||||
@@ -123,6 +125,7 @@ export default function ProxyLogs() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(50);
|
||||||
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -275,6 +278,66 @@ export default function ProxyLogs() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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%' }}>
|
<table className="data-table" style={{ width: '100%' }}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import { api } from '../api.js';
|
|||||||
import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey, type BrandInfo } from '../components/BrandIcon.js';
|
import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey, type BrandInfo } from '../components/BrandIcon.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||||
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
import {
|
import {
|
||||||
buildRouteModelCandidatesIndex,
|
buildRouteModelCandidatesIndex,
|
||||||
@@ -729,6 +731,8 @@ export default function TokenRoutes() {
|
|||||||
const [decisionAutoSkipped, setDecisionAutoSkipped] = useState(false);
|
const [decisionAutoSkipped, setDecisionAutoSkipped] = useState(false);
|
||||||
const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK);
|
const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK);
|
||||||
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
|
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
|
||||||
|
const [expandedRouteIds, setExpandedRouteIds] = useState<number[]>([]);
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -1878,7 +1882,10 @@ export default function TokenRoutes() {
|
|||||||
</div>
|
</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) => {
|
{visibleRoutes.map((route, i) => {
|
||||||
const candidateView = getRouteCandidateView(route.id);
|
const candidateView = getRouteCandidateView(route.id);
|
||||||
const routeCandidates = candidateView.routeCandidates;
|
const routeCandidates = candidateView.routeCandidates;
|
||||||
@@ -1949,7 +1956,7 @@ export default function TokenRoutes() {
|
|||||||
const routeIcon = resolveRouteIcon(route);
|
const routeIcon = resolveRouteIcon(route);
|
||||||
const routeBrand = routeBrandById.get(route.id) || null;
|
const routeBrand = routeBrandById.get(route.id) || null;
|
||||||
|
|
||||||
return (
|
const routeCard = (
|
||||||
<div
|
<div
|
||||||
key={route.id}
|
key={route.id}
|
||||||
className={`card animate-slide-up stagger-${Math.min(i + 1, 5)}`}
|
className={`card animate-slide-up stagger-${Math.min(i + 1, 5)}`}
|
||||||
@@ -2327,6 +2334,44 @@ export default function TokenRoutes() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user