feat: 实现大模型模型列表与供应商管理,并接入 Playwright 测试
补齐 LLM 子菜单路由,落地模型列表与供应商管理页面交互,同时引入功能与视觉回归用例以便后续页面持续验证。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,6 +12,9 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.pnpm-store
|
||||
playwright-report
|
||||
test-results
|
||||
blob-report
|
||||
|
||||
# Env
|
||||
.env
|
||||
|
||||
24
e2e/README.md
Normal file
24
e2e/README.md
Normal 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
19
e2e/fixtures/auth.ts
Normal 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 };
|
||||
9
e2e/pages/dashboard.spec.ts
Normal file
9
e2e/pages/dashboard.spec.ts
Normal 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();
|
||||
});
|
||||
48
e2e/pages/llm-models.spec.ts
Normal file
48
e2e/pages/llm-models.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
e2e/pages/llm-providers.spec.ts
Normal file
54
e2e/pages/llm-providers.spec.ts
Normal 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
12
e2e/pages/login.spec.ts
Normal 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();
|
||||
});
|
||||
BIN
e2e/snapshots/chromium/visual/dashboard.spec.ts/dashboard.png
Normal file
BIN
e2e/snapshots/chromium/visual/dashboard.spec.ts/dashboard.png
Normal file
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 |
12
e2e/visual/dashboard.spec.ts
Normal file
12
e2e/visual/dashboard.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
11
e2e/visual/llm-models.spec.ts
Normal file
11
e2e/visual/llm-models.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
11
e2e/visual/llm-providers.spec.ts
Normal file
11
e2e/visual/llm-providers.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,9 @@
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:update": "playwright test --update-snapshots",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -31,6 +34,7 @@
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
33
playwright.config.ts
Normal file
33
playwright.config.ts
Normal 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
38
pnpm-lock.yaml
generated
@@ -57,6 +57,9 @@ importers:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.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':
|
||||
specifier: ^24.13.2
|
||||
version: 24.13.2
|
||||
@@ -583,6 +586,11 @@ packages:
|
||||
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
|
||||
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':
|
||||
resolution: {integrity: sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==}
|
||||
engines: {node: '>=14.x'}
|
||||
@@ -1644,6 +1652,11 @@ packages:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -2051,6 +2064,16 @@ packages:
|
||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||
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:
|
||||
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -3197,6 +3220,10 @@ snapshots:
|
||||
|
||||
'@pkgr/core@0.3.6': {}
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
dependencies:
|
||||
playwright: 1.61.1
|
||||
|
||||
'@rc-component/async-validator@6.0.0':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
@@ -4311,6 +4338,9 @@ snapshots:
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -4655,6 +4685,14 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
nanoid: 3.3.15
|
||||
|
||||
@@ -16,6 +16,38 @@ const mockMenus: MenuItem[] = [
|
||||
name: '大模型管理',
|
||||
path: '/llm',
|
||||
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',
|
||||
@@ -46,6 +78,5 @@ const mockMenus: MenuItem[] = [
|
||||
|
||||
export async function getMenus(): Promise<ApiResponse<MenuItem[]>> {
|
||||
await delay();
|
||||
console.log(mockMenus);
|
||||
return { code: 200, data: mockMenus, message: 'ok' };
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
return <div>大模型管理</div>;
|
||||
import {
|
||||
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
102
src/pages/llm/mock.ts
Normal 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: '成功' },
|
||||
];
|
||||
204
src/pages/llm/providers/index.tsx
Normal file
204
src/pages/llm/providers/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/pages/llm/providers/mock.ts
Normal file
61
src/pages/llm/providers/mock.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
@@ -114,7 +114,7 @@ const LoginFrom = () => {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
placeholder={'密码: ant.design'}
|
||||
placeholder={'密码: 123456'}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import BlankLayout from '@/layouts/BlankLayout';
|
||||
const Login = lazy(() => import('@/pages/login'));
|
||||
const Dashboard = lazy(() => import('@/pages/dashboard'));
|
||||
const LLM = lazy(() => import('@/pages/llm'));
|
||||
const LLMProviders = lazy(() => import('@/pages/llm/providers'));
|
||||
const NotFound = lazy(() => import('@/pages/exception/404'));
|
||||
const Forbidden = lazy(() => import('@/pages/exception/403'));
|
||||
|
||||
@@ -38,7 +39,12 @@ export const routes: RouteObject[] = [
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ 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/role', element: <div>角色管理</div> },
|
||||
{ path: 'system/permission/menu', element: <div>菜单权限</div> },
|
||||
|
||||
Reference in New Issue
Block a user