forked from boos/BoosAPI
feat: convert core pages to mobile cards
This commit is contained in:
+199
-1
@@ -4,6 +4,8 @@ import { api } from '../api.js';
|
|||||||
import CenteredModal from '../components/CenteredModal.js';
|
import CenteredModal from '../components/CenteredModal.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import {
|
import {
|
||||||
buildAddAccountPrereqHint,
|
buildAddAccountPrereqHint,
|
||||||
buildVerifyFailureHint,
|
buildVerifyFailureHint,
|
||||||
@@ -76,6 +78,8 @@ export default function Accounts() {
|
|||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||||
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
|
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
|
||||||
|
const [expandedAccountIds, setExpandedAccountIds] = useState<number[]>([]);
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||||
const [loginForm, setLoginForm] = useState(createLoginForm);
|
const [loginForm, setLoginForm] = useState(createLoginForm);
|
||||||
@@ -538,6 +542,14 @@ export default function Accounts() {
|
|||||||
setSelectedAccountIds(visibleAccounts.map((account) => account.id));
|
setSelectedAccountIds(visibleAccounts.map((account) => account.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleAccountDetails = (accountId: number) => {
|
||||||
|
setExpandedAccountIds((current) => (
|
||||||
|
current.includes(accountId)
|
||||||
|
? current.filter((id) => id !== accountId)
|
||||||
|
: [...current, accountId]
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
const runBatchAccountAction = async (action: 'enable' | 'disable' | 'delete' | 'refreshBalance') => {
|
const runBatchAccountAction = async (action: 'enable' | 'disable' | 'delete' | 'refreshBalance') => {
|
||||||
if (selectedAccountIds.length === 0) return;
|
if (selectedAccountIds.length === 0) return;
|
||||||
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedAccountIds.length} 个账号?`)) return;
|
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedAccountIds.length} 个账号?`)) return;
|
||||||
@@ -1303,7 +1315,192 @@ export default function Accounts() {
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{visibleAccounts.length > 0 ? (
|
{visibleAccounts.length > 0 ? (
|
||||||
<table className="data-table accounts-table">
|
isMobile ? (
|
||||||
|
<div className="mobile-card-list">
|
||||||
|
{visibleAccounts.map((a: any) => {
|
||||||
|
const capabilities = resolveAccountCapabilities(a);
|
||||||
|
const connectionMode = resolveAccountCredentialMode(a);
|
||||||
|
const health = resolveRuntimeHealth(a);
|
||||||
|
const isExpanded = expandedAccountIds.includes(a.id);
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
key={a.id}
|
||||||
|
title={resolveAccountDisplayName(a)}
|
||||||
|
actions={(
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`选择账号 ${resolveAccountDisplayName(a)}`}
|
||||||
|
checked={selectedAccountIds.includes(a.id)}
|
||||||
|
onChange={(event) => toggleAccountSelection(a.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className={`badge ${connectionMode === 'apikey' ? 'badge-warning' : 'badge-info'}`} style={{ fontSize: 10 }}>
|
||||||
|
{connectionMode === 'apikey' ? 'API Key' : 'Session'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MobileField
|
||||||
|
label="运行健康状态"
|
||||||
|
value={(
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span className={`badge ${health.cls}`} style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, width: 'fit-content' }}>
|
||||||
|
<span className={`status-dot ${health.dotClass} ${health.pulse ? 'animate-pulse-dot' : ''}`} style={{ marginRight: 0 }} />
|
||||||
|
{health.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
maxWidth: 240,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
data-tooltip={health.reason}
|
||||||
|
>
|
||||||
|
{health.reason}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="余额"
|
||||||
|
value={(
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>${(a.balance || 0).toFixed(2)}</div>
|
||||||
|
<div style={{ fontSize: 11, color: (a.todayReward || 0) > 0 ? 'var(--color-success)' : 'var(--color-text-muted)', fontWeight: 500 }}>
|
||||||
|
+{(a.todayReward || 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="已用"
|
||||||
|
value={(
|
||||||
|
<div>
|
||||||
|
<div>${(a.balanceUsed || 0).toFixed(2)}</div>
|
||||||
|
<div style={{ fontSize: 11, color: (a.todaySpend || 0) > 0 ? 'var(--color-danger)' : 'var(--color-text-muted)', fontWeight: 500 }}>
|
||||||
|
-{(a.todaySpend || 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="mobile-card-extra">
|
||||||
|
<MobileField
|
||||||
|
label="站点"
|
||||||
|
value={a.site?.url ? (
|
||||||
|
<a
|
||||||
|
href={a.site.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="badge-link"
|
||||||
|
>
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||||
|
{a.site?.name || '-'}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||||
|
{a.site?.name || '-'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="签到"
|
||||||
|
value={capabilities.canCheckin ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`checkin-toggle-badge ${a.checkinEnabled ? 'is-on' : 'is-off'}`}
|
||||||
|
onClick={() => handleToggleCheckin(a)}
|
||||||
|
disabled={!!actionLoading[`checkin-toggle-${a.id}`]}
|
||||||
|
data-tooltip={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
|
||||||
|
aria-label={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
|
||||||
|
>
|
||||||
|
{actionLoading[`checkin-toggle-${a.id}`]
|
||||||
|
? <span className="spinner spinner-sm" />
|
||||||
|
: (a.checkinEnabled ? '开启' : '关闭')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||||
|
不支持
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="账号状态"
|
||||||
|
value={a.status === 'expired' ? '已过期' : (a.status || '-')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mobile-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAccountDetails(a.id)}
|
||||||
|
className="btn btn-link"
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起' : '详情'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTogglePin(a)}
|
||||||
|
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
|
||||||
|
className={`btn btn-link ${a.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||||
|
>
|
||||||
|
{actionLoading[`pin-toggle-${a.id}`] ? <span className="spinner spinner-sm" /> : (a.isPinned ? '取消置顶' : '置顶')}
|
||||||
|
</button>
|
||||||
|
{sortMode === 'custom' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleMoveCustomOrder(a, 'up')}
|
||||||
|
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||||
|
className="btn btn-link btn-link-muted"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleMoveCustomOrder(a, 'down')}
|
||||||
|
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||||
|
className="btn btn-link btn-link-muted"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{capabilities.canRefreshBalance && (
|
||||||
|
<button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]} className="btn btn-link btn-link-primary">
|
||||||
|
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
||||||
|
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
||||||
|
</button>
|
||||||
|
{capabilities.canCheckin && (
|
||||||
|
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]} className="btn btn-link btn-link-warning">
|
||||||
|
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{a.status === 'expired' && !capabilities.proxyOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => openRebindPanel(a)}
|
||||||
|
className="btn btn-link btn-link-warning"
|
||||||
|
>
|
||||||
|
重新绑定
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
|
||||||
|
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="data-table accounts-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 44 }}>
|
<th style={{ width: 44 }}>
|
||||||
@@ -1490,6 +1687,7 @@ export default function Accounts() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||||
|
|||||||
+145
-1
@@ -3,7 +3,9 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||||
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
@@ -78,6 +80,8 @@ export default function Sites() {
|
|||||||
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
|
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
|
||||||
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
||||||
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
|
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
|
||||||
|
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||||
const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
|
const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
|
||||||
const lastEditorRef = useRef<SiteEditorState | null>(null);
|
const lastEditorRef = useRef<SiteEditorState | null>(null);
|
||||||
@@ -327,6 +331,14 @@ export default function Sites() {
|
|||||||
setSelectedSiteIds(sortedSites.map((site) => site.id));
|
setSelectedSiteIds(sortedSites.map((site) => site.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSiteDetails = (siteId: number) => {
|
||||||
|
setExpandedSiteIds((current) => (
|
||||||
|
current.includes(siteId)
|
||||||
|
? current.filter((id) => id !== siteId)
|
||||||
|
: [...current, siteId]
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
const runBatchAction = async (action: 'enable' | 'disable' | 'delete' | 'enableSystemProxy' | 'disableSystemProxy') => {
|
const runBatchAction = async (action: 'enable' | 'disable' | 'delete' | 'enableSystemProxy' | 'disableSystemProxy') => {
|
||||||
if (selectedSiteIds.length === 0) return;
|
if (selectedSiteIds.length === 0) return;
|
||||||
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedSiteIds.length} 个站点?`)) return;
|
if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedSiteIds.length} 个站点?`)) return;
|
||||||
@@ -555,7 +567,138 @@ export default function Sites() {
|
|||||||
|
|
||||||
<div className="card" style={{ overflowX: 'auto' }}>
|
<div className="card" style={{ overflowX: 'auto' }}>
|
||||||
{sites.length > 0 ? (
|
{sites.length > 0 ? (
|
||||||
<table className="data-table sites-table">
|
isMobile ? (
|
||||||
|
<div className="mobile-card-list">
|
||||||
|
{sortedSites.map((site) => {
|
||||||
|
const isExpanded = expandedSiteIds.includes(site.id);
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
key={site.id}
|
||||||
|
title={site.name || '-'}
|
||||||
|
actions={(
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`选择站点 ${site.name || site.id}`}
|
||||||
|
checked={selectedSiteIds.includes(site.id)}
|
||||||
|
onChange={(event) => toggleSiteSelection(site.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MobileField
|
||||||
|
label="状态"
|
||||||
|
value={(
|
||||||
|
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
|
||||||
|
{site.status === 'disabled' ? '禁用' : '启用'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="平台"
|
||||||
|
value={(
|
||||||
|
<span className={`badge ${platformColors[site.platform || ''] || 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||||
|
{site.platform || '-'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField label="余额" value={`$${(site.totalBalance || 0).toFixed(2)}`} />
|
||||||
|
<MobileField label="权重" value={(site.globalWeight || 1).toFixed(2)} />
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="mobile-card-extra">
|
||||||
|
<MobileField
|
||||||
|
label="系统代理"
|
||||||
|
value={(
|
||||||
|
<span className={`badge ${site.useSystemProxy ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||||
|
{site.useSystemProxy ? '已开启' : '未开启'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="外部签到站URL"
|
||||||
|
value={site.externalCheckinUrl ? (
|
||||||
|
<a
|
||||||
|
href={site.externalCheckinUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="sites-url-link"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{site.externalCheckinUrl}
|
||||||
|
</a>
|
||||||
|
) : '-'}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="创建时间"
|
||||||
|
value={formatDateTimeLocal(site.createdAt)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mobile-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSiteDetails(site.id)}
|
||||||
|
className="btn btn-link"
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起' : '详情'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTogglePin(site)}
|
||||||
|
disabled={pinningSiteId === site.id}
|
||||||
|
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||||
|
>
|
||||||
|
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
|
||||||
|
</button>
|
||||||
|
{sortMode === 'custom' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleMoveCustomOrder(site, 'up')}
|
||||||
|
disabled={orderingSiteId === site.id}
|
||||||
|
className="btn btn-link btn-link-muted"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleMoveCustomOrder(site, 'down')}
|
||||||
|
disabled={orderingSiteId === site.id}
|
||||||
|
className="btn btn-link btn-link-muted"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(site)}
|
||||||
|
className="btn btn-link btn-link-primary"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(site)}
|
||||||
|
disabled={togglingSiteId === site.id}
|
||||||
|
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
|
||||||
|
>
|
||||||
|
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(site)}
|
||||||
|
disabled={deleting === site.id}
|
||||||
|
className="btn btn-link btn-link-danger"
|
||||||
|
>
|
||||||
|
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="data-table sites-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 44 }}>
|
<th style={{ width: 44 }}>
|
||||||
@@ -720,6 +863,7 @@ export default function Sites() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
+115
-1
@@ -5,6 +5,8 @@ import CenteredModal from '../components/CenteredModal.js';
|
|||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js';
|
import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js';
|
||||||
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
@@ -105,6 +107,7 @@ function isTruthyFlag(input: string | null): boolean {
|
|||||||
export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
|
export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
const initialCreateForm = {
|
const initialCreateForm = {
|
||||||
accountId: 0,
|
accountId: 0,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -868,7 +871,117 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
|||||||
<div className="skeleton" style={{ width: '100%', height: 34 }} />
|
<div className="skeleton" style={{ width: '100%', height: 34 }} />
|
||||||
</div>
|
</div>
|
||||||
) : tokens.length > 0 ? (
|
) : tokens.length > 0 ? (
|
||||||
<table className="data-table token-table">
|
isMobile ? (
|
||||||
|
<div className="mobile-card-list">
|
||||||
|
{tokens.map((token: any) => {
|
||||||
|
const loadingPrefix = `token-${token.id}`;
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
key={token.id}
|
||||||
|
title={token.name || '-'}
|
||||||
|
actions={(
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`选择令牌 ${token.name || token.id}`}
|
||||||
|
checked={selectedTokenIds.includes(token.id)}
|
||||||
|
onChange={(event) => toggleTokenSelection(token.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MobileField
|
||||||
|
label="令牌值"
|
||||||
|
value={<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{token.tokenMasked || '***'}</span>}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="来源站点"
|
||||||
|
value={token.site?.url ? (
|
||||||
|
<a
|
||||||
|
href={token.site.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="badge-link"
|
||||||
|
>
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||||
|
{token.site?.name || 'unknown'}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||||
|
{token.site?.name || 'unknown'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField label="账号" value={token.account?.username || `account-${token.accountId}`} />
|
||||||
|
<MobileField
|
||||||
|
label="状态"
|
||||||
|
value={(
|
||||||
|
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||||
|
{token.enabled ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="默认"
|
||||||
|
value={token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}>默认</span> : '-'}
|
||||||
|
/>
|
||||||
|
<MobileField label="更新时间" value={formatDateTimeLocal(token.updatedAt)} />
|
||||||
|
<div className="mobile-card-actions">
|
||||||
|
{!token.isDefault && (
|
||||||
|
<button
|
||||||
|
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
|
||||||
|
await api.setDefaultAccountToken(token.id);
|
||||||
|
toast.success('默认令牌已更新');
|
||||||
|
await load();
|
||||||
|
})}
|
||||||
|
disabled={!!rowLoading[`${loadingPrefix}-default`]}
|
||||||
|
className="btn btn-link btn-link-info"
|
||||||
|
>
|
||||||
|
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyToken(token.id, token.name || '')}
|
||||||
|
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
|
||||||
|
className="btn btn-link btn-link-primary"
|
||||||
|
data-testid={`token-copy-${token.id}`}
|
||||||
|
>
|
||||||
|
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditPanel(token)}
|
||||||
|
className="btn btn-link btn-link-info"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
|
||||||
|
await api.updateAccountToken(token.id, { enabled: !token.enabled });
|
||||||
|
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
|
||||||
|
await load();
|
||||||
|
})}
|
||||||
|
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
|
||||||
|
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||||
|
>
|
||||||
|
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => withRowLoading(`${loadingPrefix}-delete`, async () => {
|
||||||
|
await api.deleteAccountToken(token.id);
|
||||||
|
toast.success('令牌已删除');
|
||||||
|
await load();
|
||||||
|
})}
|
||||||
|
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
|
||||||
|
className="btn btn-link btn-link-danger"
|
||||||
|
>
|
||||||
|
{rowLoading[`${loadingPrefix}-delete`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="data-table token-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 44 }}>
|
<th style={{ width: 44 }}>
|
||||||
@@ -998,6 +1111,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('Sites mobile layout', () => {
|
||||||
|
it('includes mobile-card usage in Sites page', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Sites.tsx'), 'utf8');
|
||||||
|
expect(source).toContain('mobile-card');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user