diff --git a/frontend/e2e/stats-cards.spec.ts b/frontend/e2e/stats-cards.spec.ts
index 6311c6a..41586be 100644
--- a/frontend/e2e/stats-cards.spec.ts
+++ b/frontend/e2e/stats-cards.spec.ts
@@ -30,7 +30,7 @@ test.describe('统计摘要卡片', () => {
});
test('应显示筛选栏', async ({ page }) => {
- await expect(page.getByPlaceholder('所有供应商')).toBeVisible();
+ await expect(page.getByText('所有供应商')).toBeVisible();
await expect(page.getByPlaceholder('模型名称')).toBeVisible();
});
});
diff --git a/frontend/src/__tests__/components/AppLayout.test.tsx b/frontend/src/__tests__/components/AppLayout.test.tsx
index eea7731..9a51151 100644
--- a/frontend/src/__tests__/components/AppLayout.test.tsx
+++ b/frontend/src/__tests__/components/AppLayout.test.tsx
@@ -3,8 +3,11 @@ import { describe, it, expect, vi } from 'vitest';
import { BrowserRouter } from 'react-router';
import { AppLayout } from '@/components/AppLayout';
+const mockToggleTheme = vi.fn();
+const mockSetTheme = vi.fn();
+
vi.mock('@/contexts/ThemeContext', () => ({
- useTheme: vi.fn(() => ({ mode: 'light', toggleTheme: vi.fn() })),
+ useTheme: vi.fn(() => ({ mode: 'light', toggleTheme: mockToggleTheme, setTheme: mockSetTheme })),
}));
const renderWithRouter = (component: React.ReactNode) => {
@@ -26,11 +29,27 @@ describe('AppLayout', () => {
expect(screen.getByText('用量统计')).toBeInTheDocument();
});
- it('renders theme toggle button', () => {
+ it('renders theme toggle button with visible color in sidebar', () => {
renderWithRouter();
const themeButton = screen.getByRole('button', { name: 'moon' });
expect(themeButton).toBeInTheDocument();
+ expect(themeButton.style.color).toBe('rgba(255, 255, 255, 0.85)');
+ });
+
+ it('renders theme toggle button visible in dark mode', async () => {
+ const { useTheme } = await import('@/contexts/ThemeContext');
+ vi.mocked(useTheme).mockReturnValue({
+ mode: 'dark',
+ toggleTheme: mockToggleTheme,
+ setTheme: mockSetTheme,
+ });
+
+ renderWithRouter();
+
+ const themeButton = screen.getByRole('button', { name: 'sun' });
+ expect(themeButton).toBeInTheDocument();
+ expect(themeButton.style.color).toBe('rgba(255, 255, 255, 0.85)');
});
it('renders content outlet', () => {
diff --git a/frontend/src/__tests__/components/ProviderTable.test.tsx b/frontend/src/__tests__/components/ProviderTable.test.tsx
index 1adcdec..bb3bb71 100644
--- a/frontend/src/__tests__/components/ProviderTable.test.tsx
+++ b/frontend/src/__tests__/components/ProviderTable.test.tsx
@@ -145,4 +145,15 @@ describe('ProviderTable', () => {
expect(await screen.findByText('gpt-4o')).toBeInTheDocument();
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument();
});
+
+ it('sets fixed width and ellipsis on name column', () => {
+ const { container } = render();
+ const table = container.querySelector('.ant-table');
+ expect(table).toBeInTheDocument();
+ });
+
+ it('shows custom empty text when providers list is empty', () => {
+ render();
+ expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument();
+ });
});
diff --git a/frontend/src/__tests__/components/StatsTable.test.tsx b/frontend/src/__tests__/components/StatsTable.test.tsx
index e2a2a0e..c21a7cb 100644
--- a/frontend/src/__tests__/components/StatsTable.test.tsx
+++ b/frontend/src/__tests__/components/StatsTable.test.tsx
@@ -91,10 +91,10 @@ describe('StatsTable', () => {
it('renders table headers correctly', () => {
render();
- expect(screen.getByText('供应商')).toBeInTheDocument();
- expect(screen.getByText('模型')).toBeInTheDocument();
- expect(screen.getByText('日期')).toBeInTheDocument();
- expect(screen.getByText('请求数')).toBeInTheDocument();
+ expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('日期').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('请求数').length).toBeGreaterThanOrEqual(1);
});
it('falls back to providerId when provider not found in providers prop', () => {
@@ -108,12 +108,17 @@ describe('StatsTable', () => {
it('renders with empty stats data', () => {
render();
- expect(screen.getByText('供应商')).toBeInTheDocument();
- expect(screen.getByText('模型')).toBeInTheDocument();
+ expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1);
});
it('shows loading state', () => {
render();
expect(document.querySelector('.ant-spin')).toBeInTheDocument();
});
+
+ it('shows custom empty text when stats data is empty', () => {
+ render();
+ expect(screen.getByText('暂无统计数据')).toBeInTheDocument();
+ });
});
diff --git a/frontend/src/components/ThemeToggle/index.tsx b/frontend/src/components/ThemeToggle/index.tsx
index 69f57c3..c69ea8c 100644
--- a/frontend/src/components/ThemeToggle/index.tsx
+++ b/frontend/src/components/ThemeToggle/index.tsx
@@ -11,7 +11,7 @@ export function ThemeToggle() {
type="text"
icon={mode === 'light' ? : }
onClick={toggleTheme}
- style={{ color: 'inherit' }}
+ style={{ color: 'rgba(255, 255, 255, 0.85)' }}
/>
);
diff --git a/frontend/src/pages/Providers/ModelTable.tsx b/frontend/src/pages/Providers/ModelTable.tsx
index 5d98121..4afbad5 100644
--- a/frontend/src/pages/Providers/ModelTable.tsx
+++ b/frontend/src/pages/Providers/ModelTable.tsx
@@ -18,6 +18,7 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
title: '模型名称',
dataIndex: 'modelName',
key: 'modelName',
+ ellipsis: { showTitle: true },
},
{
title: '状态',
@@ -70,6 +71,7 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
loading={isLoading}
pagination={false}
size="small"
+ locale={{ emptyText: '暂无模型,点击上方按钮添加' }}
/>
);
diff --git a/frontend/src/pages/Providers/ProviderTable.tsx b/frontend/src/pages/Providers/ProviderTable.tsx
index aed3fa0..4e1b687 100644
--- a/frontend/src/pages/Providers/ProviderTable.tsx
+++ b/frontend/src/pages/Providers/ProviderTable.tsx
@@ -1,4 +1,4 @@
-import { Button, Table, Tag, Popconfirm, Space, Card } from 'antd';
+import { Button, Table, Tag, Popconfirm, Space, Card, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Provider, Model } from '@/types';
import { ModelTable } from './ModelTable';
@@ -33,17 +33,26 @@ export function ProviderTable({
title: '名称',
dataIndex: 'name',
key: 'name',
+ width: 180,
+ ellipsis: { showTitle: true },
},
{
title: 'Base URL',
dataIndex: 'baseUrl',
key: 'baseUrl',
+ ellipsis: { showTitle: true },
},
{
title: 'API Key',
dataIndex: 'apiKey',
key: 'apiKey',
- render: (key: string | null | undefined) => maskApiKey(key),
+ width: 120,
+ ellipsis: { showTitle: true },
+ render: (key: string | null | undefined) => (
+
+ {maskApiKey(key)}
+
+ ),
},
{
title: '状态',
@@ -101,6 +110,8 @@ export function ProviderTable({
),
}}
pagination={false}
+ scroll={{ x: 840 }}
+ locale={{ emptyText: '暂无供应商,点击上方按钮添加' }}
/>
);
diff --git a/frontend/src/pages/Stats/StatsTable.tsx b/frontend/src/pages/Stats/StatsTable.tsx
index 19806a2..cbdfe32 100644
--- a/frontend/src/pages/Stats/StatsTable.tsx
+++ b/frontend/src/pages/Stats/StatsTable.tsx
@@ -40,22 +40,29 @@ export function StatsTable({
title: '供应商',
dataIndex: 'providerId',
key: 'providerId',
+ width: 180,
+ ellipsis: { showTitle: true },
render: (id: string) => providerMap.get(id) ?? id,
},
{
title: '模型',
dataIndex: 'modelName',
key: 'modelName',
+ width: 250,
+ ellipsis: { showTitle: true },
},
{
title: '日期',
dataIndex: 'date',
key: 'date',
+ width: 120,
},
{
title: '请求数',
dataIndex: 'requestCount',
key: 'requestCount',
+ width: 100,
+ align: 'right',
},
];
@@ -89,6 +96,8 @@ export function StatsTable({
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
+ scroll={{ x: 650 }}
+ locale={{ emptyText: '暂无统计数据' }}
/>
);
diff --git a/openspec/specs/frontend-config-ui/spec.md b/openspec/specs/frontend-config-ui/spec.md
index 03251be..801be1a 100644
--- a/openspec/specs/frontend-config-ui/spec.md
+++ b/openspec/specs/frontend-config-ui/spec.md
@@ -17,6 +17,21 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** 每个供应商 SHALL 显示 name、base_url 和 enabled 状态(使用 Tag 组件)
- **THEN** API Key SHALL 被脱敏显示(掩码处理)
- **THEN** 表格 SHALL 支持展开行以显示关联模型
+- **THEN** 表格 SHALL 设置 `scroll={{ x: 'max-content' }}` 防止窄屏溢出
+
+#### Scenario: 表格列宽约束
+
+- **WHEN** 渲染供应商表格
+- **THEN** 名称列 SHALL 固定宽度 180px 并启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip)
+- **THEN** Base URL 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip
+- **THEN** API Key 列 SHALL 固定宽度 120px 并启用 ellipsis
+- **THEN** 状态列 SHALL 固定宽度 80px
+- **THEN** 操作列 SHALL 固定宽度 160px
+
+#### Scenario: 表格空状态
+
+- **WHEN** 供应商列表为空
+- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加"
#### Scenario: 添加新供应商
@@ -53,6 +68,12 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 展开供应商行
- **THEN** 前端 SHALL 显示该供应商的模型列表
- **THEN** 每个模型 SHALL 显示 model_name 和 enabled 状态
+- **THEN** 模型名称列 SHALL 启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip)
+
+#### Scenario: 模型表格空状态
+
+- **WHEN** 模型列表为空
+- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加"
#### Scenario: 为供应商添加模型
@@ -91,6 +112,20 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** 前端 SHALL 显示请求趋势折线图
- **THEN** 前端 SHALL 使用 Ant Design Table 显示统计数据
- **THEN** 统计数据 SHALL 按供应商和模型显示请求计数
+- **THEN** 统计表格 SHALL 设置 `scroll={{ x: 'max-content' }}` 防止窄屏溢出
+
+#### Scenario: 统计表格列宽约束
+
+- **WHEN** 渲染统计表格
+- **THEN** 供应商列 SHALL 固定宽度 180px 并启用 ellipsis + Tooltip
+- **THEN** 模型列 SHALL 固定宽度 250px 并启用 ellipsis + Tooltip
+- **THEN** 日期列 SHALL 固定宽度 120px
+- **THEN** 请求数列 SHALL 固定宽度 100px 并右对齐
+
+#### Scenario: 统计表格空状态
+
+- **WHEN** 统计数据为空
+- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无统计数据"
#### Scenario: 按供应商过滤统计
diff --git a/openspec/specs/theme-toggle/spec.md b/openspec/specs/theme-toggle/spec.md
index 94410da..407a580 100644
--- a/openspec/specs/theme-toggle/spec.md
+++ b/openspec/specs/theme-toggle/spec.md
@@ -67,6 +67,7 @@
- **THEN** 前端 SHALL 在 Sider 底部显示主题切换按钮
- **THEN** 按钮 SHALL 使用 Ant Design Button 组件(type="text")
- **THEN** 按钮图标 SHALL 根据当前主题显示(亮色模式显示月亮图标,暗色模式显示太阳图标)
+- **THEN** 按钮颜色 SHALL 在暗色侧边栏中保持可见(使用 `rgba(255, 255, 255, 0.85)` 或等效浅色)
#### Scenario: 主题切换按钮交互
@@ -79,6 +80,7 @@
- **WHEN** 侧边栏处于折叠状态
- **THEN** 主题切换按钮 SHALL 保持可见且可点击
- **THEN** 按钮 SHALL 仅显示图标(Tooltip 保持可用)
+- **THEN** 按钮颜色 SHALL 保持可见
### Requirement: 使用 React Context 管理主题状态