feat: 实现大模型模型列表与供应商管理,并接入 Playwright 测试

补齐 LLM 子菜单路由,落地模型列表与供应商管理页面交互,同时引入功能与视觉回归用例以便后续页面持续验证。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-26 02:01:40 +08:00
parent a09dcef3f1
commit c4872891f8
23 changed files with 1117 additions and 7 deletions

3
.gitignore vendored
View File

@@ -12,6 +12,9 @@ dist
dist-ssr dist-ssr
*.local *.local
.pnpm-store .pnpm-store
playwright-report
test-results
blob-report
# Env # Env
.env .env

24
e2e/README.md Normal file
View File

@@ -0,0 +1,24 @@
# E2E 与视觉回归
## 命令
```bash
pnpm test:e2e # 功能测试 + 视觉回归
pnpm test:e2e:ui # Playwright UI 模式
pnpm test:e2e:update # 确认 UI 改动后更新截图基线
```
测试默认复用本机 Google Chrome并由 Playwright 自动启动 Vite。
## 目录
- `fixtures/auth.ts`:注入测试登录态,避免每条业务用例重复登录。
- `pages/`:登录、页面功能和交互测试。
- `visual/`:页面截图回归。
- `snapshots/`:由 `test:e2e:update` 生成的基线图。
模型列表、供应商管理的功能和视觉用例已启用。确认页面视觉改动符合预期后,执行
`pnpm test:e2e:update` 更新基线。
设计目标图位于 `docs/ui/llm/list/`;截图基线用于检测后续代码造成的
视觉变化,不替代设计稿还原度审查。

19
e2e/fixtures/auth.ts Normal file
View File

@@ -0,0 +1,19 @@
import { expect, test as base } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, applyFixture) => {
await page.addInitScript(() => {
localStorage.setItem(
'user-store',
JSON.stringify({
state: { token: 'e2e-token' },
version: 0,
}),
);
});
await applyFixture(page);
},
});
export { expect };

View File

@@ -0,0 +1,9 @@
import { expect, test } from '../fixtures/auth';
test('登录态可直接访问总览', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByText('近7日对话趋势')).toBeVisible();
await expect(page.getByText('模型调用占比')).toBeVisible();
await expect(page.getByText('异常告警')).toBeVisible();
});

View File

@@ -0,0 +1,48 @@
import { expect, test } from '../fixtures/auth';
test.describe('大模型 / 模型列表', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/llm/models');
await page.getByRole('table').waitFor();
});
test('展示列表及主要操作', async ({ page }) => {
await expect(page.getByRole('button', { name: '新增模型' })).toBeVisible();
await expect(page.getByRole('table')).toBeVisible();
await expect(page.getByText('GPT-4o', { exact: true })).toBeVisible();
});
test('可新增模型', async ({ page }) => {
await page.getByRole('button', { name: '新增模型' }).click();
const dialog = page.getByRole('dialog', { name: '新增模型' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('模型名称').fill('测试模型');
await dialog.getByLabel('模型 ID').fill('test-model');
await dialog.getByRole('button', { name: '保 存' }).click();
await expect(page.getByText('测试模型', { exact: true })).toBeVisible();
});
test('可切换模型状态并查看用量', async ({ page }) => {
const statusSwitch = page.getByRole('switch', { name: 'GPT-4o状态' });
await expect(statusSwitch).toBeChecked();
await statusSwitch.click();
await expect(statusSwitch).not.toBeChecked();
const row = page.getByRole('row').filter({ hasText: 'GPT-4o' });
await row.getByRole('button', { name: '用量' }).click();
await expect(page.getByText('GPT-4o 用量详情')).toBeVisible();
await expect(page.getByText('近 7 日用量趋势')).toBeVisible();
});
test('可调整排序并删除模型', async ({ page }) => {
await page.getByRole('spinbutton', { name: 'GPT-4o排序' }).fill('2');
await expect(page.locator('.ant-table-tbody .ant-table-row').first()).toContainText('Claude-3.5-Sonnet');
const row = page.getByRole('row').filter({ hasText: 'GPT-4o' });
await row.getByRole('button', { name: '删除' }).click();
await page.getByRole('button', { name: '确 定' }).click();
await expect(page.getByText('GPT-4o', { exact: true })).toHaveCount(0);
});
});

