forked from boos/BoosAPI
feat: overhaul mobile management layouts
This commit is contained in:
+178
-105
@@ -2,6 +2,9 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import MobileBatchBar from '../components/MobileBatchBar.js';
|
||||
import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
@@ -82,7 +85,8 @@ export default function Accounts() {
|
||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
|
||||
const [expandedAccountIds, setExpandedAccountIds] = useState<number[]>([]);
|
||||
const isMobile = useIsMobile(768);
|
||||
const isMobile = useIsMobile();
|
||||
const [showMobileTools, setShowMobileTools] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||
const [loginForm, setLoginForm] = useState(createLoginForm);
|
||||
@@ -216,6 +220,7 @@ export default function Accounts() {
|
||||
if (activeSegment === 'tokens') return [];
|
||||
return sortedAccounts.filter((account) => resolveAccountCredentialMode(account) === activeSegment);
|
||||
}, [activeSegment, sortedAccounts]);
|
||||
const allVisibleAccountsSelected = visibleAccounts.length > 0 && visibleAccounts.every((account) => selectedAccountIds.includes(account.id));
|
||||
const verifyFailureHint = buildVerifyFailureHint(verifyResult);
|
||||
const addAccountPrereqHint = buildAddAccountPrereqHint(verifyResult);
|
||||
|
||||
@@ -728,10 +733,10 @@ export default function Accounts() {
|
||||
|
||||
const toggleSelectAllVisibleAccounts = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setSelectedAccountIds([]);
|
||||
setSelectedAccountIds((current) => current.filter((id) => !visibleAccounts.some((account) => account.id === id)));
|
||||
return;
|
||||
}
|
||||
setSelectedAccountIds(visibleAccounts.map((account) => account.id));
|
||||
setSelectedAccountIds((current) => Array.from(new Set([...current, ...visibleAccounts.map((account) => account.id)])));
|
||||
};
|
||||
|
||||
const toggleAccountDetails = (accountId: number) => {
|
||||
@@ -914,35 +919,59 @@ export default function Accounts() {
|
||||
<h2 className="page-title">{tr('连接管理')}</h2>
|
||||
{activeSegment !== 'tokens' && (
|
||||
<div className="page-actions accounts-page-actions">
|
||||
<div className="accounts-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={sortMode}
|
||||
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
|
||||
options={[
|
||||
{ value: 'custom', label: '自定义排序' },
|
||||
{ value: 'balance-desc', label: '余额高到低' },
|
||||
{ value: 'balance-asc', label: '余额低到高' },
|
||||
]}
|
||||
placeholder="自定义排序"
|
||||
/>
|
||||
</div>
|
||||
{activeSegment === 'session' && (
|
||||
<button
|
||||
onClick={() => withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到')}
|
||||
disabled={actionLoading['checkin-all']}
|
||||
className="btn btn-soft-primary"
|
||||
>
|
||||
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobileTools(true)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
排序与操作
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="accounts-mobile-select-all"
|
||||
onClick={() => toggleSelectAllVisibleAccounts(!allVisibleAccountsSelected)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{allVisibleAccountsSelected ? '取消全选' : '全选可见项'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="accounts-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={sortMode}
|
||||
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
|
||||
options={[
|
||||
{ value: 'custom', label: '自定义排序' },
|
||||
{ value: 'balance-desc', label: '余额高到低' },
|
||||
{ value: 'balance-asc', label: '余额低到高' },
|
||||
]}
|
||||
placeholder="自定义排序"
|
||||
/>
|
||||
</div>
|
||||
{activeSegment === 'session' && (
|
||||
<button
|
||||
onClick={() => withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到')}
|
||||
disabled={actionLoading['checkin-all']}
|
||||
className="btn btn-soft-primary"
|
||||
>
|
||||
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRefreshRuntimeHealth}
|
||||
disabled={actionLoading['health-refresh']}
|
||||
className="btn btn-soft-primary"
|
||||
>
|
||||
{actionLoading['health-refresh'] ? <><span className="spinner spinner-sm" />{tr('刷新状态中...')}</> : tr('刷新账户状态')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRefreshRuntimeHealth}
|
||||
disabled={actionLoading['health-refresh']}
|
||||
className="btn btn-soft-primary"
|
||||
>
|
||||
{actionLoading['health-refresh'] ? <><span className="spinner spinner-sm" />{tr('刷新状态中...')}</> : tr('刷新账户状态')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextOpen = !showAdd;
|
||||
@@ -964,6 +993,48 @@ export default function Accounts() {
|
||||
{activeSegment === 'tokens' && embeddedTokenActions}
|
||||
</div>
|
||||
|
||||
<MobileFilterSheet open={showMobileTools} onClose={() => setShowMobileTools(false)} title="连接排序与操作">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>排序方式</div>
|
||||
<ModernSelect
|
||||
value={sortMode}
|
||||
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
|
||||
options={[
|
||||
{ value: 'custom', label: '自定义排序' },
|
||||
{ value: 'balance-desc', label: '余额高到低' },
|
||||
{ value: 'balance-asc', label: '余额低到高' },
|
||||
]}
|
||||
placeholder="自定义排序"
|
||||
/>
|
||||
</div>
|
||||
{activeSegment === 'session' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowMobileTools(false);
|
||||
await withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到');
|
||||
}}
|
||||
disabled={actionLoading['checkin-all']}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowMobileTools(false);
|
||||
await handleRefreshRuntimeHealth();
|
||||
}}
|
||||
disabled={actionLoading['health-refresh']}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{actionLoading['health-refresh'] ? <><span className="spinner spinner-sm" />{tr('刷新状态中...')}</> : tr('刷新账户状态')}
|
||||
</button>
|
||||
</div>
|
||||
</MobileFilterSheet>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
@@ -1032,9 +1103,7 @@ export default function Accounts() {
|
||||
)}
|
||||
|
||||
{isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
|
||||
<div className="mobile-actions-bar">
|
||||
<span className="mobile-actions-info">已选 {selectedAccountIds.length} 项</span>
|
||||
<div className="mobile-actions-row">
|
||||
<MobileBatchBar info={`已选 ${selectedAccountIds.length} 项`}>
|
||||
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量刷新余额
|
||||
</button>
|
||||
@@ -1047,8 +1116,7 @@ export default function Accounts() {
|
||||
<button onClick={() => runBatchAccountAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MobileBatchBar>
|
||||
)}
|
||||
|
||||
{activeSegment === 'tokens' ? (
|
||||
@@ -1502,7 +1570,7 @@ export default function Accounts() {
|
||||
)}
|
||||
>
|
||||
{editingAccount ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<ResponsiveFormGrid>
|
||||
<input
|
||||
placeholder="账号名称"
|
||||
value={editForm.username}
|
||||
@@ -1570,7 +1638,7 @@ export default function Accounts() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveFormGrid>
|
||||
) : null}
|
||||
</CenteredModal>
|
||||
|
||||
@@ -1590,7 +1658,7 @@ export default function Accounts() {
|
||||
<MobileCard
|
||||
key={a.id}
|
||||
title={resolveAccountDisplayName(a)}
|
||||
actions={(
|
||||
headerActions={(
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1606,6 +1674,27 @@ export default function Accounts() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
footerActions={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAccountDetails(a.id)}
|
||||
className="btn btn-link"
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModelModal(a)}
|
||||
disabled={actionLoading[`models-${a.id}`]}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
模型
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<MobileField
|
||||
label="运行健康状态"
|
||||
@@ -1692,75 +1781,59 @@ export default function Accounts() {
|
||||
/>
|
||||
<MobileField
|
||||
label="提示"
|
||||
stacked
|
||||
value={hintMessage}
|
||||
/>
|
||||
<div className="mobile-card-actions">
|
||||
<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>
|
||||
)}
|
||||
{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={() => setDeleteConfirm({ mode: 'single', accountId: a.id, accountName: resolveAccountDisplayName(a) })} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
|
||||
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</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={() => openModelModal(a)}
|
||||
disabled={actionLoading[`models-${a.id}`]}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
模型
|
||||
</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={() => setDeleteConfirm({ mode: 'single', accountId: a.id, accountName: resolveAccountDisplayName(a) })} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
|
||||
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
@@ -1772,7 +1845,7 @@ export default function Accounts() {
|
||||
<th style={{ width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleAccounts.length > 0 && selectedAccountIds.length === visibleAccounts.length}
|
||||
checked={allVisibleAccountsSelected}
|
||||
onChange={(e) => toggleSelectAllVisibleAccounts(e.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api.js";
|
||||
import { MobileCard, MobileField } from "../components/MobileCard.js";
|
||||
import { MobileDrawer } from "../components/MobileDrawer.js";
|
||||
import MobileFilterSheet from "../components/MobileFilterSheet.js";
|
||||
import { useToast } from "../components/Toast.js";
|
||||
import { useIsMobile } from "../components/useIsMobile.js";
|
||||
import {
|
||||
@@ -71,7 +71,7 @@ export default function CheckinLog() {
|
||||
const [filter, setFilter] = useState<LogFilter>("all");
|
||||
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const isMobile = useIsMobile(768);
|
||||
const isMobile = useIsMobile();
|
||||
const toast = useToast();
|
||||
|
||||
function getStatus(log: any): "success" | "failed" | "skipped" {
|
||||
@@ -280,11 +280,12 @@ export default function CheckinLog() {
|
||||
筛选
|
||||
</button>
|
||||
</div>
|
||||
<MobileDrawer
|
||||
<MobileFilterSheet
|
||||
open={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
title="筛选签到记录"
|
||||
>
|
||||
<div className="mobile-filter-panel" style={{ gap: 12 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{timeRangeControls}
|
||||
{hasInvalidTimeRange && (
|
||||
<div className="alert alert-error">
|
||||
@@ -293,7 +294,7 @@ export default function CheckinLog() {
|
||||
)}
|
||||
{filterTabs}
|
||||
</div>
|
||||
</MobileDrawer>
|
||||
</MobileFilterSheet>
|
||||
</>
|
||||
) : (
|
||||
<div className="toolbar" style={{ marginBottom: "12px" }}>
|
||||
@@ -356,7 +357,7 @@ export default function CheckinLog() {
|
||||
<MobileCard
|
||||
key={logId}
|
||||
title={log.accounts?.username || "未知"}
|
||||
actions={
|
||||
headerActions={
|
||||
<span
|
||||
className={`badge ${statusClass(status)}`}
|
||||
style={{ fontSize: 10 }}
|
||||
@@ -364,6 +365,17 @@ export default function CheckinLog() {
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
}
|
||||
footerActions={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() =>
|
||||
setExpandedLogId(isExpanded ? null : logId)
|
||||
}
|
||||
>
|
||||
{isExpanded ? "收起" : "详情"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<MobileField
|
||||
label="时间"
|
||||
@@ -421,25 +433,16 @@ export default function CheckinLog() {
|
||||
<div className="mobile-card-extra">
|
||||
<MobileField
|
||||
label="信息"
|
||||
stacked
|
||||
value={log.checkin_logs?.message || log.message}
|
||||
/>
|
||||
<MobileField
|
||||
label="建议"
|
||||
stacked
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||
|
||||
const ModelAnalysisPanel = lazy(() => import('../components/ModelAnalysisPanel.js'));
|
||||
@@ -159,6 +160,7 @@ function buildAvailabilityBucketLogsRoute(siteId: number, bucket: SiteAvailabili
|
||||
}
|
||||
|
||||
export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminName?: string }) {
|
||||
const isMobile = useIsMobile();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -579,7 +581,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 16, marginBottom: 24 }}>
|
||||
<div className="chart-panel-enter animate-slide-up stagger-6">
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteDistributionChart data={siteDistribution} loading={siteLoading} />
|
||||
@@ -694,7 +696,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 300px', gap: 16 }}>
|
||||
<div className="chart-container animate-slide-up stagger-8">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import DownstreamKeys from './DownstreamKeys.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getDownstreamApiKeysSummary: vi.fn(),
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getDownstreamApiKeyOverview: vi.fn(),
|
||||
getDownstreamApiKeyTrend: vi.fn(),
|
||||
createDownstreamApiKey: vi.fn(),
|
||||
batchDownstreamApiKeys: vi.fn(),
|
||||
updateDownstreamApiKey: vi.fn(),
|
||||
deleteDownstreamApiKey: vi.fn(),
|
||||
resetDownstreamApiKeyUsage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({ api: apiMock }));
|
||||
|
||||
vi.mock('../components/useIsMobile.js', () => ({
|
||||
useIsMobile: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('react-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
|
||||
return {
|
||||
...actual,
|
||||
createPortal: (node: unknown) => node,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../components/useAnimatedVisibility.js', () => ({
|
||||
useAnimatedVisibility: (open: boolean) => ({
|
||||
shouldRender: open,
|
||||
isVisible: open,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
|
||||
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
|
||||
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ModernSelect.js', () => ({
|
||||
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node) === text
|
||||
));
|
||||
}
|
||||
|
||||
function findGhostButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node) === text
|
||||
&& String(node.props.className || '').includes('btn btn-ghost')
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buildSummaryItem(id: number, overrides?: Partial<any>) {
|
||||
return {
|
||||
id,
|
||||
name: `mobile-key-${id}`,
|
||||
keyMasked: `sk-k****${id}`,
|
||||
enabled: true,
|
||||
description: 'mobile smoke',
|
||||
groupName: '项目A',
|
||||
tags: ['移动端'],
|
||||
expiresAt: null,
|
||||
maxCost: null,
|
||||
usedCost: 0,
|
||||
maxRequests: null,
|
||||
usedRequests: 0,
|
||||
supportedModels: ['gpt-4.1-mini'],
|
||||
allowedRouteIds: [11],
|
||||
siteWeightMultipliers: {},
|
||||
lastUsedAt: '2026-03-15T08:27:25.378Z',
|
||||
createdAt: '2026-03-15T08:27:25.378Z',
|
||||
updatedAt: '2026-03-15T08:27:25.378Z',
|
||||
rangeUsage: {
|
||||
totalRequests: 3,
|
||||
successRequests: 2,
|
||||
failedRequests: 1,
|
||||
successRate: 66.7,
|
||||
totalTokens: 4200,
|
||||
totalCost: 0.42,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRawItem(id: number, overrides?: Partial<any>) {
|
||||
return {
|
||||
id,
|
||||
name: `mobile-key-${id}`,
|
||||
key: `sk-mobile-${id}`,
|
||||
keyMasked: `sk-k****${id}`,
|
||||
description: 'mobile smoke',
|
||||
groupName: '项目A',
|
||||
tags: ['移动端'],
|
||||
enabled: true,
|
||||
expiresAt: null,
|
||||
maxCost: null,
|
||||
usedCost: 0,
|
||||
maxRequests: null,
|
||||
usedRequests: 0,
|
||||
supportedModels: ['gpt-4.1-mini'],
|
||||
allowedRouteIds: [11],
|
||||
siteWeightMultipliers: {},
|
||||
lastUsedAt: '2026-03-15T08:27:25.378Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DownstreamKeys mobile layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(globalThis as any).document = {
|
||||
body: { style: {} },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
|
||||
success: true,
|
||||
items: [buildSummaryItem(1), buildSummaryItem(2)],
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({
|
||||
success: true,
|
||||
items: [buildRawItem(1), buildRawItem(2)],
|
||||
});
|
||||
apiMock.getRoutesLite.mockResolvedValue([
|
||||
{ id: 11, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
|
||||
]);
|
||||
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
|
||||
success: true,
|
||||
item: buildSummaryItem(1),
|
||||
usage: {
|
||||
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
|
||||
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
|
||||
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
|
||||
},
|
||||
});
|
||||
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
|
||||
success: true,
|
||||
buckets: [
|
||||
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
|
||||
],
|
||||
});
|
||||
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
|
||||
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1, 2], failedItems: [] });
|
||||
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
|
||||
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
|
||||
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete (globalThis as any).document;
|
||||
});
|
||||
|
||||
it('renders mobile cards and supports select-all-visible batch actions', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/downstream-keys']}>
|
||||
<ToastProvider>
|
||||
<DownstreamKeys />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const cards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
|
||||
expect(cards.length).toBe(2);
|
||||
expect(collectText(root!.root)).toContain('全选可见');
|
||||
|
||||
const selectAllButton = findButtonByText(root!.root, '全选可见');
|
||||
await act(async () => {
|
||||
selectAllButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root!.root)).toContain('已选 2 个密钥');
|
||||
expect(collectText(root!.root)).toContain('取消全选');
|
||||
|
||||
const disableButton = findGhostButtonByText(root!.root, '禁用');
|
||||
await act(async () => {
|
||||
disableButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
|
||||
ids: [1, 2],
|
||||
action: 'disable',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,13 @@ import { createPortal } from 'react-dom';
|
||||
import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
|
||||
import MobileBatchBar from '../components/MobileBatchBar.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||
|
||||
@@ -1199,6 +1203,7 @@ export default function DownstreamKeys() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState>(null);
|
||||
const [batchMetadataOpen, setBatchMetadataOpen] = useState(false);
|
||||
const [batchMetadataForm, setBatchMetadataForm] = useState<BatchMetadataForm>({
|
||||
@@ -1207,6 +1212,7 @@ export default function DownstreamKeys() {
|
||||
tagOperation: 'keep',
|
||||
tags: [],
|
||||
});
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -1571,6 +1577,69 @@ export default function DownstreamKeys() {
|
||||
await batchRun('批量归类', selectedIds);
|
||||
};
|
||||
|
||||
const filterControls = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div className="toolbar" style={{ marginBottom: 0, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 420px', minWidth: 280, flexWrap: 'wrap' }}>
|
||||
<div className="toolbar-search" style={{ maxWidth: 'unset', flex: '1 1 320px' }}>
|
||||
<svg width="16" height="16" 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>
|
||||
<InlineToggle value={tagMatchMode} onChange={setTagMatchMode} />
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<ModernSelect value={status} onChange={(value) => setStatus((value as Status) || 'all')} options={statusOptions} />
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<ModernSelect value={groupFilter} onChange={(value) => setGroupFilter(String(value || '__all__'))} options={groupFilterOptions} />
|
||||
</div>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => { setSearchInput(''); setStatus('all'); setGroupFilter('__all__'); setSelectedTags([]); setTagMatchMode('any'); }}>
|
||||
重置筛选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(activeTagFilters.length > 0 || tagSuggestions.length > 0) ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{activeTagFilters.map((tag) => {
|
||||
const fromPinnedTags = selectedTags.some((item) => item.toLowerCase() === tag.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
className="btn btn-ghost"
|
||||
style={{ ...tagChipStyle('accent'), cursor: 'pointer', opacity: fromPinnedTags ? 1 : 0.82 }}
|
||||
onClick={() => {
|
||||
if (fromPinnedTags) {
|
||||
setSelectedTags((current) => current.filter((item) => item.toLowerCase() !== tag.toLowerCase()));
|
||||
return;
|
||||
}
|
||||
setSearchInput((current) => current
|
||||
.split(/[\r\n,,]+/g)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter((item) => item.toLowerCase() !== tag.toLowerCase())
|
||||
.join(', '));
|
||||
}}
|
||||
>
|
||||
{tag} ×
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{tagSuggestions.filter((tag) => !activeTagFilters.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 8).map((tag) => (
|
||||
<button key={tag} className="btn btn-ghost" style={tagChipStyle()} onClick={() => addTagFilter(tag)}>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const empty = !loading && visibleItems.length === 0;
|
||||
|
||||
return (
|
||||
@@ -1618,14 +1687,24 @@ export default function DownstreamKeys() {
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
<div className="card" style={{ padding: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>已选 {selectedIds.length} 个密钥</span>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>批量归类/标签</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>批量启用</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>批量禁用</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>批量清零用量</button>
|
||||
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>批量删除</button>
|
||||
</div>
|
||||
isMobile ? (
|
||||
<MobileBatchBar info={`已选 ${selectedIds.length} 个密钥`}>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>归类/标签</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>启用</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>禁用</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>清零</button>
|
||||
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>删除</button>
|
||||
</MobileBatchBar>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>已选 {selectedIds.length} 个密钥</span>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>批量归类/标签</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>批量启用</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>批量禁用</button>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>批量清零用量</button>
|
||||
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>批量删除</button>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<div className="card" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -1635,65 +1714,28 @@ export default function DownstreamKeys() {
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>筛选与列表</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>按名称、状态、主分组和标签快速定位下游密钥。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="toolbar" style={{ marginBottom: 0, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 420px', minWidth: 280, flexWrap: 'wrap' }}>
|
||||
<div className="toolbar-search" style={{ maxWidth: 'unset', flex: '1 1 320px' }}>
|
||||
<svg width="16" height="16" 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>
|
||||
<InlineToggle value={tagMatchMode} onChange={setTagMatchMode} />
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<ModernSelect value={status} onChange={(value) => setStatus((value as Status) || 'all')} options={statusOptions} />
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<ModernSelect value={groupFilter} onChange={(value) => setGroupFilter(String(value || '__all__'))} options={groupFilterOptions} />
|
||||
</div>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => { setSearchInput(''); setStatus('all'); setGroupFilter('__all__'); setSelectedTags([]); setTagMatchMode('any'); }}>
|
||||
重置筛选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(activeTagFilters.length > 0 || tagSuggestions.length > 0) ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{activeTagFilters.map((tag) => {
|
||||
const fromPinnedTags = selectedTags.some((item) => item.toLowerCase() === tag.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
className="btn btn-ghost"
|
||||
style={{ ...tagChipStyle('accent'), cursor: 'pointer', opacity: fromPinnedTags ? 1 : 0.82 }}
|
||||
onClick={() => {
|
||||
if (fromPinnedTags) {
|
||||
setSelectedTags((current) => current.filter((item) => item.toLowerCase() !== tag.toLowerCase()));
|
||||
return;
|
||||
}
|
||||
setSearchInput((current) => current
|
||||
.split(/[\r\n,,]+/g)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter((item) => item.toLowerCase() !== tag.toLowerCase())
|
||||
.join(', '));
|
||||
}}
|
||||
>
|
||||
{tag} ×
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{tagSuggestions.filter((tag) => !activeTagFilters.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 8).map((tag) => (
|
||||
<button key={tag} className="btn btn-ghost" style={tagChipStyle()} onClick={() => addTagFilter(tag)}>
|
||||
{tag}
|
||||
{isMobile && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => setShowFilters(true)}>
|
||||
筛选
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={() => toggleSelectAllVisible(!allVisibleSelected)}
|
||||
>
|
||||
{allVisibleSelected ? '取消全选' : '全选可见'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title="筛选下游密钥">
|
||||
{filterControls}
|
||||
</MobileFilterSheet>
|
||||
) : (
|
||||
filterControls
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -1703,6 +1745,61 @@ export default function DownstreamKeys() {
|
||||
<div className="empty-state-title">暂无下游密钥</div>
|
||||
<div className="empty-state-desc">可以先新增一条密钥,或调整筛选条件查看已有数据。</div>
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="mobile-card-list">
|
||||
{visibleItems.map((row) => {
|
||||
const loadingToggle = !!rowLoading[`toggle-${row.id}`];
|
||||
const loadingReset = !!rowLoading[`reset-${row.id}`];
|
||||
const loadingDelete = !!rowLoading[`delete-${row.id}`];
|
||||
const checked = selectedIds.includes(row.id);
|
||||
return (
|
||||
<MobileCard
|
||||
key={row.id}
|
||||
title={row.name}
|
||||
headerActions={(
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StatusBadge enabled={row.enabled} />
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择 ${row.name}`}
|
||||
checked={checked}
|
||||
onChange={(e) => toggleSelection(row.id, e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
footerActions={(
|
||||
<>
|
||||
<button className="btn btn-link" onClick={() => { setSelectedId(row.id); setDrawerOpen(true); }}>查看</button>
|
||||
<button className="btn btn-link" onClick={() => openEdit(row)}>编辑</button>
|
||||
<button className="btn btn-link" onClick={() => void toggleEnabled(row)} disabled={loadingToggle}>{loadingToggle ? '处理中...' : (row.enabled ? '禁用' : '启用')}</button>
|
||||
<button className="btn btn-link" onClick={() => void resetUsage(row)} disabled={loadingReset}>{loadingReset ? '处理中...' : '清零用量'}</button>
|
||||
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'single', item: row })} disabled={loadingDelete}>{loadingDelete ? '处理中...' : '删除'}</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<MobileField
|
||||
label="密钥"
|
||||
value={(
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-muted)' }}>{row.keyMasked}</span>
|
||||
<DownstreamKeyCopyIconButton fullKey={row.key} />
|
||||
</span>
|
||||
)}
|
||||
stacked
|
||||
/>
|
||||
{row.description ? <MobileField label="备注" value={row.description} stacked /> : null}
|
||||
<MobileField label="主分组" value={row.groupName || '未分组'} />
|
||||
<MobileField label="标签" value={summarizeTags(row.tags || [])} stacked />
|
||||
<MobileField label="模型" value={summarizeModelLimit(row.supportedModels || [])} stacked />
|
||||
<MobileField label="群组" value={summarizeRouteLimit(row.allowedRouteIds || [], routeMap)} stacked />
|
||||
<MobileField label="倍率" value={summarizeSiteWeightMultipliers(row.siteWeightMultipliers || {})} stacked />
|
||||
<MobileField label="额度" value={`${row.maxRequests == null ? '不限' : row.maxRequests.toLocaleString()} / ${row.maxCost == null ? '成本不限' : formatMoney(row.maxCost)}`} stacked />
|
||||
<MobileField label="用量" value={`${(row.rangeUsage?.totalRequests || 0).toLocaleString()} 请求 · ${formatCompactTokens(row.rangeUsage?.totalTokens || 0)}`} stacked />
|
||||
<MobileField label="最近使用" value={formatIso(row.lastUsedAt)} stacked />
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from './helpers/conversationFileCapabilities.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type ChatJobResponse = {
|
||||
@@ -646,6 +647,7 @@ function ParameterRow(props: {
|
||||
}
|
||||
|
||||
export default function ModelTester() {
|
||||
const isMobile = useIsMobile();
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
@@ -2181,7 +2183,9 @@ export default function ModelTester() {
|
||||
return debugResponse;
|
||||
}, [activeDebugTab, debugPreview, debugRequest, debugResponse]);
|
||||
|
||||
const layoutColumns = debugPanelPresence.shouldRender
|
||||
const layoutColumns = isMobile
|
||||
? '1fr'
|
||||
: debugPanelPresence.shouldRender
|
||||
? '340px minmax(0, 1fr) 360px'
|
||||
: '340px minmax(0, 1fr)';
|
||||
|
||||
@@ -2239,7 +2243,7 @@ export default function ModelTester() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 16 }} className="animate-slide-up stagger-1">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, 1fr)', gap: 12, marginBottom: 16 }} className="animate-slide-up stagger-1">
|
||||
<div className="stat-summary-card stat-summary-purple">
|
||||
<div className="stat-summary-card-label">模型数量</div>
|
||||
<div className="stat-summary-card-value">{models.length}</div>
|
||||
@@ -2279,7 +2283,7 @@ export default function ModelTester() {
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<div className="card" style={{ padding: 16, minHeight: 680, maxHeight: 740, overflowY: 'auto' }}>
|
||||
<div className="card" style={{ padding: 16, minHeight: isMobile ? 'auto' : 680, maxHeight: isMobile ? 'none' : 740, overflowY: isMobile ? 'visible' : 'auto', order: isMobile ? 2 : 0 }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: 15 }}>设置</h3>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
@@ -2299,7 +2303,7 @@ export default function ModelTester() {
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6, fontWeight: 600 }}>模型</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<input
|
||||
value={modelSearch}
|
||||
onChange={(event) => setModelSearch(event.target.value)}
|
||||
@@ -2575,7 +2579,7 @@ export default function ModelTester() {
|
||||
</ParameterRow>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden', minHeight: 680, maxHeight: 740, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden', minHeight: isMobile ? 'auto' : 680, maxHeight: isMobile ? 'none' : 740, display: 'flex', flexDirection: 'column', order: isMobile ? 1 : 0 }}>
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
@@ -2699,7 +2703,7 @@ export default function ModelTester() {
|
||||
{isUser ? 'U' : (isSystem ? 'SYS' : 'AI')}
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: '78%', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ maxWidth: isMobile ? '100%' : '78%', display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: isMobile ? 1 : 'initial' }}>
|
||||
{showReasoning && (
|
||||
<div style={{
|
||||
border: '1px solid color-mix(in srgb, var(--color-primary) 28%, transparent)',
|
||||
@@ -2854,7 +2858,7 @@ export default function ModelTester() {
|
||||
)}
|
||||
|
||||
{inputs.mode === 'conversation' ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: isMobile ? 'stretch' : 'flex-end', flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
@@ -2983,16 +2987,17 @@ export default function ModelTester() {
|
||||
disabled={sending ? false : !canSend}
|
||||
className="btn btn-primary"
|
||||
style={{
|
||||
height: 78,
|
||||
padding: '0 20px',
|
||||
height: isMobile ? 50 : 78,
|
||||
padding: isMobile ? '0 16px' : '0 20px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexDirection: isMobile ? 'row' : 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
minWidth: 88,
|
||||
minWidth: isMobile ? '100%' : 88,
|
||||
width: isMobile ? '100%' : 'auto',
|
||||
}}
|
||||
>
|
||||
{sending ? (
|
||||
@@ -3030,7 +3035,7 @@ export default function ModelTester() {
|
||||
placeholder="输入搜索查询"
|
||||
style={{ ...inputBaseStyle, resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr 120px', gap: 10 }}>
|
||||
<input value={searchAllowedDomains} onChange={(event) => setSearchAllowedDomains(event.target.value)} placeholder="allowed_domains (逗号分隔)" style={inputBaseStyle} />
|
||||
<input value={searchBlockedDomains} onChange={(event) => setSearchBlockedDomains(event.target.value)} placeholder="blocked_domains (逗号分隔)" style={inputBaseStyle} />
|
||||
<input value={searchMaxResults} onChange={(event) => setSearchMaxResults(toNumber(event.target.value, 10))} type="number" min={1} max={20} style={inputBaseStyle} />
|
||||
@@ -3047,7 +3052,7 @@ export default function ModelTester() {
|
||||
style={{ ...inputBaseStyle, resize: 'vertical' }}
|
||||
/>
|
||||
{(inputs.mode === 'images.edit' || inputs.mode === 'videos.create') && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: inputs.mode === 'images.edit' ? '1fr 1fr' : '1fr', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : (inputs.mode === 'images.edit' ? '1fr 1fr' : '1fr'), gap: 10 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<div style={{ marginBottom: 6 }}>{inputs.mode === 'images.edit' ? '原图' : '参考图'}</div>
|
||||
<input type="file" accept="image/*" onChange={(event) => { void handleUploadChange(event.target.files, setImageSourceFile); }} />
|
||||
@@ -3063,7 +3068,7 @@ export default function ModelTester() {
|
||||
</>
|
||||
)}
|
||||
{inputs.mode === 'videos.inspect' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 160px', gap: 10 }}>
|
||||
<input
|
||||
value={videoInspectId}
|
||||
onChange={(event) => setVideoInspectId(event.target.value)}
|
||||
@@ -3100,7 +3105,7 @@ export default function ModelTester() {
|
||||
</div>
|
||||
|
||||
{debugPanelPresence.shouldRender && (
|
||||
<div className={`card panel-presence ${debugPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 14, minHeight: 680, maxHeight: 740, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className={`card panel-presence ${debugPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 14, minHeight: isMobile ? 'auto' : 680, maxHeight: isMobile ? 'none' : 740, display: 'flex', flexDirection: 'column', order: isMobile ? 3 : 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 15 }}>调试</h3>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
@@ -3108,7 +3113,7 @@ export default function ModelTester() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
|
||||
@@ -32,6 +32,8 @@ async function flushMicrotasks() {
|
||||
describe('Models marketplace text', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalMutationObserver = globalThis.MutationObserver;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalMatchMedia = globalThis.matchMedia;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -75,6 +77,8 @@ describe('Models marketplace text', () => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.MutationObserver = originalMutationObserver;
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
it('renders readable Chinese labels and fallback descriptions for marketplace models', async () => {
|
||||
@@ -191,6 +195,71 @@ describe('Models marketplace text', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a visible mobile filter entry on small screens', async () => {
|
||||
const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis;
|
||||
nextWindow.innerWidth = 768;
|
||||
nextWindow.addEventListener = nextWindow.addEventListener || (() => {});
|
||||
nextWindow.removeEventListener = nextWindow.removeEventListener || (() => {});
|
||||
nextWindow.matchMedia = (() => ({
|
||||
matches: true,
|
||||
media: '(max-width: 768px)',
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as typeof window.matchMedia;
|
||||
globalThis.window = nextWindow;
|
||||
globalThis.matchMedia = nextWindow.matchMedia;
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root!.root)).toContain('筛选');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the mobile filter entry visible even while the first screen is still loading', async () => {
|
||||
globalThis.window = {
|
||||
innerWidth: 768,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as Window & typeof globalThis;
|
||||
apiMock.getModelsMarketplace.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(collectText(root!.root)).toContain('筛选');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('limits expanded account and pricing detail to the selected site filter', async () => {
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
|
||||
+240
-139
@@ -5,7 +5,9 @@ import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../c
|
||||
import SiteBadgeLink from '../components/SiteBadgeLink.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { mergeMarketplaceMetadata, shouldHydrateMarketplaceMetadata } from './helpers/modelsMarketplaceMetadata.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
@@ -164,8 +166,10 @@ export default function Models() {
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [filterCollapsed, setFilterCollapsed] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [metadataHydrating, setMetadataHydrating] = useState(false);
|
||||
const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220);
|
||||
const isMobile = useIsMobile();
|
||||
const filterPanelPresence = useAnimatedVisibility(!isMobile && !filterCollapsed, 220);
|
||||
const latestPrimaryRequestRef = useRef(0);
|
||||
const latestMetadataRequestRef = useRef(0);
|
||||
const location = useLocation();
|
||||
@@ -242,6 +246,16 @@ export default function Models() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (viewMode !== 'card') {
|
||||
setViewMode('card');
|
||||
}
|
||||
if (!filterCollapsed) {
|
||||
setFilterCollapsed(true);
|
||||
}
|
||||
}, [filterCollapsed, isMobile, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let metadataTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -387,18 +401,137 @@ export default function Models() {
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
const filterControls = (
|
||||
<>
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('品牌')}
|
||||
{activeBrand && <button onClick={() => setActiveBrand(null)}>{tr('重置')}</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>
|
||||
{tr('全部品牌')}
|
||||
<span className="filter-item-count">{data.models.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, borderRadius: 4 }}>?</span>
|
||||
{tr('其他')}
|
||||
<span className="filter-item-count">{brandList.otherCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('供应商')}
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
|
||||
</div>
|
||||
{siteMap.map(([site, count]) => (
|
||||
<div
|
||||
key={site}
|
||||
className={`filter-item ${activeSite === site ? 'active' : ''}`}
|
||||
onClick={() => setActiveSite(activeSite === site ? null : site)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
|
||||
{site.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">{tr('排序方式')}</div>
|
||||
{[
|
||||
{ key: 'accountCount' as SortColumn, label: tr('账号数') },
|
||||
{ key: 'tokenCount' as SortColumn, label: tr('令牌数') },
|
||||
{ key: 'avgLatency' as SortColumn, label: tr('延迟') },
|
||||
{ key: 'successRate' as SortColumn, label: tr('成功率') },
|
||||
{ key: 'name' as SortColumn, label: tr('名称') },
|
||||
].map(opt => (
|
||||
<div
|
||||
key={opt.key}
|
||||
className={`filter-item ${sortBy === opt.key ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (sortBy === opt.key) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy(opt.key);
|
||||
setSortDir(opt.key === 'name' ? 'asc' : 'desc');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{sortBy === opt.key && (
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--color-primary)' }}>
|
||||
{sortDir === 'desc' ? '↓' : '↑'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
/* ---- loading skeleton ---- */
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="skeleton" style={{ width: 260, height: 28, marginBottom: 20 }} />
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||
{!isMobile && (
|
||||
<div style={{ width: 240 }}>
|
||||
{[...Array(6)].map((_, i) => <div key={i} className="skeleton" style={{ height: 28, marginBottom: 8, borderRadius: 8 }} />)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="skeleton" style={{ height: 100, marginBottom: 12, borderRadius: 12 }} />)}
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
<div className="skeleton" style={{ width: 220, height: 28, marginBottom: 8 }} />
|
||||
<div className="skeleton" style={{ width: 160, height: 16 }} />
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{isMobile && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }}
|
||||
onClick={() => setShowFilters(true)}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" /></svg>
|
||||
{tr('筛选')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<MobileFilterSheet
|
||||
open={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
title={tr('筛选模型')}
|
||||
>
|
||||
{filterControls}
|
||||
</MobileFilterSheet>
|
||||
)}
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="skeleton" style={{ height: 100, marginBottom: 12, borderRadius: 12 }} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -406,101 +539,9 @@ export default function Models() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||
{/* ====== LEFT: Filter Panel ====== */}
|
||||
{filterPanelPresence.shouldRender && (
|
||||
{!isMobile && filterPanelPresence.shouldRender && (
|
||||
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
{/* Brand filter */}
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('品牌')}
|
||||
{activeBrand && <button onClick={() => setActiveBrand(null)}>{tr('重置')}</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>
|
||||
{tr('全部品牌')}
|
||||
<span className="filter-item-count">{data.models.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, borderRadius: 4 }}>?</span>
|
||||
{tr('其他')}
|
||||
<span className="filter-item-count">{brandList.otherCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supplier filter */}
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('供应商')}
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
|
||||
</div>
|
||||
{siteMap.map(([site, count]) => (
|
||||
<div
|
||||
key={site}
|
||||
className={`filter-item ${activeSite === site ? 'active' : ''}`}
|
||||
onClick={() => setActiveSite(activeSite === site ? null : site)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
|
||||
{site.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">{tr('排序方式')}</div>
|
||||
{[
|
||||
{ key: 'accountCount' as SortColumn, label: tr('账号数') },
|
||||
{ key: 'tokenCount' as SortColumn, label: tr('令牌数') },
|
||||
{ key: 'avgLatency' as SortColumn, label: tr('延迟') },
|
||||
{ key: 'successRate' as SortColumn, label: tr('成功率') },
|
||||
{ key: 'name' as SortColumn, label: tr('名称') },
|
||||
].map(opt => (
|
||||
<div
|
||||
key={opt.key}
|
||||
className={`filter-item ${sortBy === opt.key ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (sortBy === opt.key) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy(opt.key);
|
||||
setSortDir(opt.key === 'name' ? 'asc' : 'desc');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{sortBy === opt.key && (
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--color-primary)' }}>
|
||||
{sortDir === 'desc' ? '↓' : '↑'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filterControls}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ width: '100%', fontSize: 12, padding: '6px 10px', marginTop: 8, justifyContent: 'center', border: '1px solid var(--color-border)' }}
|
||||
@@ -529,8 +570,18 @@ export default function Models() {
|
||||
)}
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{filterCollapsed && (
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }} onClick={() => setFilterCollapsed(false)}>
|
||||
{(isMobile || filterCollapsed) && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
setShowFilters(true);
|
||||
return;
|
||||
}
|
||||
setFilterCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" /></svg>
|
||||
{tr('筛选')}
|
||||
</button>
|
||||
@@ -543,17 +594,29 @@ export default function Models() {
|
||||
{metadataHydrating && (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>{tr('加载元数据中...')}</span>
|
||||
)}
|
||||
<div className="view-toggle">
|
||||
<button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} data-tooltip={tr('卡片视图')} aria-label={tr('卡片视图')}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} data-tooltip={tr('表格视图')} aria-label={tr('表格视图')}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18M3 6h18M3 18h18M10 3v18M14 3v18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="view-toggle">
|
||||
<button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} data-tooltip={tr('卡片视图')} aria-label={tr('卡片视图')}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} data-tooltip={tr('表格视图')} aria-label={tr('表格视图')}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18M3 6h18M3 18h18M10 3v18M14 3v18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<MobileFilterSheet
|
||||
open={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
title={tr('筛选模型')}
|
||||
>
|
||||
{filterControls}
|
||||
</MobileFilterSheet>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
@@ -719,36 +782,74 @@ export default function Models() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ fontWeight: 500 }}>{tr('站点')}</th>
|
||||
<th style={{ fontWeight: 500 }}>{tr('账号')}</th>
|
||||
<th style={{ fontWeight: 500 }}>{tr('令牌')}</th>
|
||||
<th style={{ fontWeight: 500 }}>{tr('延迟')}</th>
|
||||
<th style={{ fontWeight: 500 }}>{tr('余额')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{m.accounts.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
|
||||
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
|
||||
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{a.tokens.length > 0 ? a.tokens.map(t => (
|
||||
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
|
||||
)) : <span style={{ color: 'var(--color-text-muted)' }}>—</span>}
|
||||
</td>
|
||||
<td>
|
||||
{a.latency != null ? (
|
||||
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{a.latency}ms</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>${(a.balance || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
{isMobile ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('账号明细')}</div>
|
||||
{m.accounts.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="card"
|
||||
style={{ padding: 10, display: 'grid', gap: 8 }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{a.username || `ID:${a.id}`}</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>{tr('延迟')}</span>
|
||||
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums' }}>
|
||||
{a.latency != null ? `${a.latency}ms` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>{tr('余额')}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>${(a.balance || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{tr('令牌')}</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{a.tokens.length > 0 ? a.tokens.map((t) => (
|
||||
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
|
||||
)) : <span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>—</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ fontWeight: 500 }}>{tr('站点')}</th>
|
||||
<th style={{ fontWeight: 500 }}>{tr('账号')}</th>
|
||||
<th style={{ fontWeight: 500 }}>{tr('令牌')}</th>
|
||||
<th style={{ fontWeight: 500 }}>{tr('延迟')}</th>
|
||||
<th style={{ fontWeight: 500 }}>{tr('余额')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{m.accounts.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
|
||||
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
|
||||
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{a.tokens.length > 0 ? a.tokens.map(t => (
|
||||
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
|
||||
)) : <span style={{ color: 'var(--color-text-muted)' }}>—</span>}
|
||||
</td>
|
||||
<td>
|
||||
{a.latency != null ? (
|
||||
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{a.latency}ms</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>${(a.balance || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+127
-28
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { tr } from '../i18n.js';
|
||||
@@ -94,6 +97,8 @@ export default function ProgramLogs() {
|
||||
const [markingAll, setMarkingAll] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [rowLoading, setRowLoading] = useState<Record<number, boolean>>({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const toast = useToast();
|
||||
|
||||
const load = async (silent = false, append = false) => {
|
||||
@@ -208,37 +213,85 @@ export default function ProgramLogs() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={filterType}
|
||||
onChange={(nextValue) => setFilterType(nextValue)}
|
||||
options={TYPE_OPTIONS.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}))}
|
||||
placeholder="全部类型"
|
||||
/>
|
||||
</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>
|
||||
<MobileFilterSheet
|
||||
open={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
title="筛选程序日志"
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={filterType}
|
||||
onChange={(nextValue) => setFilterType(nextValue)}
|
||||
options={TYPE_OPTIONS.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}))}
|
||||
placeholder="全部类型"
|
||||
/>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyUnread}
|
||||
onChange={(e) => {
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
setOnlyUnread(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
仅看未读
|
||||
</label>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
共 {visibleRows.length} 条
|
||||
</div>
|
||||
</div>
|
||||
</MobileFilterSheet>
|
||||
</>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={filterType}
|
||||
onChange={(nextValue) => setFilterType(nextValue)}
|
||||
options={TYPE_OPTIONS.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}))}
|
||||
placeholder="全部类型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyUnread}
|
||||
onChange={(e) => {
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
setOnlyUnread(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
仅看未读
|
||||
</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyUnread}
|
||||
onChange={(e) => {
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
setOnlyUnread(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
仅看未读
|
||||
</label>
|
||||
|
||||
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
共 {visibleRows.length} 条
|
||||
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
共 {visibleRows.length} 条
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{loading ? (
|
||||
@@ -247,6 +300,52 @@ export default function ProgramLogs() {
|
||||
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
|
||||
<div className="skeleton" style={{ width: '100%', height: 34 }} />
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="mobile-card-list">
|
||||
{visibleRows.length > 0 ? visibleRows.map((row) => {
|
||||
const level = levelLabel(row.level || 'info');
|
||||
const eventStatus = eventStatusLabel(row);
|
||||
return (
|
||||
<MobileCard
|
||||
key={row.id}
|
||||
title={row.title || '-'}
|
||||
headerActions={(
|
||||
<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 10 }}>
|
||||
{eventStatus.label}
|
||||
</span>
|
||||
)}
|
||||
footerActions={(
|
||||
row.read ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markOneRead(row.id)}
|
||||
disabled={!!rowLoading[row.id]}
|
||||
className="btn btn-link btn-link-primary"
|
||||
>
|
||||
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<MobileField label="时间" value={formatDateTimeLocal(row.createdAt)} />
|
||||
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{row.type || '-'}</span>} />
|
||||
<MobileField label="级别" value={<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
|
||||
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.label}</span>} />
|
||||
<MobileField label="内容" value={row.message || '-'} stacked />
|
||||
</MobileCard>
|
||||
);
|
||||
}) : (
|
||||
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无日志</div>
|
||||
<div className="empty-state-desc">当前筛选条件下没有程序日志。</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : visibleRows.length > 0 ? (
|
||||
<table className="data-table program-logs-table">
|
||||
<colgroup>
|
||||
|
||||
+54
-27
@@ -13,7 +13,7 @@ import { useToast } from '../components/Toast.js';
|
||||
import { ModelBadge } from '../components/BrandIcon.js';
|
||||
import SiteBadgeLink from '../components/SiteBadgeLink.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||
import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
@@ -673,11 +673,13 @@ export default function ProxyLogs() {
|
||||
筛选
|
||||
</button>
|
||||
</div>
|
||||
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
|
||||
<div className="mobile-filter-panel">
|
||||
{filterControls}
|
||||
</div>
|
||||
</MobileDrawer>
|
||||
<MobileFilterSheet
|
||||
open={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
title={tr('筛选日志')}
|
||||
>
|
||||
{filterControls}
|
||||
</MobileFilterSheet>
|
||||
</>
|
||||
) : (
|
||||
<div className="toolbar" style={{ marginBottom: 12 }}>
|
||||
@@ -716,30 +718,64 @@ export default function ProxyLogs() {
|
||||
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
|
||||
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
|
||||
const isExpanded = expanded === log.id;
|
||||
const clientDisplay = resolveProxyLogClientDisplay(detailLog);
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
key={log.id}
|
||||
title={detailLog.modelRequested || 'unknown'}
|
||||
actions={(
|
||||
subtitle={formatDateTimeLocal(log.createdAt)}
|
||||
compact
|
||||
headerActions={(
|
||||
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 10 }}>
|
||||
{log.status === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
)}
|
||||
footerActions={(
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => handleToggleExpand(log.id)}
|
||||
>
|
||||
{isExpanded ? '收起详情' : '详情'}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
||||
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
|
||||
<MobileField label="客户端" value={renderProxyLogClientCell(detailLog)} />
|
||||
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
|
||||
<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)}` : '-'}
|
||||
/>
|
||||
<div className="mobile-inline-meta-row">
|
||||
<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />
|
||||
{clientDisplay.primary ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{clientDisplay.primary}
|
||||
</span>
|
||||
) : null}
|
||||
{clientDisplay.secondary ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{clientDisplay.secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mobile-summary-grid">
|
||||
<div className="mobile-summary-metric">
|
||||
<div className="mobile-summary-metric-label">用时</div>
|
||||
<div className="mobile-summary-metric-value">{formatLatency(log.latencyMs)}</div>
|
||||
</div>
|
||||
<div className="mobile-summary-metric">
|
||||
<div className="mobile-summary-metric-label">输入</div>
|
||||
<div className="mobile-summary-metric-value">{log.promptTokens?.toLocaleString() || '-'}</div>
|
||||
</div>
|
||||
<div className="mobile-summary-metric">
|
||||
<div className="mobile-summary-metric-label">输出</div>
|
||||
<div className="mobile-summary-metric-value">{log.completionTokens?.toLocaleString() || '-'}</div>
|
||||
</div>
|
||||
<div className="mobile-summary-metric">
|
||||
<div className="mobile-summary-metric-label">花费</div>
|
||||
<div className="mobile-summary-metric-value">{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
||||
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
|
||||
<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>}
|
||||
@@ -758,15 +794,6 @@ export default function ProxyLogs() {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => handleToggleExpand(log.id)}
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
</div>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import ChangeKeyModal from '../components/ChangeKeyModal.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { BrandGlyph, InlineBrandIcon, getBrand, normalizeBrandIconKey } from '../components/BrandIcon.js';
|
||||
@@ -204,6 +205,7 @@ function resolveRouteBrandSource(route: RouteSelectorItem): string {
|
||||
|
||||
export default function Settings() {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [runtime, setRuntime] = useState<RuntimeSettings>({
|
||||
checkinCron: '0 8 * * *',
|
||||
checkinScheduleMode: 'cron',
|
||||
@@ -1020,7 +1022,7 @@ export default function Settings() {
|
||||
|
||||
<div className="card animate-slide-up stagger-2" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>定时任务</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到方式</div>
|
||||
<ModernSelect
|
||||
@@ -1053,7 +1055,7 @@ export default function Settings() {
|
||||
{testingCheckin ? '触发中...' : '测试一次签到'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到 Cron</div>
|
||||
<input
|
||||
@@ -1082,7 +1084,7 @@ export default function Settings() {
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>自动清理日志</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 160px', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>清理 Cron</div>
|
||||
<input
|
||||
@@ -1384,7 +1386,7 @@ export default function Settings() {
|
||||
|
||||
<div className={`anim-collapse ${showAdvancedRouting ? 'is-open' : ''}`.trim()}>
|
||||
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
|
||||
{([
|
||||
['baseWeightFactor', '基础权重因子'],
|
||||
['valueScoreFactor', '价值分因子'],
|
||||
@@ -1430,7 +1432,7 @@ export default function Settings() {
|
||||
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 10, marginBottom: 10, alignItems: 'center' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 1fr', gap: 10, marginBottom: 10, alignItems: 'center' }}>
|
||||
<ModernSelect
|
||||
value={migrationDialect}
|
||||
onChange={(value) => setMigrationDialect(value as DbDialect)}
|
||||
@@ -1471,7 +1473,7 @@ export default function Settings() {
|
||||
/>
|
||||
) : (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 8 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr 1fr', gap: 10, marginBottom: 8 }}>
|
||||
<input
|
||||
value={shorthandConnection.host}
|
||||
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, host: e.target.value }))}
|
||||
@@ -1502,7 +1504,7 @@ export default function Settings() {
|
||||
</button>
|
||||
</div>
|
||||
{showShorthandOptional && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 8 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 10, marginBottom: 8 }}>
|
||||
<input
|
||||
value={shorthandConnection.port}
|
||||
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, port: e.target.value }))}
|
||||
|
||||
+258
-151
@@ -2,9 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import MobileBatchBar from '../components/MobileBatchBar.js';
|
||||
import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
@@ -202,7 +205,8 @@ export default function Sites() {
|
||||
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
||||
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
|
||||
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
|
||||
const isMobile = useIsMobile(768);
|
||||
const isMobile = useIsMobile();
|
||||
const [showMobileTools, setShowMobileTools] = useState(false);
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<null | {
|
||||
mode: 'single' | 'batch';
|
||||
@@ -254,6 +258,7 @@ export default function Sites() {
|
||||
() => sortItemsForDisplay(sites, sortMode, (site) => site.totalBalance || 0),
|
||||
[sites, sortMode],
|
||||
);
|
||||
const allVisibleSitesSelected = sortedSites.length > 0 && sortedSites.every((site) => selectedSiteIds.includes(site.id));
|
||||
|
||||
const platformOptions = useMemo(() => {
|
||||
const current = form.platform.trim();
|
||||
@@ -538,10 +543,10 @@ export default function Sites() {
|
||||
|
||||
const toggleSelectAllVisible = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setSelectedSiteIds([]);
|
||||
setSelectedSiteIds((current) => current.filter((id) => !sortedSites.some((site) => site.id === id)));
|
||||
return;
|
||||
}
|
||||
setSelectedSiteIds(sortedSites.map((site) => site.id));
|
||||
setSelectedSiteIds((current) => Array.from(new Set([...current, ...sortedSites.map((site) => site.id)])));
|
||||
};
|
||||
|
||||
const toggleSiteDetails = (siteId: number) => {
|
||||
@@ -614,9 +619,52 @@ export default function Sites() {
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{tr('站点管理')}</h2>
|
||||
<div className="page-actions sites-page-actions">
|
||||
<div className="sites-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobileTools(true)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
排序与操作
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sites-mobile-select-all"
|
||||
onClick={() => toggleSelectAllVisible(!allVisibleSitesSelected)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{allVisibleSitesSelected ? '取消全选' : '全选可见项'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="sites-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={sortMode}
|
||||
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
|
||||
options={[
|
||||
{ value: 'custom', label: '自定义排序' },
|
||||
{ value: 'balance-desc', label: '余额高到低' },
|
||||
{ value: 'balance-asc', label: '余额低到高' },
|
||||
]}
|
||||
placeholder="自定义排序"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={openAdd} className="btn btn-primary">
|
||||
{isAdding ? '取消' : '+ 添加站点'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileFilterSheet open={showMobileTools} onClose={() => setShowMobileTools(false)} title="站点排序与操作">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>排序方式</div>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={sortMode}
|
||||
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
|
||||
options={[
|
||||
@@ -627,11 +675,19 @@ export default function Sites() {
|
||||
placeholder="自定义排序"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={openAdd} className="btn btn-primary">
|
||||
{isAdding ? '取消' : '+ 添加站点'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggleSelectAllVisible(!allVisibleSitesSelected);
|
||||
setShowMobileTools(false);
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{allVisibleSitesSelected ? '取消全选可见项' : '全选可见项'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MobileFilterSheet>
|
||||
|
||||
{!isMobile && selectedSiteIds.length > 0 && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
@@ -666,9 +722,7 @@ export default function Sites() {
|
||||
)}
|
||||
|
||||
{isMobile && selectedSiteIds.length > 0 && (
|
||||
<div className="mobile-actions-bar">
|
||||
<span className="mobile-actions-info">已选 {selectedSiteIds.length} 项</span>
|
||||
<div className="mobile-actions-row">
|
||||
<MobileBatchBar info={`已选 ${selectedSiteIds.length} 项`}>
|
||||
<button
|
||||
data-testid="sites-batch-enable-system-proxy"
|
||||
onClick={() => runBatchAction('enableSystemProxy')}
|
||||
@@ -695,8 +749,7 @@ export default function Sites() {
|
||||
<button onClick={() => runBatchAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MobileBatchBar>
|
||||
)}
|
||||
|
||||
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||
@@ -747,54 +800,56 @@ export default function Sites() {
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
placeholder="站点名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<ResponsiveFormGrid>
|
||||
<input
|
||||
placeholder="站点 URL (例如 https://api.example.com)"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||
onBlur={() => {
|
||||
if (form.url.trim() && !form.platform.trim()) {
|
||||
handleDetect();
|
||||
}
|
||||
placeholder="站点名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<input
|
||||
placeholder="站点 URL (例如 https://api.example.com)"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||
onBlur={() => {
|
||||
if (form.url.trim() && !form.platform.trim()) {
|
||||
handleDetect();
|
||||
}
|
||||
}}
|
||||
style={{ ...formInputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDetect}
|
||||
disabled={detecting || !form.url.trim()}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '10px 14px', minWidth: 96, border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{detecting ? <><span className="spinner spinner-sm" /> 检测中</> : '自动检测'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
style={{ ...formInputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDetect}
|
||||
disabled={detecting || !form.url.trim()}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '10px 14px', minWidth: 96, border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{detecting ? <><span className="spinner spinner-sm" /> 检测中</> : '自动检测'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<ModernSelect
|
||||
value={form.platform}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
|
||||
options={platformOptions}
|
||||
placeholder="平台类型(可自动检测)"
|
||||
<ModernSelect
|
||||
value={form.platform}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
|
||||
options={platformOptions}
|
||||
placeholder="平台类型(可自动检测)"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
placeholder="外部签到/福利站点 URL(可选)"
|
||||
value={form.externalCheckinUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
placeholder="外部签到/福利站点 URL(可选)"
|
||||
value={form.externalCheckinUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
</ResponsiveFormGrid>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -920,42 +975,48 @@ export default function Sites() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
placeholder="站点代理(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||
value={form.proxyUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
|
||||
</div>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.useSystemProxy}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
|
||||
/>
|
||||
使用系统代理
|
||||
</label>
|
||||
<input
|
||||
placeholder="站点全局权重(默认 1)"
|
||||
value={form.globalWeight}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
越大越容易被路由选中。建议 0.5-3,默认 1。
|
||||
</div>
|
||||
<ResponsiveFormGrid>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<input
|
||||
placeholder="站点代理(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||
value={form.proxyUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
|
||||
</div>
|
||||
</div>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.useSystemProxy}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
|
||||
/>
|
||||
使用系统代理
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<input
|
||||
placeholder="站点全局权重(默认 1)"
|
||||
value={form.globalWeight}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
越大越容易被路由选中。建议 0.5-3,默认 1。
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveFormGrid>
|
||||
</CenteredModal>
|
||||
)}
|
||||
|
||||
@@ -968,8 +1029,29 @@ export default function Sites() {
|
||||
return (
|
||||
<MobileCard
|
||||
key={site.id}
|
||||
title={site.name || '-'}
|
||||
actions={(
|
||||
title={(
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span>{site.name || '-'}</span>
|
||||
{site.url ? (
|
||||
<a
|
||||
href={site.url}
|
||||
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.url}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
headerActions={(
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择站点 ${site.name || site.id}`}
|
||||
@@ -977,6 +1059,30 @@ export default function Sites() {
|
||||
onChange={(event) => toggleSiteSelection(site.id, event.target.checked)}
|
||||
/>
|
||||
)}
|
||||
footerActions={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSiteDetails(site.id)}
|
||||
className="btn btn-link"
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<MobileField
|
||||
label="状态"
|
||||
@@ -1007,6 +1113,27 @@ export default function Sites() {
|
||||
<MobileField label="权重" value={(site.globalWeight || 1).toFixed(2)} />
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
<MobileField
|
||||
label="主站点 URL"
|
||||
stacked
|
||||
value={site.url ? (
|
||||
<a
|
||||
href={site.url}
|
||||
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.url}
|
||||
</a>
|
||||
) : '-'}
|
||||
/>
|
||||
<MobileField
|
||||
label="系统代理"
|
||||
value={(
|
||||
@@ -1043,63 +1170,43 @@ export default function Sites() {
|
||||
label="创建时间"
|
||||
value={formatDateTimeLocal(site.createdAt)}
|
||||
/>
|
||||
<div className="mobile-card-actions">
|
||||
<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={() => handleDelete(site)}
|
||||
disabled={deleting === site.id}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -1111,7 +1218,7 @@ export default function Sites() {
|
||||
<th style={{ width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
|
||||
checked={allVisibleSitesSelected}
|
||||
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
|
||||
+114
-45
@@ -7,7 +7,7 @@ import { BrandGlyph, getBrand, InlineBrandIcon, type BrandInfo } from '../compon
|
||||
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 MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import {
|
||||
@@ -143,7 +143,7 @@ export default function TokenRoutes() {
|
||||
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
|
||||
const [expandedRouteIds, setExpandedRouteIds] = useState<number[]>([]);
|
||||
const [addChannelModalRouteId, setAddChannelModalRouteId] = useState<number | null>(null);
|
||||
const isMobile = useIsMobile(768);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
channelsByRouteId,
|
||||
@@ -1122,27 +1122,25 @@ export default function TokenRoutes() {
|
||||
>
|
||||
{tr('筛选')}
|
||||
</button>
|
||||
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
|
||||
<div className="mobile-filter-panel">
|
||||
<RouteFilterBar
|
||||
totalRouteCount={listVisibleRoutes.length}
|
||||
activeBrand={activeBrand}
|
||||
setActiveBrand={setActiveBrand}
|
||||
activeSite={activeSite}
|
||||
setActiveSite={setActiveSite}
|
||||
activeEndpointType={activeEndpointType}
|
||||
setActiveEndpointType={setActiveEndpointType}
|
||||
activeGroupFilter={activeGroupFilter}
|
||||
setActiveGroupFilter={setActiveGroupFilter}
|
||||
brandList={brandList}
|
||||
siteList={siteList}
|
||||
endpointTypeList={endpointTypeList}
|
||||
groupRouteList={groupRouteList}
|
||||
collapsed={false}
|
||||
onToggle={() => setShowFilters(false)}
|
||||
/>
|
||||
</div>
|
||||
</MobileDrawer>
|
||||
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title={tr('筛选路由')}>
|
||||
<RouteFilterBar
|
||||
totalRouteCount={listVisibleRoutes.length}
|
||||
activeBrand={activeBrand}
|
||||
setActiveBrand={setActiveBrand}
|
||||
activeSite={activeSite}
|
||||
setActiveSite={setActiveSite}
|
||||
activeEndpointType={activeEndpointType}
|
||||
setActiveEndpointType={setActiveEndpointType}
|
||||
activeGroupFilter={activeGroupFilter}
|
||||
setActiveGroupFilter={setActiveGroupFilter}
|
||||
brandList={brandList}
|
||||
siteList={siteList}
|
||||
endpointTypeList={endpointTypeList}
|
||||
groupRouteList={groupRouteList}
|
||||
collapsed={false}
|
||||
onToggle={() => setShowFilters(false)}
|
||||
/>
|
||||
</MobileFilterSheet>
|
||||
</>
|
||||
) : (
|
||||
<RouteFilterBar
|
||||
@@ -1190,32 +1188,103 @@ export default function TokenRoutes() {
|
||||
{visibleRoutes.map((route) => {
|
||||
const isExpanded = expandedRouteIds.includes(route.id);
|
||||
const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
||||
const exactRoute = isRouteExactModel(route);
|
||||
const explicitGroupRoute = isExplicitGroupRoute(route);
|
||||
const channelManagementDisabled = explicitGroupRoute;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileCard
|
||||
key={route.id}
|
||||
title={resolveRouteTitle(route)}
|
||||
actions={(
|
||||
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
|
||||
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
|
||||
</span>
|
||||
<div key={route.id} style={{ display: 'grid', gap: 8 }}>
|
||||
<MobileCard
|
||||
title={resolveRouteTitle(route)}
|
||||
headerActions={(
|
||||
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
|
||||
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
|
||||
</span>
|
||||
)}
|
||||
footerActions={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => toggleExpand(route.id)}
|
||||
>
|
||||
{isExpanded ? tr('收起') : tr('详情')}
|
||||
</button>
|
||||
{!isReadOnlyRoute && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => handleEditRoute(route)}
|
||||
>
|
||||
{tr('编辑')}
|
||||
</button>
|
||||
)}
|
||||
{!isReadOnlyRoute && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => handleToggleRouteEnabled(route)}
|
||||
>
|
||||
{route.enabled ? tr('禁用') : tr('启用')}
|
||||
</button>
|
||||
)}
|
||||
{!isReadOnlyRoute && !channelManagementDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => setAddChannelModalRouteId(route.id)}
|
||||
>
|
||||
{tr('添加通道')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<MobileField label="模型" value={route.modelPattern} stacked />
|
||||
<MobileField label="通道" value={route.channelCount} />
|
||||
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : getRouteRoutingStrategyLabel(route.routingStrategy)} />
|
||||
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
|
||||
{explicitGroupRoute && (
|
||||
<MobileField label="模式" value={tr('群组聚合')} />
|
||||
)}
|
||||
{!exactRoute && !explicitGroupRoute && (
|
||||
<MobileField label="模式" value={tr('通配符路由')} />
|
||||
)}
|
||||
</MobileCard>
|
||||
{isExpanded && (
|
||||
<RouteCard
|
||||
route={route}
|
||||
brand={routeBrandById.get(route.id) || null}
|
||||
expanded
|
||||
compact
|
||||
onToggleExpand={stableToggleExpand}
|
||||
onEdit={stableEditRoute}
|
||||
onDelete={stableDeleteRoute}
|
||||
onToggleEnabled={stableToggleEnabled}
|
||||
onRoutingStrategyChange={stableRoutingStrategyChange}
|
||||
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
|
||||
channels={channelsByRouteId[route.id]}
|
||||
loadingChannels={!!loadingChannelsByRouteId[route.id]}
|
||||
routeDecision={decisionByRoute[route.id] || null}
|
||||
loadingDecision={loadingDecision}
|
||||
candidateView={getRouteCandidateView(route.id)}
|
||||
channelTokenDraft={channelTokenDraft}
|
||||
updatingChannel={updatingChannel}
|
||||
savingPriority={!!savingPriorityByRoute[route.id]}
|
||||
onTokenDraftChange={stableTokenDraftChange}
|
||||
onSaveToken={stableChannelTokenSave}
|
||||
onDeleteChannel={stableDeleteChannel}
|
||||
onChannelDragEnd={stableChannelDragEnd}
|
||||
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
||||
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
|
||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||
onAddChannel={stableAddChannel}
|
||||
expandedSourceGroupMap={expandedSourceGroupMap}
|
||||
onToggleSourceGroup={stableToggleSourceGroup}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<MobileField label="模型" value={route.modelPattern} />
|
||||
<MobileField label="通道" value={route.channelCount} />
|
||||
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : getRouteRoutingStrategyLabel(route.routingStrategy)} />
|
||||
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => toggleExpand(route.id)}
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
</div>
|
||||
</MobileCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+258
-167
@@ -2,6 +2,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import MobileBatchBar from '../components/MobileBatchBar.js';
|
||||
import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
@@ -117,7 +120,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 isMobile = useIsMobile();
|
||||
const initialCreateForm = {
|
||||
accountId: 0,
|
||||
name: '',
|
||||
@@ -145,6 +148,8 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
const [pendingAutoOpenTokenId, setPendingAutoOpenTokenId] = useState<number | null>(null);
|
||||
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
|
||||
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
|
||||
const [expandedTokenIds, setExpandedTokenIds] = useState<number[]>([]);
|
||||
const [showMobileTools, setShowMobileTools] = useState(false);
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<null | {
|
||||
mode: 'single' | 'batch';
|
||||
@@ -303,6 +308,8 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
return Number(left?.id || 0) - Number(right?.id || 0);
|
||||
});
|
||||
}, [tokens]);
|
||||
const allVisibleTokensSelected = accountClusteredTokens.length > 0
|
||||
&& accountClusteredTokens.every((token) => selectedTokenIds.includes(token.id));
|
||||
|
||||
const activeAccounts = useMemo(() => accounts.filter(isAccountSyncable), [accounts]);
|
||||
|
||||
@@ -396,10 +403,18 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
|
||||
const toggleSelectAllTokens = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setSelectedTokenIds([]);
|
||||
setSelectedTokenIds((current) => current.filter((id) => !accountClusteredTokens.some((token) => token.id === id)));
|
||||
return;
|
||||
}
|
||||
setSelectedTokenIds(accountClusteredTokens.map((token) => token.id));
|
||||
setSelectedTokenIds((current) => Array.from(new Set([...current, ...accountClusteredTokens.map((token) => token.id)])));
|
||||
};
|
||||
|
||||
const toggleTokenDetails = (tokenId: number) => {
|
||||
setExpandedTokenIds((current) => (
|
||||
current.includes(tokenId)
|
||||
? current.filter((id) => id !== tokenId)
|
||||
: [...current, tokenId]
|
||||
));
|
||||
};
|
||||
|
||||
const runBatchTokenAction = async (action: 'enable' | 'disable' | 'delete', skipDeleteConfirm = false) => {
|
||||
@@ -748,37 +763,61 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<div className={`page-actions ${embedded ? 'accounts-page-actions' : ''}`.trim()}>
|
||||
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(syncingAccountId || 0)}
|
||||
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
|
||||
options={[
|
||||
{ value: '0', label: '选择账号后同步站点令牌' },
|
||||
...activeAccounts.map((account) => ({
|
||||
value: String(account.id),
|
||||
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
|
||||
})),
|
||||
]}
|
||||
placeholder="选择账号后同步站点令牌"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing || syncingAll || !syncingAccountId}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncAll}
|
||||
disabled={syncing || syncingAll || activeAccounts.length === 0}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobileTools(true)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
同步与筛选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tokens-mobile-select-all"
|
||||
onClick={() => toggleSelectAllTokens(!allVisibleTokensSelected)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{allVisibleTokensSelected ? '取消全选' : '全选可见项'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(syncingAccountId || 0)}
|
||||
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
|
||||
options={[
|
||||
{ value: '0', label: '选择账号后同步站点令牌' },
|
||||
...activeAccounts.map((account) => ({
|
||||
value: String(account.id),
|
||||
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
|
||||
})),
|
||||
]}
|
||||
placeholder="选择账号后同步站点令牌"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing || syncingAll || !syncingAccountId}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncAll}
|
||||
disabled={syncing || syncingAll || activeAccounts.length === 0}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleToggleAdd}
|
||||
className="btn btn-primary"
|
||||
@@ -786,7 +825,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
{showAdd ? '取消' : '+ 新增令牌'}
|
||||
</button>
|
||||
</div>
|
||||
), [activeAccounts, embedded, handleSync, handleSyncAll, handleToggleAdd, showAdd, syncing, syncingAccountId, syncingAll]);
|
||||
), [activeAccounts, allVisibleTokensSelected, embedded, handleSync, handleSyncAll, handleToggleAdd, isMobile, showAdd, syncing, syncingAccountId, syncingAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embedded || !onEmbeddedActionsChange) return;
|
||||
@@ -805,6 +844,42 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MobileFilterSheet open={showMobileTools} onClose={() => setShowMobileTools(false)} title="令牌同步与筛选">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>同步账号</div>
|
||||
<ModernSelect
|
||||
value={String(syncingAccountId || 0)}
|
||||
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
|
||||
options={[
|
||||
{ value: '0', label: '选择账号后同步站点令牌' },
|
||||
...activeAccounts.map((account) => ({
|
||||
value: String(account.id),
|
||||
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
|
||||
})),
|
||||
]}
|
||||
placeholder="选择账号后同步站点令牌"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing || syncingAll || !syncingAccountId}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncAll}
|
||||
disabled={syncing || syncingAll || activeAccounts.length === 0}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
|
||||
</button>
|
||||
</div>
|
||||
</MobileFilterSheet>
|
||||
|
||||
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
|
||||
</div>
|
||||
@@ -866,48 +941,50 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
) : null}
|
||||
<div style={sectionCardStyle}>
|
||||
<div style={sectionLabelStyle}>基本信息</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称</div>
|
||||
<input
|
||||
placeholder="令牌名称"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌值</div>
|
||||
<textarea
|
||||
placeholder={editingTokenValueLoading ? '令牌加载中...' : '令牌值'}
|
||||
value={editForm.token}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, token: e.target.value }))}
|
||||
style={{
|
||||
...inputStyle,
|
||||
minHeight: 96,
|
||||
resize: 'vertical',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
disabled={editingTokenValueLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>分组</div>
|
||||
<ModernSelect
|
||||
value={editForm.group || 'default'}
|
||||
onChange={(nextValue) => setEditForm((prev) => ({ ...prev, group: nextValue || 'default' }))}
|
||||
options={(editGroupOptions.length > 0 ? editGroupOptions : ['default']).map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))}
|
||||
placeholder={editGroupLoading ? '分组加载中...' : '选择分组'}
|
||||
disabled={editGroupLoading}
|
||||
/>
|
||||
</div>
|
||||
<ResponsiveFormGrid>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称</div>
|
||||
<input
|
||||
placeholder="令牌名称"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>分组</div>
|
||||
<ModernSelect
|
||||
value={editForm.group || 'default'}
|
||||
onChange={(nextValue) => setEditForm((prev) => ({ ...prev, group: nextValue || 'default' }))}
|
||||
options={(editGroupOptions.length > 0 ? editGroupOptions : ['default']).map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))}
|
||||
placeholder={editGroupLoading ? '分组加载中...' : '选择分组'}
|
||||
disabled={editGroupLoading}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌值</div>
|
||||
<textarea
|
||||
placeholder={editingTokenValueLoading ? '令牌加载中...' : '令牌值'}
|
||||
value={editForm.token}
|
||||
onChange={(e) => setEditForm((prev) => ({ ...prev, token: e.target.value }))}
|
||||
style={{
|
||||
...inputStyle,
|
||||
minHeight: 96,
|
||||
resize: 'vertical',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
disabled={editingTokenValueLoading}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveFormGrid>
|
||||
</div>
|
||||
<div style={sectionCardStyle}>
|
||||
<div style={sectionLabelStyle}>状态设置</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
|
||||
<ResponsiveFormGrid>
|
||||
<label style={toggleCardStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -932,7 +1009,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>优先作为该账号的默认转发令牌</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</ResponsiveFormGrid>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
@@ -954,9 +1031,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
)}
|
||||
|
||||
{isMobile && selectedTokenIds.length > 0 && (
|
||||
<div className="mobile-actions-bar">
|
||||
<span className="mobile-actions-info">已选 {selectedTokenIds.length} 项</span>
|
||||
<div className="mobile-actions-row">
|
||||
<MobileBatchBar info={`已选 ${selectedTokenIds.length} 项`}>
|
||||
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
批量启用
|
||||
</button>
|
||||
@@ -966,8 +1041,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
<button data-testid="tokens-batch-delete" onClick={() => runBatchTokenAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MobileBatchBar>
|
||||
)}
|
||||
|
||||
<CenteredModal
|
||||
@@ -977,8 +1051,8 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
maxWidth={820}
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div style={{ gridColumn: '1 / span 2' }}>
|
||||
<ResponsiveFormGrid>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>所属账号</div>
|
||||
<ModernSelect
|
||||
value={String(form.accountId || 0)}
|
||||
@@ -1002,7 +1076,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
{createHintModelName ? (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: '1 / span 2',
|
||||
gridColumn: '1 / -1',
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
background: 'color-mix(in srgb, var(--color-info) 10%, var(--color-bg))',
|
||||
@@ -1036,7 +1110,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
disabled={!form.accountId || groupLoading}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1072,10 +1146,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
将在选中账号所属站点直接创建新密钥
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveFormGrid>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
|
||||
<button onClick={handleToggleAdd} className="btn btn-ghost">取消</button>
|
||||
@@ -1102,11 +1176,12 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
{accountClusteredTokens.map((token: any) => {
|
||||
const loadingPrefix = `token-${token.id}`;
|
||||
const isPending = isMaskedPendingToken(token);
|
||||
const isExpanded = expandedTokenIds.includes(token.id);
|
||||
return (
|
||||
<MobileCard
|
||||
key={token.id}
|
||||
title={token.name || '-'}
|
||||
actions={(
|
||||
headerActions={(
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择令牌 ${token.name || token.id}`}
|
||||
@@ -1114,30 +1189,34 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
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"
|
||||
footerActions={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTokenDetails(token.id)}
|
||||
className="btn btn-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>
|
||||
)}
|
||||
/>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
{!isPending ? (
|
||||
<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>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => openEditPanel(token)}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isPending ? '编辑补全' : '编辑'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<MobileField label="账号" value={token.account?.username || `account-${token.accountId}`} />
|
||||
<MobileField label="分组" value={token.tokenGroup || 'default'} />
|
||||
<MobileField
|
||||
@@ -1148,62 +1227,74 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
</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">
|
||||
{!isPending && !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>
|
||||
)}
|
||||
{!isPending ? (
|
||||
<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>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => openEditPanel(token)}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isPending ? '编辑补全' : '编辑'}
|
||||
</button>
|
||||
{!isPending ? (
|
||||
<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>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
|
||||
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
{rowLoading[`${loadingPrefix}-delete`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
<MobileField
|
||||
label="令牌值"
|
||||
stacked
|
||||
value={<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, wordBreak: 'break-all' }}>{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.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}>默认</span> : '-'}
|
||||
/>
|
||||
<MobileField label="更新时间" value={formatDateTimeLocal(token.updatedAt)} />
|
||||
<div className="mobile-card-actions">
|
||||
{!isPending && !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>
|
||||
)}
|
||||
{!isPending ? (
|
||||
<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>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
|
||||
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
{rowLoading[`${loadingPrefix}-delete`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
@@ -1215,7 +1306,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
<th style={{ width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accountClusteredTokens.length > 0 && selectedTokenIds.length === accountClusteredTokens.length}
|
||||
checked={allVisibleTokensSelected}
|
||||
onChange={(e) => toggleSelectAllTokens(e.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
batchUpdateAccounts: vi.fn(),
|
||||
refreshAccountHealth: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/useIsMobile.js', () => ({
|
||||
useIsMobile: () => true,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function collectText(node: any): string {
|
||||
return (node.children || []).map((child: any) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findButtonByText(root: any, text: string) {
|
||||
return root.find((node: any) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes(text)
|
||||
));
|
||||
}
|
||||
|
||||
describe('Accounts mobile actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
siteId: 1,
|
||||
username: 'alpha',
|
||||
accessToken: 'session-alpha',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
siteId: 1,
|
||||
username: 'beta',
|
||||
accessToken: 'session-beta',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
]);
|
||||
apiMock.batchUpdateAccounts.mockResolvedValue({
|
||||
success: true,
|
||||
successIds: [1, 2],
|
||||
failedItems: [],
|
||||
});
|
||||
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('supports select-all-visible from the mobile toolbar', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'accounts-mobile-select-all');
|
||||
await act(async () => {
|
||||
selectAllButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const batchButton = root.root.find((node) => node.props['data-testid'] === 'accounts-batch-refresh-balance');
|
||||
await act(async () => {
|
||||
batchButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.batchUpdateAccounts).toHaveBeenCalledWith({
|
||||
ids: [1, 2],
|
||||
action: 'refreshBalance',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears only the visible segment selection when toggling mobile select-all off', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
siteId: 1,
|
||||
username: 'alpha',
|
||||
accessToken: 'session-alpha',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
siteId: 1,
|
||||
username: 'beta',
|
||||
accessToken: '',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||
},
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const sessionCheckbox = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.type === 'checkbox'
|
||||
&& node.props['aria-label'] === '选择账号 alpha'
|
||||
));
|
||||
await act(async () => {
|
||||
sessionCheckbox.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const apiKeySegmentButton = findButtonByText(root.root, 'API Key管理');
|
||||
await act(async () => {
|
||||
apiKeySegmentButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'accounts-mobile-select-all');
|
||||
await act(async () => {
|
||||
selectAllButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(collectText(root.root)).toContain('已选 2 项');
|
||||
|
||||
const clearVisibleButton = root.root.find((node) => node.props['data-testid'] === 'accounts-mobile-select-all');
|
||||
await act(async () => {
|
||||
clearVisibleButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('已选 1 项');
|
||||
|
||||
const batchButton = root.root.find((node) => node.props['data-testid'] === 'accounts-batch-refresh-balance');
|
||||
await act(async () => {
|
||||
batchButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.batchUpdateAccounts).toHaveBeenLastCalledWith({
|
||||
ids: [1],
|
||||
action: 'refreshBalance',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('CheckinLog mobile layout', () => {
|
||||
it('uses the shared mobile filter sheet for mobile filters', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/CheckinLog.tsx'), 'utf8');
|
||||
expect(source).toContain('MobileFilterSheet');
|
||||
});
|
||||
|
||||
it('uses the shared mobile card header and footer action slots', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/CheckinLog.tsx'), 'utf8');
|
||||
expect(source).toContain('const isMobile = useIsMobile();');
|
||||
expect(source).toContain('headerActions={');
|
||||
expect(source).toContain('footerActions={');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Dashboard mobile layout', () => {
|
||||
it('uses the shared mobile breakpoint to collapse fixed desktop grids', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Dashboard.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'");
|
||||
expect(source).toContain('const isMobile = useIsMobile()');
|
||||
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr'");
|
||||
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 300px'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import DownstreamKeys from './DownstreamKeys.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getDownstreamApiKeysSummary: vi.fn(),
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getDownstreamApiKeyOverview: vi.fn(),
|
||||
getDownstreamApiKeyTrend: vi.fn(),
|
||||
createDownstreamApiKey: vi.fn(),
|
||||
batchDownstreamApiKeys: vi.fn(),
|
||||
updateDownstreamApiKey: vi.fn(),
|
||||
deleteDownstreamApiKey: vi.fn(),
|
||||
resetDownstreamApiKeyUsage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({ api: apiMock }));
|
||||
|
||||
vi.mock('react-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
|
||||
return {
|
||||
...actual,
|
||||
createPortal: (node: unknown) => node,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../components/useAnimatedVisibility.js', () => ({
|
||||
useAnimatedVisibility: (open: boolean) => ({
|
||||
shouldRender: open,
|
||||
isVisible: open,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/useIsMobile.js', () => ({
|
||||
useIsMobile: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
|
||||
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
|
||||
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ModernSelect.js', () => ({
|
||||
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
function buildSummaryItem(id: number, overrides?: Partial<any>) {
|
||||
return {
|
||||
id,
|
||||
name: `mobile-key-${id}`,
|
||||
keyMasked: `sk-m****0${id}`,
|
||||
enabled: true,
|
||||
description: `mobile item ${id}`,
|
||||
groupName: '项目A',
|
||||
tags: ['移动端'],
|
||||
expiresAt: null,
|
||||
maxCost: null,
|
||||
usedCost: 0,
|
||||
maxRequests: null,
|
||||
usedRequests: 0,
|
||||
supportedModels: ['gpt-4.1-mini'],
|
||||
allowedRouteIds: [11],
|
||||
siteWeightMultipliers: {},
|
||||
lastUsedAt: '2026-03-15T08:27:25.378Z',
|
||||
createdAt: '2026-03-15T08:27:25.378Z',
|
||||
updatedAt: '2026-03-15T08:27:25.378Z',
|
||||
rangeUsage: {
|
||||
totalRequests: 3,
|
||||
successRequests: 2,
|
||||
failedRequests: 1,
|
||||
successRate: 66.7,
|
||||
totalTokens: 4200,
|
||||
totalCost: 0.42,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRawItem(id: number, overrides?: Partial<any>) {
|
||||
return {
|
||||
id,
|
||||
name: `mobile-key-${id}`,
|
||||
key: `sk-mobile-0${id}`,
|
||||
keyMasked: `sk-m****0${id}`,
|
||||
description: `mobile item ${id}`,
|
||||
groupName: '项目A',
|
||||
tags: ['移动端'],
|
||||
enabled: true,
|
||||
expiresAt: null,
|
||||
maxCost: null,
|
||||
usedCost: 0,
|
||||
maxRequests: null,
|
||||
usedRequests: 0,
|
||||
supportedModels: ['gpt-4.1-mini'],
|
||||
allowedRouteIds: [11],
|
||||
siteWeightMultipliers: {},
|
||||
lastUsedAt: '2026-03-15T08:27:25.378Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node) === text
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('DownstreamKeys mobile layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(globalThis as any).document = {
|
||||
body: { style: {} },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
|
||||
success: true,
|
||||
items: [buildSummaryItem(1), buildSummaryItem(2)],
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({
|
||||
success: true,
|
||||
items: [buildRawItem(1), buildRawItem(2)],
|
||||
});
|
||||
apiMock.getRoutesLite.mockResolvedValue([
|
||||
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
|
||||
]);
|
||||
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
|
||||
success: true,
|
||||
item: buildSummaryItem(1),
|
||||
usage: {
|
||||
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
|
||||
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
|
||||
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
|
||||
},
|
||||
});
|
||||
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
|
||||
success: true,
|
||||
buckets: [],
|
||||
});
|
||||
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
|
||||
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1, 2], failedItems: [] });
|
||||
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
|
||||
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
|
||||
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete (globalThis as any).document;
|
||||
});
|
||||
|
||||
it('renders mobile cards and supports select-all-visible batch actions', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/downstream-keys']}>
|
||||
<ToastProvider>
|
||||
<DownstreamKeys />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root!.root)).toContain('筛选');
|
||||
expect(collectText(root!.root)).toContain('全选可见');
|
||||
|
||||
const mobileCards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
|
||||
expect(mobileCards).toHaveLength(2);
|
||||
|
||||
const selectAllButton = findButtonByText(root!.root, '全选可见');
|
||||
await act(async () => {
|
||||
selectAllButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const batchEnableButton = findButtonByText(root!.root, '启用');
|
||||
await act(async () => {
|
||||
batchEnableButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
|
||||
ids: [1, 2],
|
||||
action: 'enable',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
SiteAnnouncementContent,
|
||||
@@ -8,36 +7,11 @@ import {
|
||||
resolveSiteAnnouncementTimeZone,
|
||||
} from './siteAnnouncementPresentation.js';
|
||||
|
||||
type BrowserGlobals = {
|
||||
window?: Window & typeof globalThis;
|
||||
document?: Document;
|
||||
DOMParser?: typeof DOMParser;
|
||||
Node?: typeof Node;
|
||||
};
|
||||
|
||||
const browserGlobals = globalThis as typeof globalThis & BrowserGlobals;
|
||||
const originalWindow = browserGlobals.window;
|
||||
const originalDocument = browserGlobals.document;
|
||||
const originalDOMParser = browserGlobals.DOMParser;
|
||||
const originalNode = browserGlobals.Node;
|
||||
const hasDomSanitizerSupport = typeof DOMParser === 'function' && typeof Node !== 'undefined';
|
||||
const itWithDomSupport = hasDomSanitizerSupport ? it : it.skip;
|
||||
|
||||
describe('siteAnnouncementPresentation helpers', () => {
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
||||
browserGlobals.window = dom.window as unknown as Window & typeof globalThis;
|
||||
browserGlobals.document = dom.window.document;
|
||||
browserGlobals.DOMParser = dom.window.DOMParser;
|
||||
browserGlobals.Node = dom.window.Node;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
browserGlobals.window = originalWindow;
|
||||
browserGlobals.document = originalDocument;
|
||||
browserGlobals.DOMParser = originalDOMParser;
|
||||
browserGlobals.Node = originalNode;
|
||||
});
|
||||
|
||||
it('renders sanitized html notices with safe links', () => {
|
||||
itWithDomSupport('renders sanitized html notices with safe links', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SiteAnnouncementContent
|
||||
content={[
|
||||
@@ -59,7 +33,7 @@ describe('siteAnnouncementPresentation helpers', () => {
|
||||
expect(markup).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
it('renders markdown notices as structured content', () => {
|
||||
itWithDomSupport('renders markdown notices as structured content', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SiteAnnouncementContent
|
||||
content={[
|
||||
|
||||
@@ -3,8 +3,12 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('ProxyLogs mobile layout', () => {
|
||||
it('renders mobile cards for proxy logs', () => {
|
||||
it('renders compact mobile summary cards for proxy logs', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProxyLogs.tsx'), 'utf8');
|
||||
expect(source).toContain('MobileCard');
|
||||
expect(source).toContain('MobileFilterSheet');
|
||||
expect(source).toContain('compact');
|
||||
expect(source).toContain('mobile-summary-grid');
|
||||
expect(source).toContain("subtitle={formatDateTimeLocal(log.createdAt)}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,27 @@ import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
function findRuleValue(source: string, selector: string, property: string): number | null {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = source.match(new RegExp(`${escapedSelector}\\s*\\{[^}]*${property}:\\s*([^;]+)`, 'm'));
|
||||
if (!match) return null;
|
||||
const rawValue = match[1].trim();
|
||||
const directNumber = rawValue.match(/^(\d+)$/);
|
||||
if (directNumber) return Number(directNumber[1]);
|
||||
const variableName = rawValue.match(/^var\((--[^)]+)\)$/);
|
||||
if (!variableName) return null;
|
||||
const variableMatch = source.match(new RegExp(`${variableName[1]}:\\s*(\\d+)`, 'm'));
|
||||
return variableMatch ? Number(variableMatch[1]) : null;
|
||||
}
|
||||
|
||||
describe('Mobile actions bar styles', () => {
|
||||
it('defines .mobile-actions-bar in index.css', () => {
|
||||
it('keeps overlay layers above the mobile batch bar', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
expect(css).toContain('.mobile-actions-bar');
|
||||
const batchBarZIndex = findRuleValue(css, '.mobile-actions-bar', 'z-index');
|
||||
const modalBackdropZIndex = findRuleValue(css, '.modal-backdrop', 'z-index');
|
||||
|
||||
expect(batchBarZIndex).not.toBeNull();
|
||||
expect(modalBackdropZIndex).not.toBeNull();
|
||||
expect(modalBackdropZIndex!).toBeGreaterThan(batchBarZIndex!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Mobile layout shell styles', () => {
|
||||
it('allows page actions and pagination to wrap on narrow screens', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.page-actions {\n width: 100%;');
|
||||
expect(css).toContain('.page-actions {\n width: 100%;\n flex-wrap: wrap;');
|
||||
expect(css).toContain('.pagination {\n flex-wrap: wrap;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Mobile layout utilities', () => {
|
||||
it('allows page actions and pagination controls to wrap on narrow screens', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(css).toMatch(/\.page-actions\s*\{[^}]*flex-wrap:\s*wrap/s);
|
||||
expect(css).toMatch(/\.pagination\s*\{[^}]*flex-wrap:\s*wrap/s);
|
||||
expect(css).toMatch(/\.pagination-size\s*\{[^}]*flex-wrap:\s*wrap/s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('ModelTester mobile layout', () => {
|
||||
it('switches the playground shell into a true single-column mobile layout', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ModelTester.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'");
|
||||
expect(source).toContain('const isMobile = useIsMobile()');
|
||||
expect(source).toContain("const layoutColumns = isMobile");
|
||||
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 160px'");
|
||||
expect(source).toContain("flexDirection: isMobile ? 'column' : 'row'");
|
||||
expect(source).toContain("order: isMobile ? 1 : 0");
|
||||
expect(source).toContain("order: isMobile ? 2 : 0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Models from './Models.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getModelsMarketplace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Models mobile layout', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalMutationObserver = globalThis.MutationObserver;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalMatchMedia = globalThis.matchMedia;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = {
|
||||
documentElement: {
|
||||
getAttribute: () => 'light',
|
||||
},
|
||||
} as unknown as Document;
|
||||
globalThis.MutationObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof MutationObserver;
|
||||
const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis;
|
||||
nextWindow.innerWidth = 768;
|
||||
nextWindow.addEventListener = nextWindow.addEventListener || (() => {});
|
||||
nextWindow.removeEventListener = nextWindow.removeEventListener || (() => {});
|
||||
nextWindow.matchMedia = (() => ({
|
||||
matches: true,
|
||||
media: '(max-width: 768px)',
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as typeof window.matchMedia;
|
||||
globalThis.window = nextWindow;
|
||||
globalThis.matchMedia = nextWindow.matchMedia;
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
name: 'gpt-4o',
|
||||
accountCount: 2,
|
||||
tokenCount: 3,
|
||||
avgLatency: 420,
|
||||
successRate: 96,
|
||||
description: '旗舰聊天模型',
|
||||
tags: ['chat'],
|
||||
supportedEndpointTypes: ['openai'],
|
||||
pricingSources: [],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
site: '站点 A',
|
||||
username: 'alice',
|
||||
latency: 320,
|
||||
balance: 12.5,
|
||||
tokens: [{ id: 1, name: 'default', isDefault: true }],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
site: '站点 B',
|
||||
username: 'bob',
|
||||
latency: 540,
|
||||
balance: 8.4,
|
||||
tokens: [{ id: 2, name: 'backup', isDefault: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.MutationObserver = originalMutationObserver;
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
it('keeps a mobile filter entry and hides the table-view toggle on small screens', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root!.root)).toContain('筛选');
|
||||
expect(root!.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '表格视图'
|
||||
))).toHaveLength(0);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders stacked account detail cards instead of tables when a mobile card expands', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const modelCard = root!.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('model-card')
|
||||
&& typeof node.props.onClick === 'function'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
modelCard.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root!.root)).toContain('账号明细');
|
||||
expect(collectText(root!.root)).toContain('站点 A');
|
||||
expect(collectText(root!.root)).toContain('alice');
|
||||
expect(root!.root.findAll((node) => node.type === 'table')).toHaveLength(0);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('OAuthManagement mobile layout', () => {
|
||||
it('allows narrow screens to wrap provider and connection action rows', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/OAuthManagement.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("flexWrap: 'wrap'");
|
||||
expect(source).toContain("justifyContent: 'space-between'");
|
||||
expect(source).toContain("minWidth: 0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('ProgramLogs mobile layout', () => {
|
||||
it('uses the shared mobile primitives for filters and list cards', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProgramLogs.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import MobileFilterSheet from '../components/MobileFilterSheet.js'");
|
||||
expect(source).toContain("import { MobileCard, MobileField } from '../components/MobileCard.js'");
|
||||
expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'");
|
||||
expect(source).toContain('const isMobile = useIsMobile()');
|
||||
expect(source).toContain('mobile-card-list');
|
||||
expect(source).toContain('<MobileFilterSheet');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import ProgramLogs from './ProgramLogs.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getEvents: vi.fn(),
|
||||
markEventRead: vi.fn(),
|
||||
markAllEventsRead: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/useIsMobile.js', () => ({
|
||||
useIsMobile: () => true,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes(text)
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ProgramLogs mobile layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
type: 'status',
|
||||
title: '同步完成',
|
||||
message: '成功 3,失败 0',
|
||||
level: 'info',
|
||||
read: false,
|
||||
createdAt: '2026-03-04T06:43:03.000Z',
|
||||
},
|
||||
]);
|
||||
apiMock.markEventRead.mockResolvedValue({ success: true });
|
||||
apiMock.markAllEventsRead.mockResolvedValue({ success: true });
|
||||
apiMock.clearEvents.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders mobile cards instead of only the desktop table on small screens', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/events']}>
|
||||
<ToastProvider>
|
||||
<ProgramLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const cards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
expect(collectText(root!.root)).toContain('同步完成');
|
||||
expect(collectText(root!.root)).toContain('筛选');
|
||||
expect(collectText(root!.root)).toContain('标记已读');
|
||||
|
||||
const markReadButton = findButtonByText(root!.root, '标记已读');
|
||||
await act(async () => {
|
||||
markReadButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.markEventRead).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Settings mobile layout', () => {
|
||||
it('collapses fixed form grids behind the shared mobile breakpoint', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'");
|
||||
expect(source).toContain('const isMobile = useIsMobile()');
|
||||
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto'");
|
||||
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr'");
|
||||
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr 1fr'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Sites from './Sites.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
batchUpdateSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/useIsMobile.js', () => ({
|
||||
useIsMobile: () => true,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Sites mobile actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Site A',
|
||||
url: 'https://a.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Site B',
|
||||
url: 'https://b.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
},
|
||||
]);
|
||||
apiMock.batchUpdateSites.mockResolvedValue({
|
||||
success: true,
|
||||
successIds: [1, 2],
|
||||
failedItems: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('supports select-all-visible and preserves the primary site url in mobile cards', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/sites']}>
|
||||
<ToastProvider>
|
||||
<Sites />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'sites-mobile-select-all');
|
||||
await act(async () => {
|
||||
selectAllButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(Array.isArray(selectAllButton.children) ? selectAllButton.children.join('') : '').toContain('取消全选');
|
||||
|
||||
const clearVisibleButton = root.root.find((node) => node.props['data-testid'] === 'sites-mobile-select-all');
|
||||
await act(async () => {
|
||||
clearVisibleButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(Array.isArray(clearVisibleButton.children) ? clearVisibleButton.children.join('') : '').toContain('全选可见项');
|
||||
|
||||
const reselectVisibleButton = root.root.find((node) => node.props['data-testid'] === 'sites-mobile-select-all');
|
||||
await act(async () => {
|
||||
reselectVisibleButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const batchButton = root.root.find((node) => node.props['data-testid'] === 'sites-batch-enable-system-proxy');
|
||||
await act(async () => {
|
||||
batchButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.batchUpdateSites).toHaveBeenCalledWith({
|
||||
ids: [1, 2],
|
||||
action: 'enableSystemProxy',
|
||||
});
|
||||
|
||||
const primaryLink = root.root.find((node) => node.type === 'a' && node.props.href === 'https://a.example.com');
|
||||
expect(primaryLink.props.target).toBe('_blank');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ type RouteCardProps = {
|
||||
route: RouteSummaryRow;
|
||||
brand: BrandInfo | null;
|
||||
expanded: boolean;
|
||||
compact?: boolean;
|
||||
onToggleExpand: (routeId: number) => void;
|
||||
onEdit: (route: RouteSummaryRow) => void;
|
||||
onDelete: (routeId: number) => void;
|
||||
@@ -100,6 +101,7 @@ function RouteCardInner({
|
||||
route,
|
||||
brand,
|
||||
expanded,
|
||||
compact = false,
|
||||
onToggleExpand,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -263,86 +265,127 @@ function RouteCardInner({
|
||||
|
||||
// Expanded card
|
||||
return (
|
||||
<div className="card route-card-expanded" style={{ padding: 16 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<code style={{ fontWeight: 600, fontSize: 13, background: 'var(--color-bg)', padding: '4px 10px', borderRadius: 6, color: 'var(--color-text-primary)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
{routeIcon.kind === 'brand' ? (
|
||||
<BrandGlyph icon={routeIcon.value} alt={title} size={20} fallbackText={title} />
|
||||
) : routeIcon.kind === 'text' ? (
|
||||
<span style={{ width: 20, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: 'var(--color-bg-card)', fontSize: 14, lineHeight: 1 }}>
|
||||
{routeIcon.value}
|
||||
</span>
|
||||
) : routeIcon.kind === 'auto' && brand ? (
|
||||
<BrandGlyph brand={brand} alt={title} size={20} fallbackText={title} />
|
||||
) : routeIcon.kind === 'auto' ? (
|
||||
<InlineBrandIcon model={route.modelPattern} size={20} />
|
||||
<div className={`card route-card-expanded ${compact ? 'route-card-expanded-compact' : ''}`.trim()} style={{ padding: compact ? 14 : 16 }}>
|
||||
{!compact ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<code style={{ fontWeight: 600, fontSize: 13, background: 'var(--color-bg)', padding: '4px 10px', borderRadius: 6, color: 'var(--color-text-primary)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
{routeIcon.kind === 'brand' ? (
|
||||
<BrandGlyph icon={routeIcon.value} alt={title} size={20} fallbackText={title} />
|
||||
) : routeIcon.kind === 'text' ? (
|
||||
<span style={{ width: 20, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: 'var(--color-bg-card)', fontSize: 14, lineHeight: 1 }}>
|
||||
{routeIcon.value}
|
||||
</span>
|
||||
) : routeIcon.kind === 'auto' && brand ? (
|
||||
<BrandGlyph brand={brand} alt={title} size={20} fallbackText={title} />
|
||||
) : routeIcon.kind === 'auto' ? (
|
||||
<InlineBrandIcon model={route.modelPattern} size={20} />
|
||||
) : null}
|
||||
{title}
|
||||
</code>
|
||||
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
|
||||
) : null}
|
||||
{title}
|
||||
</code>
|
||||
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
|
||||
) : null}
|
||||
{readOnlyRoute ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{tr('未生成')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
|
||||
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
|
||||
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
|
||||
>
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
</button>
|
||||
)}
|
||||
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
|
||||
<>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{explicitGroupSourceCount} {tr('来源模型')}
|
||||
</span>
|
||||
{readOnlyRoute ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{tr('未生成')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
|
||||
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
|
||||
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
|
||||
>
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
</button>
|
||||
)}
|
||||
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
|
||||
<>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{explicitGroupSourceCount} {tr('来源模型')}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{readOnlyRoute && (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>
|
||||
{tr('0 通道')}
|
||||
</span>
|
||||
)}
|
||||
{savingPriority && (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
|
||||
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
|
||||
)}
|
||||
{!readOnlyRoute && <button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>}
|
||||
<button
|
||||
onClick={() => onToggleExpand(route.id)}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '4px 8px', border: '1px solid var(--color-border)' }}
|
||||
data-tooltip={tr('收起')}
|
||||
>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="m5 7 5 6 5-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
{tr('路由详情与通道管理')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
{!readOnlyRoute && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{!exactRoute && (
|
||||
<button onClick={() => onEdit(route)} className="btn btn-link">{explicitGroupRoute ? tr('编辑群组') : tr('编辑路由')}</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
{readOnlyRoute ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>{tr('未生成')}</span>
|
||||
) : (
|
||||
<span className={`badge ${route.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 10 }}>
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
</span>
|
||||
)}
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
)}
|
||||
{readOnlyRoute && (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>
|
||||
{tr('0 通道')}
|
||||
</span>
|
||||
)}
|
||||
{savingPriority && (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span>
|
||||
)}
|
||||
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{explicitGroupSourceCount} {tr('来源模型')}
|
||||
</span>
|
||||
) : null}
|
||||
{savingPriority ? <span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
|
||||
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
|
||||
)}
|
||||
{!readOnlyRoute && <button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>}
|
||||
<button
|
||||
onClick={() => onToggleExpand(route.id)}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '4px 8px', border: '1px solid var(--color-border)' }}
|
||||
data-tooltip={tr('收起')}
|
||||
>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="m5 7 5 6 5-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explicitGroupRoute ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
@@ -355,11 +398,11 @@ function RouteCardInner({
|
||||
) : null}
|
||||
|
||||
{!readOnlyRoute && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: compact ? 'stretch' : 'center', flexDirection: compact ? 'column' : 'row', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', minWidth: compact ? '100%' : undefined }}>
|
||||
{tr('路由策略')}
|
||||
</div>
|
||||
<div style={{ minWidth: 220, maxWidth: 320, flex: '1 1 220px' }}>
|
||||
<div style={{ minWidth: compact ? '100%' : 220, maxWidth: compact ? '100%' : 320, flex: compact ? '1 1 100%' : '1 1 220px', width: compact ? '100%' : undefined }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={routingStrategy}
|
||||
@@ -374,7 +417,7 @@ function RouteCardInner({
|
||||
)}
|
||||
|
||||
{/* Missing token hints + Add channel button */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: compact ? 'stretch' : 'flex-start', flexDirection: compact ? 'column' : 'row', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
{!channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
|
||||
{missingTokenSiteItems.length > 0 && (
|
||||
@@ -417,7 +460,7 @@ function RouteCardInner({
|
||||
<button
|
||||
onClick={() => onAddChannel(route.id)}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}
|
||||
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: compact ? 'normal' : 'nowrap', width: compact ? '100%' : 'auto' }}
|
||||
>
|
||||
+ {tr('添加通道')}
|
||||
</button>
|
||||
@@ -498,6 +541,7 @@ function RouteCardInner({
|
||||
loadingDecision={loadingDecision}
|
||||
isSavingPriority={!!savingPriority}
|
||||
readOnly
|
||||
mobile={compact}
|
||||
tokenOptions={tokenOptions}
|
||||
activeTokenId={activeTokenId}
|
||||
isUpdatingToken={!!updatingChannel[channel.id]}
|
||||
@@ -521,6 +565,7 @@ function RouteCardInner({
|
||||
isExactRoute={exactRoute}
|
||||
loadingDecision={loadingDecision}
|
||||
isSavingPriority={savingPriority}
|
||||
mobile={compact}
|
||||
tokenOptions={tokenOptions}
|
||||
activeTokenId={activeTokenId}
|
||||
isUpdatingToken={!!updatingChannel[channel.id]}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import ModernSelect from '../../components/ModernSelect.js';
|
||||
@@ -18,6 +18,7 @@ export function SortableChannelRow({
|
||||
loadingDecision,
|
||||
isSavingPriority,
|
||||
readOnly = false,
|
||||
mobile = false,
|
||||
tokenOptions,
|
||||
activeTokenId,
|
||||
isUpdatingToken,
|
||||
@@ -44,10 +45,10 @@ export function SortableChannelRow({
|
||||
opacity: isDragging ? 0.72 : 1,
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: readOnly ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto',
|
||||
alignItems: 'center',
|
||||
gridTemplateColumns: readOnly || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto',
|
||||
alignItems: mobile ? 'stretch' : 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
padding: mobile ? '10px 12px' : '8px 12px',
|
||||
borderLeft: '2px solid var(--color-primary)',
|
||||
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
|
||||
background: isDragging ? 'rgba(59,130,246,0.08)' : 'rgba(79,70,229,0.02)',
|
||||
@@ -65,9 +66,221 @@ export function SortableChannelRow({
|
||||
},
|
||||
);
|
||||
|
||||
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false);
|
||||
|
||||
if (mobile) {
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ ...rowStyle, display: 'block' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
disabled={isSavingPriority || readOnly}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
width: 22,
|
||||
minWidth: 22,
|
||||
height: 22,
|
||||
padding: 0,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
|
||||
opacity: readOnly ? 0.65 : 1,
|
||||
marginTop: 2,
|
||||
}}
|
||||
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
|
||||
aria-label="拖拽调整优先级"
|
||||
>
|
||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
||||
<circle cx="3" cy="2" r="1" />
|
||||
<circle cx="9" cy="2" r="1" />
|
||||
<circle cx="3" cy="6" r="1" />
|
||||
<circle cx="9" cy="6" r="1" />
|
||||
<circle cx="3" cy="10" r="1" />
|
||||
<circle cx="9" cy="10" r="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.1,
|
||||
...getPriorityTagStyle(channel.priority ?? 0),
|
||||
}}
|
||||
>
|
||||
P{channel.priority ?? 0}
|
||||
</span>
|
||||
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)', fontSize: 14, minWidth: 0 }}>
|
||||
{channel.account?.username || `account-${channel.accountId}`}
|
||||
</span>
|
||||
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{channel.site?.name || 'unknown'}
|
||||
</span>
|
||||
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
|
||||
成功/失败 <span style={{ color: 'var(--color-success)', fontWeight: 600 }}>{channel.successCount || 0}</span>
|
||||
<span style={{ color: 'var(--color-text-muted)', margin: '0 2px' }}>/</span>
|
||||
<span style={{ color: 'var(--color-danger)', fontWeight: 600 }}>{channel.failCount || 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
background: tokenBinding.badgeTone === 'info'
|
||||
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
|
||||
color: tokenBinding.badgeTone === 'info' ? 'var(--color-info)' : 'var(--color-warning)',
|
||||
}}
|
||||
>
|
||||
{tokenBinding.bindingModeLabel}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
|
||||
color: 'var(--color-primary)',
|
||||
maxWidth: 220,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`}
|
||||
>
|
||||
当前生效:{tokenBinding.effectiveTokenName}
|
||||
</span>
|
||||
|
||||
{channel.sourceModel ? (
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{channel.sourceModel}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{channel.manualOverride ? (
|
||||
<span
|
||||
className="badge badge-warning"
|
||||
style={{ fontSize: 10 }}
|
||||
data-tooltip="该通道由用户手动添加,而非系统自动生成"
|
||||
>
|
||||
手动配置
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
|
||||
<div
|
||||
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
|
||||
style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
background: 'var(--color-border)',
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, decisionState.probability))}%`,
|
||||
height: '100%',
|
||||
background: getProbabilityColor(decisionState.probability),
|
||||
borderRadius: 999,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{decisionState.probability.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
onClick={() => setMobileDetailsOpen((current) => !current)}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
{mobileDetailsOpen ? '收起配置' : '配置通道'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && mobileDetailsOpen && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 8, borderTop: '1px solid var(--color-border-light)' }}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(activeTokenId || 0)}
|
||||
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
|
||||
disabled={isUpdatingToken}
|
||||
options={[
|
||||
{
|
||||
value: '0',
|
||||
label: tokenBinding.followOptionLabel,
|
||||
description: tokenBinding.followOptionDescription,
|
||||
},
|
||||
...tokenOptions.map((token) => ({
|
||||
value: String(token.id),
|
||||
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
|
||||
description: buildFixedTokenOptionDescription(token),
|
||||
})),
|
||||
]}
|
||||
placeholder="选择令牌绑定方式"
|
||||
/>
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||
{tokenBinding.helperText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onSaveToken}
|
||||
disabled={isUpdatingToken}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: 10, fontSize: 13, flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
ref={setActivatorNodeRef}
|
||||
@@ -163,7 +376,7 @@ export function SortableChannelRow({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: mobile ? 0 : 4, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
|
||||
<div
|
||||
@@ -209,9 +422,9 @@ export function SortableChannelRow({
|
||||
</div>
|
||||
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ minWidth: 220, flex: 1 }}>
|
||||
mobile ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(activeTokenId || 0)}
|
||||
@@ -235,22 +448,67 @@ export function SortableChannelRow({
|
||||
{tokenBinding.helperText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSaveToken}
|
||||
disabled={isUpdatingToken}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onSaveToken}
|
||||
disabled={isUpdatingToken}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ minWidth: 220, flex: 1 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(activeTokenId || 0)}
|
||||
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
|
||||
disabled={isUpdatingToken}
|
||||
options={[
|
||||
{
|
||||
value: '0',
|
||||
label: tokenBinding.followOptionLabel,
|
||||
description: tokenBinding.followOptionDescription,
|
||||
},
|
||||
...tokenOptions.map((token) => ({
|
||||
value: String(token.id),
|
||||
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
|
||||
description: buildFixedTokenOptionDescription(token),
|
||||
})),
|
||||
]}
|
||||
placeholder="选择令牌绑定方式"
|
||||
/>
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||
{tokenBinding.helperText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSaveToken}
|
||||
disabled={isUpdatingToken}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -154,6 +154,7 @@ export type SortableChannelRowProps = {
|
||||
loadingDecision: boolean;
|
||||
isSavingPriority: boolean;
|
||||
readOnly?: boolean;
|
||||
mobile?: boolean;
|
||||
tokenOptions: RouteTokenOption[];
|
||||
activeTokenId: number;
|
||||
isUpdatingToken: boolean;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import TokenRoutes from './TokenRoutes.js';
|
||||
|
||||
const { apiMock, getBrandMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getRoutesSummary: vi.fn(),
|
||||
getRouteChannels: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
getRouteDecisionsBatch: vi.fn(),
|
||||
getRouteWideDecisionsBatch: vi.fn(),
|
||||
updateRoute: vi.fn(),
|
||||
rebuildRoutes: vi.fn(),
|
||||
deleteRoute: vi.fn(),
|
||||
deleteChannel: vi.fn(),
|
||||
},
|
||||
getBrandMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('react-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
|
||||
return {
|
||||
...actual,
|
||||
createPortal: (node: unknown) => node,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../components/useIsMobile.js', () => ({
|
||||
useIsMobile: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
|
||||
<span>{brand?.name || icon || model || ''}</span>
|
||||
),
|
||||
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
|
||||
getBrand: (...args: unknown[]) => getBrandMock(...args),
|
||||
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('TokenRoutes mobile layout', () => {
|
||||
const originalIntersectionObserver = globalThis.IntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.IntersectionObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
takeRecords() { return []; }
|
||||
readonly root = null;
|
||||
readonly rootMargin = '0px';
|
||||
readonly thresholds = [];
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
getBrandMock.mockReset();
|
||||
getBrandMock.mockReturnValue(null);
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
displayName: 'gpt-4o-mini',
|
||||
displayIcon: null,
|
||||
modelMapping: null,
|
||||
routingStrategy: 'weighted',
|
||||
enabled: true,
|
||||
channelCount: 1,
|
||||
enabledChannelCount: 1,
|
||||
siteNames: ['site-a'],
|
||||
decisionSnapshot: null,
|
||||
decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 11,
|
||||
accountId: 101,
|
||||
tokenId: 1001,
|
||||
sourceModel: 'gpt-4o-mini',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
account: { username: 'user_a' },
|
||||
site: { name: 'site-a' },
|
||||
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {
|
||||
'gpt-4o-mini': [
|
||||
{
|
||||
accountId: 101,
|
||||
tokenId: 1001,
|
||||
tokenName: 'token-a',
|
||||
isDefault: true,
|
||||
username: 'user_a',
|
||||
siteId: 1,
|
||||
siteName: 'site-a',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.updateRoute.mockResolvedValue({});
|
||||
apiMock.rebuildRoutes.mockResolvedValue({ rebuild: { createdRoutes: 0, createdChannels: 0 } });
|
||||
apiMock.deleteRoute.mockResolvedValue({});
|
||||
apiMock.deleteChannel.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.IntersectionObserver = originalIntersectionObserver;
|
||||
});
|
||||
|
||||
it('shows direct mobile actions and reveals the management panel after expansion', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const summaryText = collectText(root!.root);
|
||||
expect(summaryText).toContain('详情');
|
||||
expect(summaryText).toContain('禁用');
|
||||
expect(summaryText).toContain('添加通道');
|
||||
|
||||
const expandButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node) === '详情'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await expandButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandedText = collectText(root!.root);
|
||||
expect(expandedText).toContain('路由策略');
|
||||
expect(expandedText).toContain('权重随机');
|
||||
expect(expandedText).toContain('user_a');
|
||||
expect(expandedText).toContain('token-a');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets mobile users toggle route enabled state from the summary card', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const toggleButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node) === '禁用'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await toggleButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { enabled: false });
|
||||
expect(collectText(root!.root)).toContain('启用');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import TokenRoutes from './TokenRoutes.js';
|
||||
|
||||
const { apiMock, getBrandMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getRoutesSummary: vi.fn(),
|
||||
getRouteChannels: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
getRouteDecisionsBatch: vi.fn(),
|
||||
getRouteWideDecisionsBatch: vi.fn(),
|
||||
updateRoute: vi.fn(),
|
||||
addRoute: vi.fn(),
|
||||
},
|
||||
getBrandMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('react-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
|
||||
return {
|
||||
...actual,
|
||||
createPortal: (node: unknown) => node,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../components/useIsMobile.js', () => ({
|
||||
useIsMobile: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
|
||||
<span>{brand?.name || icon || model || ''}</span>
|
||||
),
|
||||
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
|
||||
getBrand: (...args: unknown[]) => getBrandMock(...args),
|
||||
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes(text)
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('TokenRoutes mobile actions', () => {
|
||||
const originalIntersectionObserver = globalThis.IntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getBrandMock.mockReset();
|
||||
getBrandMock.mockReturnValue(null);
|
||||
globalThis.IntersectionObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
displayName: 'gpt-4o-mini',
|
||||
displayIcon: null,
|
||||
modelMapping: null,
|
||||
routeMode: 'pattern',
|
||||
routingStrategy: 'weighted',
|
||||
enabled: true,
|
||||
channelCount: 1,
|
||||
enabledChannelCount: 1,
|
||||
siteNames: ['site-a'],
|
||||
decisionSnapshot: null,
|
||||
decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 11,
|
||||
accountId: 101,
|
||||
tokenId: 1001,
|
||||
sourceModel: 'gpt-4o-mini',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
account: { username: 'user_a' },
|
||||
site: { name: 'site-a' },
|
||||
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.updateRoute.mockResolvedValue({});
|
||||
apiMock.addRoute.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.IntersectionObserver = originalIntersectionObserver;
|
||||
});
|
||||
|
||||
it('shows mobile detail expansion and direct management actions', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root!.root)).toContain('筛选');
|
||||
expect(collectText(root!.root)).toContain('详情');
|
||||
expect(collectText(root!.root)).toContain('编辑');
|
||||
expect(collectText(root!.root)).toContain('添加通道');
|
||||
|
||||
const disableButton = findButtonByText(root!.root, '禁用');
|
||||
await act(async () => {
|
||||
disableButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { enabled: false });
|
||||
|
||||
const detailButton = findButtonByText(root!.root, '详情');
|
||||
await act(async () => {
|
||||
detailButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('user_a');
|
||||
expect(text).toContain('token-a');
|
||||
expect(text).toContain('site-a');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import { TokensPanel } from './Tokens.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccountTokens: vi.fn(),
|
||||
getAccounts: vi.fn(),
|
||||
getAccountTokenGroups: vi.fn(),
|
||||
batchUpdateAccountTokens: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/useIsMobile.js', () => ({
|
||||
useIsMobile: () => true,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Tokens mobile actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getAccountTokens.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
accountId: 1,
|
||||
name: 'token-1',
|
||||
tokenMasked: 'sk-***1',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
updatedAt: '2026-03-21T10:00:00.000Z',
|
||||
account: { username: 'alpha' },
|
||||
site: { name: 'Site A', url: 'https://site-a.example.com' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
accountId: 1,
|
||||
name: 'token-2',
|
||||
tokenMasked: 'sk-***2',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
updatedAt: '2026-03-21T11:00:00.000Z',
|
||||
account: { username: 'alpha' },
|
||||
site: { name: 'Site A', url: 'https://site-a.example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: 'alpha',
|
||||
accessToken: 'session-alpha',
|
||||
status: 'active',
|
||||
site: { id: 1, name: 'Site A', status: 'active' },
|
||||
},
|
||||
]);
|
||||
apiMock.getAccountTokenGroups.mockResolvedValue({ groups: ['default'] });
|
||||
apiMock.batchUpdateAccountTokens.mockResolvedValue({
|
||||
success: true,
|
||||
successIds: [1, 2],
|
||||
failedItems: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('supports select-all-visible and expandable mobile token details', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
|
||||
<TokensPanel />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const detailsButton = root.root
|
||||
.findAll((node) => node.type === 'button')
|
||||
.find((node) => Array.isArray(node.children) && node.children.includes('详情'));
|
||||
expect(detailsButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
detailsButton!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'tokens-mobile-select-all');
|
||||
await act(async () => {
|
||||
selectAllButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(Array.isArray(selectAllButton.children) ? selectAllButton.children.join('') : '').toContain('取消全选');
|
||||
|
||||
const clearVisibleButton = root.root.find((node) => node.props['data-testid'] === 'tokens-mobile-select-all');
|
||||
await act(async () => {
|
||||
clearVisibleButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(Array.isArray(clearVisibleButton.children) ? clearVisibleButton.children.join('') : '').toContain('全选可见项');
|
||||
|
||||
const reselectVisibleButton = root.root.find((node) => node.props['data-testid'] === 'tokens-mobile-select-all');
|
||||
await act(async () => {
|
||||
reselectVisibleButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const batchButton = root.root.find((node) => node.props['data-testid'] === 'tokens-batch-delete');
|
||||
await act(async () => {
|
||||
batchButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const confirmButton = root.root
|
||||
.findAll((node) => node.type === 'button')
|
||||
.find((node) => Array.isArray(node.children) && node.children.some((child) => child === '确认删除'));
|
||||
expect(confirmButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
confirmButton!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.batchUpdateAccountTokens).toHaveBeenCalledWith({
|
||||
ids: [1, 2],
|
||||
action: 'delete',
|
||||
});
|
||||
|
||||
const expandedText = root.root.findAll(() => true)
|
||||
.flatMap((instance) => instance.children)
|
||||
.filter((child): child is string => typeof child === 'string')
|
||||
.join('');
|
||||
expect(expandedText).toContain('更新时间');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user