forked from boos/BoosAPI
Merge pull request #49 from cita-777/codex/mobile-responsive
Codex/mobile responsive
This commit is contained in:
@@ -9,6 +9,7 @@ data/*
|
|||||||
logs/
|
logs/
|
||||||
tmp/*
|
tmp/*
|
||||||
!tmp/.gitkeep
|
!tmp/.gitkeep
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Local desktop/debug artifacts
|
# Local desktop/debug artifacts
|
||||||
/.tmp-*
|
/.tmp-*
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('App mobile layout', () => {
|
||||||
|
it('sets data-layout attribute when switching layouts', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
|
||||||
|
expect(source).toContain('useIsMobile(768)');
|
||||||
|
expect(source).toContain("document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('App mobile sidebar', () => {
|
||||||
|
it('renders hamburger trigger for mobile navigation', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
|
||||||
|
expect(source).toContain('aria-label="Open navigation"');
|
||||||
|
expect(source).toContain('MobileDrawer');
|
||||||
|
expect(source).toContain('mobile-nav');
|
||||||
|
});
|
||||||
|
});
|
||||||
+68
-25
@@ -16,6 +16,8 @@ import { I18nProvider, useI18n } from './i18n.js';
|
|||||||
import { resolveLoginErrorMessage } from './loginError.js';
|
import { resolveLoginErrorMessage } from './loginError.js';
|
||||||
import { SITE_DOCS_URL } from './docsLink.js';
|
import { SITE_DOCS_URL } from './docsLink.js';
|
||||||
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
|
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
|
||||||
|
import { useIsMobile } from './components/useIsMobile.js';
|
||||||
|
import { MobileDrawer } from './components/MobileDrawer.js';
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
|
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
|
||||||
const Sites = lazy(() => import('./pages/Sites.js'));
|
const Sites = lazy(() => import('./pages/Sites.js'));
|
||||||
const Accounts = lazy(() => import('./pages/Accounts.js'));
|
const Accounts = lazy(() => import('./pages/Accounts.js'));
|
||||||
@@ -377,12 +379,14 @@ function AppShell() {
|
|||||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160);
|
const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160);
|
||||||
const userMenuPresence = useAnimatedVisibility(showUserMenu, 160);
|
const userMenuPresence = useAnimatedVisibility(showUserMenu, 160);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const notifBtnRef = useRef<HTMLButtonElement>(null);
|
const notifBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const latestTaskEventIdRef = useRef(0);
|
const latestTaskEventIdRef = useRef(0);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
const resolvedTheme: 'light' | 'dark' = themeMode === 'system'
|
const resolvedTheme: 'light' | 'dark' = themeMode === 'system'
|
||||||
? (systemPrefersDark ? 'dark' : 'light')
|
? (systemPrefersDark ? 'dark' : 'light')
|
||||||
: themeMode;
|
: themeMode;
|
||||||
@@ -415,6 +419,10 @@ function AppShell() {
|
|||||||
}
|
}
|
||||||
}, [resolvedTheme, themeMode]);
|
}, [resolvedTheme, themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
@@ -545,6 +553,17 @@ function AppShell() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
className="topbar-icon-btn"
|
||||||
|
aria-label="Open navigation"
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="topbar-logo">
|
<div className="topbar-logo">
|
||||||
<img src="/logo.png" alt="Metapi" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
<img src="/logo.png" alt="Metapi" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||||
<span className="topbar-logo-text">Metapi</span>
|
<span className="topbar-logo-text">Metapi</span>
|
||||||
@@ -667,32 +686,56 @@ function AppShell() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
{isMobile ? (
|
||||||
{sidebarGroups.map((group) => (
|
<MobileDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
<div key={group.label} className="sidebar-group">
|
<nav className="mobile-nav">
|
||||||
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
|
{sidebarGroups.map((group) => (
|
||||||
{group.items.map((item) => (
|
<div key={group.label} className="mobile-nav-group">
|
||||||
<NavLink
|
<div className="mobile-nav-label">{t(group.label)}</div>
|
||||||
key={item.to}
|
{group.items.map((item) => (
|
||||||
to={item.to}
|
<NavLink
|
||||||
end={item.to === '/' || item.to === '/settings'}
|
key={item.to}
|
||||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
|
to={item.to}
|
||||||
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
end={item.to === '/' || item.to === '/settings'}
|
||||||
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
|
||||||
>
|
onClick={() => setDrawerOpen(false)}
|
||||||
{item.icon}
|
>
|
||||||
{!sidebarCollapsed && <span>{t(item.label)}</span>}
|
{item.icon}
|
||||||
</NavLink>
|
<span>{t(item.label)}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</nav>
|
||||||
))}
|
</MobileDrawer>
|
||||||
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
|
) : (
|
||||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
|
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
{sidebarGroups.map((group) => (
|
||||||
</svg>
|
<div key={group.label} className="sidebar-group">
|
||||||
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
|
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
|
||||||
</button>
|
{group.items.map((item) => (
|
||||||
</aside>
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/' || item.to === '/settings'}
|
||||||
|
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
|
||||||
|
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
||||||
|
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{!sidebarCollapsed && <span>{t(item.label)}</span>}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<PageTransition>
|
<PageTransition>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type MobileCardProps = {
|
||||||
|
title: React.ReactNode;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MobileFieldProps = {
|
||||||
|
label: React.ReactNode;
|
||||||
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileCard({ title, actions, children }: MobileCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="mobile-card">
|
||||||
|
<div className="mobile-card-header">
|
||||||
|
<div className="mobile-card-title">{title}</div>
|
||||||
|
{actions ? <div className="mobile-card-actions">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mobile-card-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileField({ label, value }: MobileFieldProps) {
|
||||||
|
return (
|
||||||
|
<div className="mobile-field">
|
||||||
|
<div className="mobile-field-label">{label}</div>
|
||||||
|
<div className="mobile-field-value">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type MobileDrawerProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MobileDrawer({ open, onClose, children }: MobileDrawerProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-drawer-root">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-drawer-backdrop"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close navigation"
|
||||||
|
/>
|
||||||
|
<div className="mobile-drawer-panel" role="dialog" aria-modal="true">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MobileDrawer };
|
||||||
|
export default MobileDrawer;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { create } from 'react-test-renderer';
|
||||||
|
import { MobileCard, MobileField } from './MobileCard.js';
|
||||||
|
|
||||||
|
describe('MobileCard', () => {
|
||||||
|
it('renders title and fields', () => {
|
||||||
|
const root = create(
|
||||||
|
<MobileCard title="CardTitle">
|
||||||
|
<MobileField label="Status" value="OK" />
|
||||||
|
</MobileCard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = root.root.findAll(() => true)
|
||||||
|
.flatMap((instance) => instance.children)
|
||||||
|
.filter((child): child is string => typeof child === 'string')
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
expect(text).toContain('CardTitle');
|
||||||
|
expect(text).toContain('Status');
|
||||||
|
expect(text).toContain('OK');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { create } from 'react-test-renderer';
|
||||||
|
import MobileDrawer from './MobileDrawer.js';
|
||||||
|
|
||||||
|
describe('MobileDrawer', () => {
|
||||||
|
it('renders content when open and closes on backdrop click', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const root = create(
|
||||||
|
<MobileDrawer open onClose={onClose}>
|
||||||
|
<div>DrawerContent</div>
|
||||||
|
</MobileDrawer>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = root.root.findAll(() => true)
|
||||||
|
.flatMap((instance) => instance.children)
|
||||||
|
.filter((child): child is string => typeof child === 'string')
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
expect(text).toContain('DrawerContent');
|
||||||
|
|
||||||
|
const backdrop = root.root.find((node) => node.props?.className === 'mobile-drawer-backdrop');
|
||||||
|
backdrop.props.onClick();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useIsMobile(breakpoint = 768) {
|
||||||
|
const [isMobile, setIsMobile] = useState(() => (
|
||||||
|
typeof window !== 'undefined' ? window.innerWidth < breakpoint : false
|
||||||
|
));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const update = () => setIsMobile(window.innerWidth < breakpoint);
|
||||||
|
update();
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
return () => window.removeEventListener('resize', update);
|
||||||
|
}, [breakpoint]);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -964,6 +964,142 @@ body {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile Card ===== */
|
||||||
|
.mobile-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-field-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-field-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card-extra {
|
||||||
|
border-top: 1px dashed var(--color-border-light);
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile Drawer ===== */
|
||||||
|
.mobile-drawer-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 220;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-drawer-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 15, 15, 0.4);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-drawer-panel {
|
||||||
|
position: relative;
|
||||||
|
width: min(82vw, 320px);
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-filter-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-filter-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile Actions Bar ===== */
|
||||||
|
.mobile-actions-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 210;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-actions-info {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-actions-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Stat Card (New API style) ===== */
|
/* ===== Stat Card (New API style) ===== */
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
@@ -3486,6 +3622,33 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 16px;
|
||||||
|
padding-bottom: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-actions-bar .btn {
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Responsive Adjustments ===== */
|
/* ===== Responsive Adjustments ===== */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.topbar-search-trigger {
|
.topbar-search-trigger {
|
||||||
|
|||||||
+227
-2
@@ -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;
|
||||||
@@ -783,7 +795,7 @@ export default function Accounts() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
|
{!isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
|
||||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedAccountIds.length} 项</span>
|
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedAccountIds.length} 项</span>
|
||||||
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
@@ -801,6 +813,26 @@ export default function Accounts() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
|
||||||
|
<div className="mobile-actions-bar">
|
||||||
|
<span className="mobile-actions-info">已选 {selectedAccountIds.length} 项</span>
|
||||||
|
<div className="mobile-actions-row">
|
||||||
|
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
批量刷新余额
|
||||||
|
</button>
|
||||||
|
<button onClick={() => runBatchAccountAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
批量启用
|
||||||
|
</button>
|
||||||
|
<button onClick={() => runBatchAccountAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
批量禁用
|
||||||
|
</button>
|
||||||
|
<button onClick={() => runBatchAccountAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||||
|
批量删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSegment === 'tokens' ? (
|
{activeSegment === 'tokens' ? (
|
||||||
<TokensPanel embedded onEmbeddedActionsChange={setEmbeddedTokenActions} />
|
<TokensPanel embedded onEmbeddedActionsChange={setEmbeddedTokenActions} />
|
||||||
) : (
|
) : (
|
||||||
@@ -1303,7 +1335,199 @@ 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);
|
||||||
|
const hintMessage = (a.status === 'expired' && !capabilities.proxyOnly)
|
||||||
|
? '账号已过期,请重新绑定'
|
||||||
|
: (health.reason || '-');
|
||||||
|
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 || '-')}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="提示"
|
||||||
|
value={hintMessage}
|
||||||
|
/>
|
||||||
|
</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 +1714,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>
|
||||||
|
|||||||
+113
-10
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
|
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { formatCheckinLogTime } from './helpers/checkinLogTime.js';
|
import { formatCheckinLogTime } from './helpers/checkinLogTime.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ export default function CheckinLog() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [triggering, setTriggering] = useState(false);
|
const [triggering, setTriggering] = useState(false);
|
||||||
const [filter, setFilter] = useState<LogFilter>('all');
|
const [filter, setFilter] = useState<LogFilter>('all');
|
||||||
|
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -82,6 +88,19 @@ export default function CheckinLog() {
|
|||||||
return reason;
|
return reason;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterTabs = (
|
||||||
|
<div className="tabs" style={{ marginBottom: 0 }}>
|
||||||
|
{(['all', 'success', 'failed', 'skipped'] as const).map((tab) => (
|
||||||
|
<button key={tab} className={`tab ${filter === tab ? 'active' : ''}`} onClick={() => setFilter(tab)}>
|
||||||
|
{tab === 'all' && `全部 (${logs.length})`}
|
||||||
|
{tab === 'success' && `成功 (${countBy('success')})`}
|
||||||
|
{tab === 'failed' && `失败 (${countBy('failed')})`}
|
||||||
|
{tab === 'skipped' && `跳过 (${countBy('skipped')})`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -102,16 +121,27 @@ export default function CheckinLog() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tabs" style={{ marginBottom: 0 }}>
|
{isMobile ? (
|
||||||
{(['all', 'success', 'failed', 'skipped'] as const).map((tab) => (
|
<>
|
||||||
<button key={tab} className={`tab ${filter === tab ? 'active' : ''}`} onClick={() => setFilter(tab)}>
|
<div className="mobile-filter-row">
|
||||||
{tab === 'all' && `全部 (${logs.length})`}
|
<button
|
||||||
{tab === 'success' && `成功 (${countBy('success')})`}
|
type="button"
|
||||||
{tab === 'failed' && `失败 (${countBy('failed')})`}
|
className="btn btn-ghost"
|
||||||
{tab === 'skipped' && `跳过 (${countBy('skipped')})`}
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
</button>
|
onClick={() => setShowFilters(true)}
|
||||||
))}
|
>
|
||||||
</div>
|
筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
|
||||||
|
<div className="mobile-filter-panel">
|
||||||
|
{filterTabs}
|
||||||
|
</div>
|
||||||
|
</MobileDrawer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
filterTabs
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ overflowX: 'auto', borderTopLeftRadius: 0, borderTopRightRadius: 0 }}>
|
<div className="card" style={{ overflowX: 'auto', borderTopLeftRadius: 0, borderTopRightRadius: 0 }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -127,6 +157,79 @@ export default function CheckinLog() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : isMobile ? (
|
||||||
|
<div className="mobile-card-list">
|
||||||
|
{filtered.map((log: any) => {
|
||||||
|
const status = getStatus(log);
|
||||||
|
const reason = getFailureReason(log);
|
||||||
|
const isExpanded = expandedLogId === (log.checkin_logs?.id || log.id);
|
||||||
|
const logId = log.checkin_logs?.id || log.id;
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
key={logId}
|
||||||
|
title={log.accounts?.username || '未知'}
|
||||||
|
actions={(
|
||||||
|
<span className={`badge ${statusClass(status)}`} style={{ fontSize: 10 }}>
|
||||||
|
{statusLabel(status)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MobileField label="时间" value={formatCheckinLogTime(log.checkin_logs?.createdAt || log.createdAt)} />
|
||||||
|
<MobileField
|
||||||
|
label="站点"
|
||||||
|
value={log.sites?.url ? (
|
||||||
|
<a
|
||||||
|
href={log.sites.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="badge-link"
|
||||||
|
>
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||||
|
{log.sites?.name || '-'}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||||
|
{log.sites?.name || '-'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="分类"
|
||||||
|
value={reason ? (
|
||||||
|
<span className="badge badge-info" data-tooltip={reason.detailHint}>
|
||||||
|
{reason.title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-muted">-</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MobileField label="奖励" value={log.checkin_logs?.reward || '-'} />
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="mobile-card-extra">
|
||||||
|
<MobileField
|
||||||
|
label="信息"
|
||||||
|
value={log.checkin_logs?.message || log.message}
|
||||||
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="建议"
|
||||||
|
value={reason?.actionHint || '-'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mobile-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link"
|
||||||
|
onClick={() => setExpandedLogId(isExpanded ? null : logId)}
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起' : '详情'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
+113
-22
@@ -9,6 +9,9 @@ import {
|
|||||||
} from '../api.js';
|
} from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { ModelBadge } from '../components/BrandIcon.js';
|
import { ModelBadge } from '../components/BrandIcon.js';
|
||||||
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
|
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||||
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js';
|
import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js';
|
||||||
@@ -123,6 +126,8 @@ export default function ProxyLogs() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(50);
|
||||||
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -214,6 +219,35 @@ export default function ProxyLogs() {
|
|||||||
}
|
}
|
||||||
}, [expanded, loadDetail]);
|
}, [expanded, loadDetail]);
|
||||||
|
|
||||||
|
const filterControls = (
|
||||||
|
<>
|
||||||
|
<div className="pill-tabs">
|
||||||
|
{([
|
||||||
|
{ key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount },
|
||||||
|
{ key: 'success' as ProxyLogStatusFilter, label: '成功', count: summary.successCount },
|
||||||
|
{ key: 'failed' as ProxyLogStatusFilter, label: '失败', count: summary.failedCount },
|
||||||
|
]).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
className={`pill-tab ${statusFilter === tab.key ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(tab.key);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label} <span style={{ fontVariantNumeric: 'tabular-nums', opacity: 0.7 }}>{tab.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-search" style={{ maxWidth: 280 }}>
|
||||||
|
<svg width="14" height="14" 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||||
@@ -234,32 +268,29 @@ export default function ProxyLogs() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar" style={{ marginBottom: 12 }}>
|
{isMobile ? (
|
||||||
<div className="pill-tabs">
|
<>
|
||||||
{([
|
<div className="mobile-filter-row">
|
||||||
{ key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount },
|
|
||||||
{ key: 'success' as ProxyLogStatusFilter, label: '成功', count: summary.successCount },
|
|
||||||
{ key: 'failed' as ProxyLogStatusFilter, label: '失败', count: summary.failedCount },
|
|
||||||
]).map((tab) => (
|
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
type="button"
|
||||||
className={`pill-tab ${statusFilter === tab.key ? 'active' : ''}`}
|
className="btn btn-ghost"
|
||||||
onClick={() => {
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
setStatusFilter(tab.key);
|
onClick={() => setShowFilters(true)}
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{tab.label} <span style={{ fontVariantNumeric: 'tabular-nums', opacity: 0.7 }}>{tab.count}</span>
|
筛选
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
|
||||||
|
<div className="mobile-filter-panel">
|
||||||
|
{filterControls}
|
||||||
|
</div>
|
||||||
|
</MobileDrawer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="toolbar" style={{ marginBottom: 12 }}>
|
||||||
|
{filterControls}
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar-search" style={{ maxWidth: 280 }}>
|
)}
|
||||||
<svg width="14" height="14" 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ overflowX: 'auto' }}>
|
<div className="card" style={{ overflowX: 'auto' }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -275,6 +306,66 @@ export default function ProxyLogs() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : isMobile ? (
|
||||||
|
<div className="mobile-card-list">
|
||||||
|
{logs.map((log) => {
|
||||||
|
const detailState = detailById[log.id];
|
||||||
|
const detail = detailState?.data;
|
||||||
|
const detailLog: ProxyLogRenderItem = detail ? { ...log, ...detail } : log;
|
||||||
|
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
|
||||||
|
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
|
||||||
|
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
|
||||||
|
const isExpanded = expanded === log.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
key={log.id}
|
||||||
|
title={detailLog.modelRequested || 'unknown'}
|
||||||
|
actions={(
|
||||||
|
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 10 }}>
|
||||||
|
{log.status === 'success' ? '成功' : '失败'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
||||||
|
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
|
||||||
|
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
|
||||||
|
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
|
||||||
|
<MobileField
|
||||||
|
label="花费"
|
||||||
|
value={typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}
|
||||||
|
/>
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="mobile-card-extra">
|
||||||
|
<MobileField label="重试" value={log.retryCount > 0 ? log.retryCount : 0} />
|
||||||
|
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
|
||||||
|
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
|
||||||
|
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
|
||||||
|
{billingProcessLines.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{billingProcessLines.map((line, index) => (
|
||||||
|
<span key={`${log.id}-billing-mobile-${index}`}>{line}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail && pathMeta.errorMessage.trim().length > 0 && (
|
||||||
|
<div style={{ color: 'var(--color-danger)' }}>{pathMeta.errorMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mobile-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link"
|
||||||
|
onClick={() => handleToggleExpand(log.id)}
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起' : '详情'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="data-table" style={{ width: '100%' }}>
|
<table className="data-table" style={{ width: '100%' }}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
+180
-2
@@ -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;
|
||||||
@@ -383,7 +395,7 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedSiteIds.length > 0 && (
|
{!isMobile && selectedSiteIds.length > 0 && (
|
||||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedSiteIds.length} 项</span>
|
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedSiteIds.length} 项</span>
|
||||||
<button
|
<button
|
||||||
@@ -415,6 +427,40 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isMobile && selectedSiteIds.length > 0 && (
|
||||||
|
<div className="mobile-actions-bar">
|
||||||
|
<span className="mobile-actions-info">已选 {selectedSiteIds.length} 项</span>
|
||||||
|
<div className="mobile-actions-row">
|
||||||
|
<button
|
||||||
|
data-testid="sites-batch-enable-system-proxy"
|
||||||
|
onClick={() => runBatchAction('enableSystemProxy')}
|
||||||
|
disabled={batchActionLoading}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
批量开启系统代理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => runBatchAction('disableSystemProxy')}
|
||||||
|
disabled={batchActionLoading}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
批量关闭系统代理
|
||||||
|
</button>
|
||||||
|
<button onClick={() => runBatchAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
批量启用
|
||||||
|
</button>
|
||||||
|
<button onClick={() => runBatchAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
批量禁用
|
||||||
|
</button>
|
||||||
|
<button onClick={() => runBatchAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||||
|
批量删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="info-tip" style={{ marginBottom: 12 }}>
|
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||||
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
||||||
</div>
|
</div>
|
||||||
@@ -555,7 +601,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 +897,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">
|
||||||
|
|||||||
+276
-202
@@ -21,7 +21,10 @@ import { api } from '../api.js';
|
|||||||
import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey, type BrandInfo } from '../components/BrandIcon.js';
|
import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey, type BrandInfo } from '../components/BrandIcon.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
|
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||||
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
import {
|
import {
|
||||||
buildRouteModelCandidatesIndex,
|
buildRouteModelCandidatesIndex,
|
||||||
@@ -708,6 +711,7 @@ export default function TokenRoutes() {
|
|||||||
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
|
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
|
||||||
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
|
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
|
||||||
const [filterCollapsed, setFilterCollapsed] = useState(false);
|
const [filterCollapsed, setFilterCollapsed] = useState(false);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
|
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
|
||||||
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
|
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
|
||||||
|
|
||||||
@@ -729,6 +733,8 @@ export default function TokenRoutes() {
|
|||||||
const [decisionAutoSkipped, setDecisionAutoSkipped] = useState(false);
|
const [decisionAutoSkipped, setDecisionAutoSkipped] = useState(false);
|
||||||
const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK);
|
const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK);
|
||||||
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
|
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
|
||||||
|
const [expandedRouteIds, setExpandedRouteIds] = useState<number[]>([]);
|
||||||
|
const isMobile = useIsMobile(768);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -743,6 +749,217 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
const openFilters = () => {
|
||||||
|
setFilterCollapsed(false);
|
||||||
|
setShowFilters(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFilters = () => {
|
||||||
|
setFilterCollapsed(true);
|
||||||
|
setShowFilters(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFilterPanelContent = () => (
|
||||||
|
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||||
|
<div className="filter-panel-section">
|
||||||
|
<div className="filter-panel-title">
|
||||||
|
品牌
|
||||||
|
{activeBrand && <button onClick={() => setActiveBrand(null)}>重置</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>
|
||||||
|
全部品牌
|
||||||
|
<span className="filter-item-count">{routes.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 }}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
其他
|
||||||
|
<span className="filter-item-count">{brandList.otherCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-panel-section">
|
||||||
|
<div className="filter-panel-title">
|
||||||
|
{tr('群组')}
|
||||||
|
{activeGroupFilter !== null && <button onClick={() => setActiveGroupFilter(null)}>重置</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`filter-item ${activeGroupFilter === '__all__' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="filter-item-icon"
|
||||||
|
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
◎
|
||||||
|
</span>
|
||||||
|
{tr('全部群组')}
|
||||||
|
<span className="filter-item-count">{groupRouteList.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groupRouteList.map((groupRoute) => (
|
||||||
|
<div
|
||||||
|
key={groupRoute.id}
|
||||||
|
className={`filter-item ${activeGroupFilter === groupRoute.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="filter-item-icon"
|
||||||
|
style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
{groupRoute.icon.kind === 'brand' ? (
|
||||||
|
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
||||||
|
) : groupRoute.icon.kind === 'text' ? (
|
||||||
|
<span style={{ fontSize: 12, lineHeight: 1 }}>{groupRoute.icon.value}</span>
|
||||||
|
) : groupRoute.brand ? (
|
||||||
|
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
||||||
|
) : (
|
||||||
|
<InlineBrandIcon model={groupRoute.modelPattern} size={14} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{groupRoute.title}</span>
|
||||||
|
<span className="filter-item-count">{groupRoute.channelCount}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{siteList.length > 0 && (
|
||||||
|
<div className="filter-panel-section">
|
||||||
|
<div className="filter-panel-title">
|
||||||
|
站点
|
||||||
|
{activeSite && <button onClick={() => setActiveSite(null)}>重置</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`filter-item ${!activeSite ? 'active' : ''}`} onClick={() => setActiveSite(null)}>
|
||||||
|
<span
|
||||||
|
className="filter-item-icon"
|
||||||
|
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
⚡
|
||||||
|
</span>
|
||||||
|
全部站点
|
||||||
|
<span className="filter-item-count">{routes.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{siteList.map(([siteName, { count }]) => (
|
||||||
|
<div
|
||||||
|
key={siteName}
|
||||||
|
className={`filter-item ${activeSite === siteName ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="filter-item-icon"
|
||||||
|
style={{ background: hashColor(siteName), color: 'white', fontSize: 9, borderRadius: 4 }}
|
||||||
|
>
|
||||||
|
{siteAvatarLetters(siteName)}
|
||||||
|
</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{siteName}</span>
|
||||||
|
<span className="filter-item-count">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="filter-panel-section">
|
||||||
|
<div className="filter-panel-title">
|
||||||
|
接口能力
|
||||||
|
{activeEndpointType && <button onClick={() => setActiveEndpointType(null)}>重置</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`filter-item ${!activeEndpointType ? 'active' : ''}`} onClick={() => setActiveEndpointType(null)}>
|
||||||
|
<span
|
||||||
|
className="filter-item-icon"
|
||||||
|
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</span>
|
||||||
|
全部能力
|
||||||
|
<span className="filter-item-count">{routes.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{endpointTypeList.map(([endpointType, count]) => (
|
||||||
|
<div
|
||||||
|
key={endpointType}
|
||||||
|
className={`filter-item ${activeEndpointType === endpointType ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="filter-item-icon"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const iconModel = resolveEndpointTypeIconModel(endpointType);
|
||||||
|
if (!iconModel) return <span style={{ fontSize: 10 }}>⚙</span>;
|
||||||
|
return <InlineBrandIcon model={iconModel} size={14} />;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{endpointType}</span>
|
||||||
|
<span className="filter-item-count">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{endpointTypeList.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '4px 10px 0' }}>
|
||||||
|
暂无接口能力数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '6px 10px',
|
||||||
|
marginTop: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
onClick={closeFilters}
|
||||||
|
>
|
||||||
|
{tr('收起')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const loadRouteDecisions = async (
|
const loadRouteDecisions = async (
|
||||||
routeRows: RouteRow[],
|
routeRows: RouteRow[],
|
||||||
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
|
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
|
||||||
@@ -1401,205 +1618,13 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||||
{filterPanelPresence.shouldRender && (
|
{!isMobile && filterPanelPresence.shouldRender && renderFilterPanelContent()}
|
||||||
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
{isMobile && (
|
||||||
<div className="filter-panel-section">
|
<MobileDrawer open={showFilters} onClose={closeFilters}>
|
||||||
<div className="filter-panel-title">
|
<div className="mobile-filter-panel">
|
||||||
品牌
|
{filterPanelPresence.shouldRender && renderFilterPanelContent()}
|
||||||
{activeBrand && <button onClick={() => setActiveBrand(null)}>重置</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>
|
|
||||||
全部品牌
|
|
||||||
<span className="filter-item-count">{routes.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 }}
|
|
||||||
>
|
|
||||||
?
|
|
||||||
</span>
|
|
||||||
其他
|
|
||||||
<span className="filter-item-count">{brandList.otherCount}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</MobileDrawer>
|
||||||
<div className="filter-panel-section">
|
|
||||||
<div className="filter-panel-title">
|
|
||||||
{tr('群组')}
|
|
||||||
{activeGroupFilter !== null && <button onClick={() => setActiveGroupFilter(null)}>重置</button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`filter-item ${activeGroupFilter === '__all__' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="filter-item-icon"
|
|
||||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
|
||||||
>
|
|
||||||
◎
|
|
||||||
</span>
|
|
||||||
{tr('全部群组')}
|
|
||||||
<span className="filter-item-count">{groupRouteList.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{groupRouteList.map((groupRoute) => (
|
|
||||||
<div
|
|
||||||
key={groupRoute.id}
|
|
||||||
className={`filter-item ${activeGroupFilter === groupRoute.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="filter-item-icon"
|
|
||||||
style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
{groupRoute.icon.kind === 'brand' ? (
|
|
||||||
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
|
||||||
) : groupRoute.icon.kind === 'text' ? (
|
|
||||||
<span style={{ fontSize: 12, lineHeight: 1 }}>{groupRoute.icon.value}</span>
|
|
||||||
) : groupRoute.brand ? (
|
|
||||||
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
|
||||||
) : (
|
|
||||||
<InlineBrandIcon model={groupRoute.modelPattern} size={14} />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{groupRoute.title}</span>
|
|
||||||
<span className="filter-item-count">{groupRoute.channelCount}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{siteList.length > 0 && (
|
|
||||||
<div className="filter-panel-section">
|
|
||||||
<div className="filter-panel-title">
|
|
||||||
站点
|
|
||||||
{activeSite && <button onClick={() => setActiveSite(null)}>重置</button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`filter-item ${!activeSite ? 'active' : ''}`} onClick={() => setActiveSite(null)}>
|
|
||||||
<span
|
|
||||||
className="filter-item-icon"
|
|
||||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
|
||||||
>
|
|
||||||
⚡
|
|
||||||
</span>
|
|
||||||
全部站点
|
|
||||||
<span className="filter-item-count">{routes.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{siteList.map(([siteName, { count }]) => (
|
|
||||||
<div
|
|
||||||
key={siteName}
|
|
||||||
className={`filter-item ${activeSite === siteName ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="filter-item-icon"
|
|
||||||
style={{ background: hashColor(siteName), color: 'white', fontSize: 9, borderRadius: 4 }}
|
|
||||||
>
|
|
||||||
{siteAvatarLetters(siteName)}
|
|
||||||
</span>
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{siteName}</span>
|
|
||||||
<span className="filter-item-count">{count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="filter-panel-section">
|
|
||||||
<div className="filter-panel-title">
|
|
||||||
接口能力
|
|
||||||
{activeEndpointType && <button onClick={() => setActiveEndpointType(null)}>重置</button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`filter-item ${!activeEndpointType ? 'active' : ''}`} onClick={() => setActiveEndpointType(null)}>
|
|
||||||
<span
|
|
||||||
className="filter-item-icon"
|
|
||||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</span>
|
|
||||||
全部能力
|
|
||||||
<span className="filter-item-count">{routes.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{endpointTypeList.map(([endpointType, count]) => (
|
|
||||||
<div
|
|
||||||
key={endpointType}
|
|
||||||
className={`filter-item ${activeEndpointType === endpointType ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="filter-item-icon"
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-bg)',
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
fontSize: 10,
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const iconModel = resolveEndpointTypeIconModel(endpointType);
|
|
||||||
if (!iconModel) return <span style={{ fontSize: 10 }}>⚙</span>;
|
|
||||||
return <InlineBrandIcon model={iconModel} size={14} />;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{endpointType}</span>
|
|
||||||
<span className="filter-item-count">{count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{endpointTypeList.length === 0 && (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '4px 10px 0' }}>
|
|
||||||
暂无接口能力数据
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
fontSize: 12,
|
|
||||||
padding: '6px 10px',
|
|
||||||
marginTop: 8,
|
|
||||||
justifyContent: 'center',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
}}
|
|
||||||
onClick={() => setFilterCollapsed(true)}
|
|
||||||
>
|
|
||||||
{tr('收起')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -1627,7 +1652,15 @@ export default function TokenRoutes() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-actions" style={{ flexWrap: 'wrap' }}>
|
<div className="page-actions" style={{ flexWrap: 'wrap' }}>
|
||||||
{filterCollapsed && (
|
{isMobile ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||||
|
onClick={openFilters}
|
||||||
|
>
|
||||||
|
{tr('筛选')}
|
||||||
|
</button>
|
||||||
|
) : (filterCollapsed && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||||
@@ -1635,7 +1668,7 @@ export default function TokenRoutes() {
|
|||||||
>
|
>
|
||||||
{tr('筛选')}
|
{tr('筛选')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleRefreshRouteDecisions}
|
onClick={handleRefreshRouteDecisions}
|
||||||
@@ -1878,7 +1911,10 @@ export default function TokenRoutes() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div
|
||||||
|
className={isMobile ? 'mobile-card-list' : ''}
|
||||||
|
style={isMobile ? undefined : { display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||||
|
>
|
||||||
{visibleRoutes.map((route, i) => {
|
{visibleRoutes.map((route, i) => {
|
||||||
const candidateView = getRouteCandidateView(route.id);
|
const candidateView = getRouteCandidateView(route.id);
|
||||||
const routeCandidates = candidateView.routeCandidates;
|
const routeCandidates = candidateView.routeCandidates;
|
||||||
@@ -1949,7 +1985,7 @@ export default function TokenRoutes() {
|
|||||||
const routeIcon = resolveRouteIcon(route);
|
const routeIcon = resolveRouteIcon(route);
|
||||||
const routeBrand = routeBrandById.get(route.id) || null;
|
const routeBrand = routeBrandById.get(route.id) || null;
|
||||||
|
|
||||||
return (
|
const routeCard = (
|
||||||
<div
|
<div
|
||||||
key={route.id}
|
key={route.id}
|
||||||
className={`card animate-slide-up stagger-${Math.min(i + 1, 5)}`}
|
className={`card animate-slide-up stagger-${Math.min(i + 1, 5)}`}
|
||||||
@@ -2327,6 +2363,44 @@ export default function TokenRoutes() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isExpanded = expandedRouteIds.includes(route.id);
|
||||||
|
|
||||||
|
return isMobile ? (
|
||||||
|
<MobileCard
|
||||||
|
key={route.id}
|
||||||
|
title={resolveRouteTitle(route)}
|
||||||
|
actions={(
|
||||||
|
<span className={`badge ${route.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 10 }}>
|
||||||
|
{route.enabled ? tr('启用') : tr('禁用')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MobileField label="模型" value={route.modelPattern} />
|
||||||
|
<MobileField label="通道" value={route.channels?.length || 0} />
|
||||||
|
<MobileField label="状态" value={route.enabled ? tr('启用') : tr('禁用')} />
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="mobile-card-extra">
|
||||||
|
{routeCard}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mobile-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link"
|
||||||
|
onClick={() => setExpandedRouteIds((prev) => (
|
||||||
|
prev.includes(route.id)
|
||||||
|
? prev.filter((id) => id !== route.id)
|
||||||
|
: [...prev, route.id]
|
||||||
|
))}
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起' : '详情'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
) : (
|
||||||
|
routeCard
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
|
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
|
||||||
|
|||||||
+133
-2
@@ -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: '',
|
||||||
@@ -726,7 +729,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
|||||||
) : null}
|
) : null}
|
||||||
</CenteredModal>
|
</CenteredModal>
|
||||||
|
|
||||||
{selectedTokenIds.length > 0 && (
|
{!isMobile && selectedTokenIds.length > 0 && (
|
||||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedTokenIds.length} 项</span>
|
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedTokenIds.length} 项</span>
|
||||||
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
@@ -741,6 +744,23 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isMobile && selectedTokenIds.length > 0 && (
|
||||||
|
<div className="mobile-actions-bar">
|
||||||
|
<span className="mobile-actions-info">已选 {selectedTokenIds.length} 项</span>
|
||||||
|
<div className="mobile-actions-row">
|
||||||
|
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
批量启用
|
||||||
|
</button>
|
||||||
|
<button onClick={() => runBatchTokenAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
批量禁用
|
||||||
|
</button>
|
||||||
|
<button data-testid="tokens-batch-delete" onClick={() => runBatchTokenAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
|
||||||
|
批量删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<CenteredModal
|
<CenteredModal
|
||||||
open={showAdd}
|
open={showAdd}
|
||||||
onClose={handleToggleAdd}
|
onClose={handleToggleAdd}
|
||||||
@@ -868,7 +888,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 +1128,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('Accounts mobile layout', () => {
|
||||||
|
it('includes hints field in mobile expanded section', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Accounts.tsx'), 'utf8');
|
||||||
|
expect(source).toContain('label="提示"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('ProxyLogs mobile layout', () => {
|
||||||
|
it('renders mobile cards for proxy logs', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProxyLogs.tsx'), 'utf8');
|
||||||
|
expect(source).toContain('MobileCard');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('Mobile actions bar styles', () => {
|
||||||
|
it('defines .mobile-actions-bar in index.css', () => {
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||||
|
expect(css).toContain('.mobile-actions-bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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