View File

@@ -0,0 +1,54 @@
import { expect, test } from '../fixtures/auth';
test.describe('大模型 / 供应商管理', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/llm/providers');
await page.getByRole('table').waitFor();
});
test('展示列表及主要操作', async ({ page }) => {
await expect(page.getByRole('button', { name: '新增供应商' })).toBeVisible();
await expect(page.getByRole('table')).toBeVisible();
await expect(page.getByText('OpenAI', { exact: true })).toBeVisible();
await expect(page.getByText('https://api.openai.com/v1')).toBeVisible();
});
test('可搜索供应商', async ({ page }) => {
await page.getByPlaceholder('搜索供应商名称').fill('DeepSeek');
await expect(page.getByText('DeepSeek', { exact: true })).toBeVisible();
await expect(page.getByText('OpenAI', { exact: true })).toHaveCount(0);
});
test('可新增供应商', async ({ page }) => {
await page.getByRole('button', { name: '新增供应商' }).click();
const dialog = page.getByRole('dialog', { name: '新增供应商' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('供应商名称').fill('测试供应商');
await dialog.getByLabel('Base URL').fill('https://api.test.com/v1');
await dialog.getByRole('button', { name: '保 存' }).click();
await expect(page.getByText('测试供应商', { exact: true })).toBeVisible();
});
test('可切换供应商状态并测试连通', async ({ page }) => {
const statusSwitch = page.getByRole('switch', { name: 'OpenAI状态' });
await expect(statusSwitch).toBeChecked();
await statusSwitch.click();
await expect(statusSwitch).not.toBeChecked();
const row = page.getByRole('row').filter({ hasText: 'OpenAI' });
await row.getByRole('button', { name: '测试连通' }).click();
await expect(page.getByText('OpenAI 连通正常')).toBeVisible();
});
test('可编辑凭证', async ({ page }) => {
const row = page.getByRole('row').filter({ hasText: 'DeepSeek' });
await row.getByRole('button', { name: '编辑凭证' }).click();
const dialog = page.getByRole('dialog', { name: '编辑供应商' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('API Key').fill('sk-test-key');
await dialog.getByRole('button', { name: '保 存' }).click();
await expect(page.getByText('供应商已更新')).toBeVisible();
});
});

12
e2e/pages/login.spec.ts Normal file
View File

@@ -0,0 +1,12 @@
import { expect, test } from '@playwright/test';
test('账号密码可登录并进入总览', async ({ page }) => {
await page.goto('/login');
await page.getByPlaceholder('用户名: admin or user').fill('admin');
await page.getByPlaceholder('密码: 123456').fill('123456');
await page.getByRole('button', { name: '登 录' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText('近7日对话趋势')).toBeVisible();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -0,0 +1,12 @@
import { expect, test } from '../fixtures/auth';
test('总览页面视觉基线', async ({ page }) => {
await page.goto('/dashboard');
await page.getByText('近7日对话趋势').waitFor();
await page.locator('canvas').first().waitFor();
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot('dashboard.png', {
fullPage: true,
});
});

View File

@@ -0,0 +1,11 @@
import { expect, test } from '../fixtures/auth';
test('模型列表视觉基线', async ({ page }) => {
await page.goto('/llm/models');
await page.getByRole('table').waitFor();
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot('llm-models-list.png', {
fullPage: true,
});
});

View File

@@ -0,0 +1,11 @@
import { expect, test } from '../fixtures/auth';
test('供应商管理视觉基线', async ({ page }) => {
await page.goto('/llm/providers');
await page.getByRole('table').waitFor();
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot('llm-providers-list.png', {
fullPage: true,
});
});

View File

@@ -10,6 +10,9 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:update": "playwright test --update-snapshots",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -31,6 +34,7 @@
"@commitlint/cli": "^21.2.1", "@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0", "@commitlint/config-conventional": "^21.2.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

33
playwright.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';
import process from 'node:process';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
use: {
baseURL: 'http://127.0.0.1:8083',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'pnpm dev --host 127.0.0.1',
url: 'http://127.0.0.1:8083',
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
},
],
snapshotPathTemplate: 'e2e/snapshots/{projectName}/{testFilePath}/{arg}{ext}',
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.02,
animations: 'disabled',
},
},
});

38
pnpm-lock.yaml generated
View File

@@ -57,6 +57,9 @@ importers:
'@eslint/js': '@eslint/js':
specifier: ^10.0.1 specifier: ^10.0.1
version: 10.0.1(eslint@10.6.0(jiti@2.6.1)) version: 10.0.1(eslint@10.6.0(jiti@2.6.1))
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
'@types/node': '@types/node':
specifier: ^24.13.2 specifier: ^24.13.2
version: 24.13.2 version: 24.13.2
@@ -583,6 +586,11 @@ packages:
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@rc-component/async-validator@6.0.0': '@rc-component/async-validator@6.0.0':
resolution: {integrity: sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==} resolution: {integrity: sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==}
engines: {node: '>=14.x'} engines: {node: '>=14.x'}
@@ -1644,6 +1652,11 @@ packages:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2051,6 +2064,16 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'} engines: {node: '>=12'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.16: postcss@8.5.16:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -3197,6 +3220,10 @@ snapshots:
'@pkgr/core@0.3.6': {} '@pkgr/core@0.3.6': {}
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@rc-component/async-validator@6.0.0': '@rc-component/async-validator@6.0.0':
dependencies: dependencies:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
@@ -4311,6 +4338,9 @@ snapshots:
hasown: 2.0.4 hasown: 2.0.4
mime-types: 2.1.35 mime-types: 2.1.35
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -4655,6 +4685,14 @@ snapshots:
picomatch@4.0.5: {} picomatch@4.0.5: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.16: postcss@8.5.16:
dependencies: dependencies:
nanoid: 3.3.15 nanoid: 3.3.15

View File

@@ -16,6 +16,38 @@ const mockMenus: MenuItem[] = [
name: '大模型管理', name: '大模型管理',
path: '/llm', path: '/llm',
icon: 'BlockOutlined', icon: 'BlockOutlined',
children: [
{
id: '2-1',
name: '模型列表',
path: '/llm/models',
icon: 'AppstoreOutlined',
},
{
id: '2-2',
name: '供应商管理',
path: '/llm/providers',
icon: 'CloudServerOutlined',
},
{
id: '2-3',
name: '用量统计',
path: '/llm/usage',
icon: 'LineChartOutlined',
},
{
id: '2-4',
name: '用量明细',
path: '/llm/usage/details',
icon: 'UnorderedListOutlined',
},
{
id: '2-5',
name: '限流配额',
path: '/llm/quota',
icon: 'ControlOutlined',
},
],
}, },
{ {
id: '3', id: '3',
@@ -46,6 +78,5 @@ const mockMenus: MenuItem[] = [
export async function getMenus(): Promise<ApiResponse<MenuItem[]>> { export async function getMenus(): Promise<ApiResponse<MenuItem[]>> {
await delay(); await delay();
console.log(mockMenus);
return { code: 200, data: mockMenus, message: 'ok' }; return { code: 200, data: mockMenus, message: 'ok' };
} }

View File

@@ -1,7 +1,435 @@
import 'react'; import { DeleteOutlined, EditOutlined, HolderOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import { Line } from '@ant-design/charts';
import { ProCard } from '@ant-design/pro-components';
import {
App,
Button,
Col,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Statistic,
Switch,
Table,
Tag,
Typography,
theme,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useMemo, useState } from 'react';
function LLM() { import {
return <div></div>; initialModels,
recentCalls,
usageTrend,
type ModelItem,
type ModelProvider,
type ModelType,
type RecentCall,
} from './mock';
interface ModelFormValues {
name: string;
provider: ModelProvider;
modelId: string;
type: ModelType;
order: number;
enabled: boolean;
} }
export default LLM; const providerOptions: ModelProvider[] = ['OpenAI', 'Anthropic', '阿里云百炼', 'Google', 'DeepSeek'];
const typeOptions: ModelType[] = ['对话', '嵌入', '多模态'];
const normalizeOrder = (items: ModelItem[]) => items.map((item, index) => ({ ...item, order: index + 1 }));
export default function LLM() {
const { message } = App.useApp();
const { token } = theme.useToken();
const [form] = Form.useForm<ModelFormValues>();
const [models, setModels] = useState(initialModels);
const [keyword, setKeyword] = useState('');
const [provider, setProvider] = useState<ModelProvider>();
const [enabled, setEnabled] = useState<boolean>();
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [editingModel, setEditingModel] = useState<ModelItem>();
const [formOpen, setFormOpen] = useState(false);
const [usageModel, setUsageModel] = useState<ModelItem>();
const [draggingId, setDraggingId] = useState<string>();
const filteredModels = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
return models.filter(item => {
const matchesKeyword =
!normalizedKeyword ||
item.name.toLowerCase().includes(normalizedKeyword) ||
item.modelId.toLowerCase().includes(normalizedKeyword);
return (
matchesKeyword &&
(!provider || item.provider === provider) &&
(enabled === undefined || item.enabled === enabled)
);
});
}, [enabled, keyword, models, provider]);
const openCreate = () => {
setEditingModel(undefined);
form.setFieldsValue({
name: '',
provider: 'OpenAI',
modelId: '',
type: '对话',
order: models.length + 1,
enabled: true,
});
setFormOpen(true);
};
const openEdit = (record: ModelItem) => {
setEditingModel(record);
form.setFieldsValue(record);
setFormOpen(true);
};
const saveModel = async () => {
const values = await form.validateFields();
if (editingModel) {
const updated = models.map(item => (item.id === editingModel.id ? { ...item, ...values } : item));
const target = updated.find(item => item.id === editingModel.id)!;
const withoutTarget = updated.filter(item => item.id !== editingModel.id);
withoutTarget.splice(Math.max(0, values.order - 1), 0, target);
setModels(normalizeOrder(withoutTarget));
message.success('模型已更新');
} else {
const newModel: ModelItem = {
...values,
id: `model-${Date.now()}`,
todayToken: 0,
updatedAt: '2026-07-26 00:00:00',
};
const next = [...models];
next.splice(Math.max(0, values.order - 1), 0, newModel);
setModels(normalizeOrder(next));
message.success('模型已添加');
}
setFormOpen(false);
form.resetFields();
};
const deleteModels = (ids: React.Key[]) => {
setModels(current => normalizeOrder(current.filter(item => !ids.includes(item.id))));
setSelectedKeys([]);
message.success(`已删除 ${ids.length} 个模型`);
};
const changeOrder = (record: ModelItem, nextOrder: number | null) => {
if (!nextOrder) return;
const withoutCurrent = models.filter(item => item.id !== record.id);
withoutCurrent.splice(Math.min(Math.max(nextOrder - 1, 0), withoutCurrent.length), 0, record);
setModels(normalizeOrder(withoutCurrent));
};
const dropModel = (targetId: string) => {
if (!draggingId || draggingId === targetId) return;
const source = models.find(item => item.id === draggingId);
const targetIndex = models.findIndex(item => item.id === targetId);
if (!source || targetIndex < 0) return;
const next = models.filter(item => item.id !== draggingId);
next.splice(targetIndex, 0, source);
setModels(normalizeOrder(next));
setDraggingId(undefined);
message.success('排序已更新');
};
const columns: ColumnsType<ModelItem> = [
{
title: '',
key: 'drag',
width: 48,
align: 'center',
render: (_, record) => (
<Button
type="text"
aria-label={`拖动排序-${record.name}`}
icon={<HolderOutlined />}
style={{ cursor: 'grab', color: token.colorTextTertiary }}
/>
),
},
{ title: '模型名称', dataIndex: 'name', width: 170, ellipsis: true },
{
title: '供应商',
dataIndex: 'provider',
width: 130,
render: (value: ModelProvider) => <Tag color="blue">{value}</Tag>,
},
{ title: '模型 ID', dataIndex: 'modelId', width: 220, ellipsis: true },
{
title: '类型',
dataIndex: 'type',
width: 90,
render: (value: ModelType) => <Tag>{value}</Tag>,
},
{
title: '状态',
dataIndex: 'enabled',
width: 100,
render: (value: boolean, record) => (
<Switch
size="small"
checked={value}
checkedChildren="启用"
unCheckedChildren="禁用"
aria-label={`${record.name}状态`}
onChange={checked => {
setModels(current => current.map(item => (item.id === record.id ? { ...item, enabled: checked } : item)));
message.success(checked ? '模型已启用' : '模型已禁用');
}}
/>
),
},
{
title: '今日 Token',
dataIndex: 'todayToken',
width: 130,
align: 'right',
render: (value: number) => value.toLocaleString(),
},
{
title: '排序',
dataIndex: 'order',
width: 82,
render: (value: number, record) => (
<InputNumber
aria-label={`${record.name}排序`}
size="small"
min={1}
max={models.length}
value={value}
controls={false}
style={{ width: 48 }}
onChange={next => changeOrder(record, next)}
/>
),
},
{ title: '更新时间', dataIndex: 'updatedAt', width: 170 },
{
title: '操作',
key: 'actions',
width: 180,
fixed: 'right',
render: (_, record) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}>
</Button>
<Button type="link" size="small" onClick={() => setUsageModel(record)}>
</Button>
<Popconfirm title={`确定删除 ${record.name}`} onConfirm={() => deleteModels([record.id])}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
const recentColumns: ColumnsType<RecentCall> = [
{ title: '时间', dataIndex: 'time' },
{ title: 'Token', dataIndex: 'token', align: 'right', render: (value: number) => value.toLocaleString() },
{
title: '状态',
dataIndex: 'status',
align: 'center',
render: (value: RecentCall['status']) => <Tag color={value === '成功' ? 'success' : 'error'}>{value}</Tag>,
},
];
const usageLineConfig = {
data: usageTrend,
xField: 'date',
yField: 'value',
height: 210,
shapeField: 'smooth',
style: { stroke: token.colorPrimary, lineWidth: 2 },
area: {
style: {
fill: `linear-gradient(-90deg, ${token.colorPrimary}00 0%, ${token.colorPrimary}38 100%)`,
},
},
point: { sizeField: 3, shapeField: 'circle', style: { fill: token.colorPrimary } },
axis: {
y: { labelFormatter: (value: number) => `${Math.round(value / 1000000)}M` },
},
};
return (
<>
<ProCard variant="outlined">
<Space vertical size={16} style={{ width: '100%' }}>
<Row gutter={[12, 12]} justify="space-between">
<Col flex="auto">
<Space wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索模型名称或 ID"
style={{ width: 240 }}
value={keyword}
onChange={event => setKeyword(event.target.value)}
/>
<Select
allowClear
placeholder="全部供应商"
style={{ width: 160 }}
options={providerOptions.map(value => ({ label: value, value }))}
value={provider}
onChange={setProvider}
/>
<Select
allowClear
placeholder="全部状态"
style={{ width: 130 }}
options={[
{ label: '已启用', value: true },
{ label: '已禁用', value: false },
]}
value={enabled}
onChange={setEnabled}
/>
</Space>
</Col>
<Col>
<Space>
<Popconfirm
title={`确定删除选中的 ${selectedKeys.length} 个模型?`}
disabled={!selectedKeys.length}
onConfirm={() => deleteModels(selectedKeys)}
>
<Button danger disabled={!selectedKeys.length}>
</Button>
</Popconfirm>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
</Col>
</Row>
<Table<ModelItem>
rowKey="id"
columns={columns}
dataSource={filteredModels}
rowSelection={{ selectedRowKeys: selectedKeys, onChange: setSelectedKeys }}
onRow={record => ({
draggable: true,
onDragStart: () => setDraggingId(record.id),
onDragOver: event => event.preventDefault(),
onDrop: () => dropModel(record.id),
})}
scroll={{ x: 1400 }}
pagination={{ pageSize: 10, showSizeChanger: true, showTotal: total => `${total}` }}
/>
</Space>
</ProCard>
<Modal
open={formOpen}
title={editingModel ? '编辑模型' : '新增模型'}
okText="保存"
cancelText="取消"
width={680}
onOk={saveModel}
onCancel={() => setFormOpen(false)}
>
<Form<ModelFormValues> form={form} layout="vertical" requiredMark="optional">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="模型名称" name="name" rules={[{ required: true, message: '请输入模型名称' }]}>
<Input placeholder="例如 GPT-4o" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="供应商" name="provider" rules={[{ required: true }]}>
<Select options={providerOptions.map(value => ({ label: value, value }))} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="模型 ID" name="modelId" rules={[{ required: true, message: '请输入模型 ID' }]}>
<Input placeholder="例如 gpt-4o" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="模型类型" name="type" rules={[{ required: true }]}>
<Select options={typeOptions.map(value => ({ label: value, value }))} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="排序号" name="order" rules={[{ required: true }]}>
<InputNumber min={1} max={models.length + 1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="状态" name="enabled" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
<Drawer
open={Boolean(usageModel)}
title={`${usageModel?.name ?? ''} 用量详情`}
size={680}
onClose={() => setUsageModel(undefined)}
>
<Space vertical size={16} style={{ width: '100%' }}>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Row gutter={12}>
<Col span={8}>
<ProCard size="small" variant="outlined">
<Statistic title="今日 Token" value={usageModel?.todayToken ?? 0} />
</ProCard>
</Col>
<Col span={8}>
<ProCard size="small" variant="outlined">
<Statistic title="本月 Token" value={34567890} />
</ProCard>
</Col>
<Col span={8}>
<ProCard size="small" variant="outlined">
<Statistic title="调用次数" value={12345} />
</ProCard>
</Col>
</Row>
<ProCard title="近 7 日用量趋势" size="small" variant="outlined">
<Line {...usageLineConfig} />
</ProCard>
<ProCard title="最近调用" size="small" variant="outlined">
<Table<RecentCall>
rowKey="id"
size="small"
columns={recentColumns}
dataSource={recentCalls}
pagination={false}
/>
</ProCard>
<Button block type="primary" ghost>
</Button>
</Space>
</Drawer>
</>
);
}

102
src/pages/llm/mock.ts Normal file
View File

@@ -0,0 +1,102 @@
export type ModelProvider = 'OpenAI' | 'Anthropic' | '阿里云百炼' | 'Google' | 'DeepSeek';
export type ModelType = '对话' | '嵌入' | '多模态';
export interface ModelItem {
id: string;
name: string;
provider: ModelProvider;
modelId: string;
type: ModelType;
enabled: boolean;
todayToken: number;
order: number;
updatedAt: string;
}
export interface UsagePoint {
date: string;
value: number;
}
export interface RecentCall {
id: string;
time: string;
token: number;
status: '成功' | '失败';
}
export const initialModels: ModelItem[] = [
{
id: '1',
name: 'GPT-4o',
provider: 'OpenAI',
modelId: 'gpt-4o',
type: '多模态',
enabled: true,
todayToken: 12345678,
order: 1,
updatedAt: '2026-07-25 14:30:22',
},
{
id: '2',
name: 'Claude-3.5-Sonnet',
provider: 'Anthropic',
modelId: 'claude-3-5-sonnet-20240620',
type: '对话',
enabled: true,
todayToken: 8765432,
order: 2,
updatedAt: '2026-07-25 14:18:45',
},
{
id: '3',
name: '通义千问-Max',
provider: '阿里云百炼',
modelId: 'qwen-max',
type: '对话',
enabled: true,
todayToken: 5678901,
order: 3,
updatedAt: '2026-07-25 13:55:10',
},
{
id: '4',
name: 'Gemini-1.5-Pro',
provider: 'Google',
modelId: 'gemini-1.5-pro',
type: '多模态',
enabled: false,
todayToken: 123456,
order: 4,
updatedAt: '2026-07-24 18:20:33',
},
{
id: '5',
name: 'DeepSeek-V3',
provider: 'DeepSeek',
modelId: 'deepseek-chat',
type: '对话',
enabled: true,
todayToken: 3456789,
order: 5,
updatedAt: '2026-07-24 16:42:08',
},
];
export const usageTrend: UsagePoint[] = [
{ date: '07-19', value: 4200000 },
{ date: '07-20', value: 6800000 },
{ date: '07-21', value: 5900000 },
{ date: '07-22', value: 9100000 },
{ date: '07-23', value: 7200000 },
{ date: '07-24', value: 8400000 },
{ date: '07-25', value: 12345678 },
];
export const recentCalls: RecentCall[] = [
{ id: 'call-1', time: '2026-07-25 14:23:45', token: 12345, status: '成功' },
{ id: 'call-2', time: '2026-07-25 14:20:11', token: 8765, status: '成功' },
{ id: 'call-3', time: '2026-07-25 14:18:03', token: 3210, status: '成功' },
{ id: 'call-4', time: '2026-07-25 14:15:42', token: 9876, status: '失败' },
{ id: 'call-5', time: '2026-07-25 14:10:33', token: 7654, status: '成功' },
];

View File

@@ -0,0 +1,204 @@
import { ApiOutlined, CheckCircleOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import { ProCard } from '@ant-design/pro-components';
import { App, Button, Form, Input, InputNumber, Modal, Space, Switch, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useMemo, useState } from 'react';
import { initialProviders, type ProviderItem } from './mock';
interface ProviderFormValues {
name: string;
baseUrl: string;
modelCount: number;
apiKey?: string;
}
export default function LLMProviders() {
const { message } = App.useApp();
const [form] = Form.useForm<ProviderFormValues>();
const [providers, setProviders] = useState(initialProviders);
const [keyword, setKeyword] = useState('');
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<ProviderItem>();
const filteredProviders = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
if (!normalized) return providers;
return providers.filter(item => item.name.toLowerCase().includes(normalized));
}, [keyword, providers]);
const openCreate = () => {
setEditing(undefined);
form.setFieldsValue({ name: '', baseUrl: '', modelCount: 0, apiKey: '' });
setFormOpen(true);
};
const openEditCredential = (record: ProviderItem) => {
setEditing(record);
form.setFieldsValue({ name: record.name, baseUrl: record.baseUrl, modelCount: record.modelCount, apiKey: '' });
setFormOpen(true);
};
const saveProvider = async () => {
const values = await form.validateFields();
if (editing) {
setProviders(current =>
current.map(item =>
item.id === editing.id
? {
...item,
name: values.name,
baseUrl: values.baseUrl,
modelCount: values.modelCount,
credential: values.apiKey ? '已配置' : item.credential,
}
: item,
),
);
message.success('供应商已更新');
} else {
setProviders(current => [
...current,
{
id: `provider-${Date.now()}`,
name: values.name,
baseUrl: values.baseUrl,
modelCount: values.modelCount,
credential: values.apiKey ? '已配置' : '未配置',
enabled: true,
},
]);
message.success('供应商已添加');
}
setFormOpen(false);
form.resetFields();
};
const testConnection = (record: ProviderItem) => {
if (record.credential === '未配置') {
message.warning(`${record.name} 尚未配置凭证`);
return;
}
message.success(`${record.name} 连通正常`);
};
const columns: ColumnsType<ProviderItem> = [
{
title: '供应商',
dataIndex: 'name',
width: 200,
render: (value: string) => (
<Space>
<ApiOutlined style={{ color: '#1677ff' }} />
<Typography.Text strong>{value}</Typography.Text>
</Space>
),
},
{ title: 'Base URL', dataIndex: 'baseUrl', ellipsis: true },
{ title: '关联模型数', dataIndex: 'modelCount', width: 120, align: 'right' },
{
title: '凭证状态',
dataIndex: 'credential',
width: 120,
render: (value: ProviderItem['credential']) => (
<Tag color={value === '已配置' ? 'success' : 'warning'}>{value}</Tag>
),
},
{
title: '状态',
dataIndex: 'enabled',
width: 100,
render: (value: boolean, record) => (
<Switch
size="small"
checked={value}
checkedChildren="启用"
unCheckedChildren="禁用"
aria-label={`${record.name}状态`}
onChange={checked => {
setProviders(current =>
current.map(item => (item.id === record.id ? { ...item, enabled: checked } : item)),
);
message.success(checked ? '供应商已启用' : '供应商已禁用');
}}
/>
),
},
{
title: '操作',
key: 'actions',
width: 220,
render: (_, record) => (
<Space size={4}>
<Button type="link" size="small" onClick={() => openEditCredential(record)}>
</Button>
<Button type="link" size="small" icon={<CheckCircleOutlined />} onClick={() => testConnection(record)}>
</Button>
</Space>
),
},
];
return (
<ProCard variant="outlined">
<Space vertical size={16} style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<div>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索供应商名称"
style={{ width: 260 }}
value={keyword}
onChange={event => setKeyword(event.target.value)}
/>
<Table<ProviderItem>
rowKey="id"
columns={columns}
dataSource={filteredProviders}
scroll={{ x: 900 }}
pagination={{ pageSize: 10, showSizeChanger: true, showTotal: total => `${total}` }}
/>
</Space>
<Modal
open={formOpen}
title={editing ? '编辑供应商' : '新增供应商'}
okText="保存"
cancelText="取消"
onOk={saveProvider}
onCancel={() => setFormOpen(false)}
>
<Form<ProviderFormValues> form={form} layout="vertical" requiredMark="optional">
<Form.Item label="供应商名称" name="name" rules={[{ required: true, message: '请输入供应商名称' }]}>
<Input placeholder="例如 OpenAI" />
</Form.Item>
<Form.Item label="Base URL" name="baseUrl" rules={[{ required: true, message: '请输入 Base URL' }]}>
<Input placeholder="例如 https://api.openai.com/v1" />
</Form.Item>
<Form.Item label="关联模型数" name="modelCount">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="API Key" name="apiKey" extra="留空则不修改现有凭证">
<Input.Password placeholder="请输入 API Key" />
</Form.Item>
</Form>
</Modal>
</ProCard>
);
}

View File

@@ -0,0 +1,61 @@
export type CredentialStatus = '已配置' | '未配置';
export interface ProviderItem {
id: string;
name: string;
baseUrl: string;
modelCount: number;
credential: CredentialStatus;
enabled: boolean;
}
export const initialProviders: ProviderItem[] = [
{
id: '1',
name: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
modelCount: 12,
credential: '已配置',
enabled: true,
},
{
id: '2',
name: 'Anthropic',
baseUrl: 'https://api.anthropic.com/v1',
modelCount: 8,
credential: '已配置',
enabled: true,
},
{
id: '3',
name: '阿里云通义',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
modelCount: 15,
credential: '已配置',
enabled: true,
},
{
id: '4',
name: 'Google Gemini',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
modelCount: 9,
credential: '已配置',
enabled: true,
},
{
id: '5',
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com/v1',
modelCount: 6,
credential: '未配置',
enabled: true,
},
{
id: '6',
name: '自定义',
baseUrl: 'https://your-api-endpoint.com/v1',
modelCount: 0,
credential: '未配置',
enabled: true,
},
];

View File

@@ -114,7 +114,7 @@ const LoginFrom = () => {
/> />
), ),
}} }}
placeholder={'密码: ant.design'} placeholder={'密码: 123456'}
rules={[ rules={[
{ {
required: true, required: true,

View File

@@ -10,6 +10,7 @@ import BlankLayout from '@/layouts/BlankLayout';
const Login = lazy(() => import('@/pages/login')); const Login = lazy(() => import('@/pages/login'));
const Dashboard = lazy(() => import('@/pages/dashboard')); const Dashboard = lazy(() => import('@/pages/dashboard'));
const LLM = lazy(() => import('@/pages/llm')); const LLM = lazy(() => import('@/pages/llm'));
const LLMProviders = lazy(() => import('@/pages/llm/providers'));
const NotFound = lazy(() => import('@/pages/exception/404')); const NotFound = lazy(() => import('@/pages/exception/404'));
const Forbidden = lazy(() => import('@/pages/exception/403')); const Forbidden = lazy(() => import('@/pages/exception/403'));
@@ -38,7 +39,12 @@ export const routes: RouteObject[] = [
children: [ children: [
{ index: true, element: <Navigate to="/dashboard" replace /> }, { index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <Dashboard /> }, { path: 'dashboard', element: <Dashboard /> },
{ path: 'llm', element: <LLM /> }, { path: 'llm', element: <Navigate to="/llm/models" replace /> },
{ path: 'llm/models', element: <LLM /> },
{ path: 'llm/providers', element: <LLMProviders /> },
{ path: 'llm/usage', element: <div></div> },
{ path: 'llm/usage/details', element: <div></div> },
{ path: 'llm/quota', element: <div></div> },
{ path: 'system/user', element: <div></div> }, { path: 'system/user', element: <div></div> },
{ path: 'system/role', element: <div></div> }, { path: 'system/role', element: <div></div> },
{ path: 'system/permission/menu', element: <div></div> }, { path: 'system/permission/menu', element: <div></div> },