diff --git a/.gitignore b/.gitignore index f7c320d..b38594d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local .pnpm-store +playwright-report +test-results +blob-report # Env .env diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..cfd87ce --- /dev/null +++ b/e2e/README.md @@ -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/`;截图基线用于检测后续代码造成的 +视觉变化,不替代设计稿还原度审查。 diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts new file mode 100644 index 0000000..87d9134 --- /dev/null +++ b/e2e/fixtures/auth.ts @@ -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 }; diff --git a/e2e/pages/dashboard.spec.ts b/e2e/pages/dashboard.spec.ts new file mode 100644 index 0000000..f4fc264 --- /dev/null +++ b/e2e/pages/dashboard.spec.ts @@ -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(); +}); diff --git a/e2e/pages/llm-models.spec.ts b/e2e/pages/llm-models.spec.ts new file mode 100644 index 0000000..b836ad0 --- /dev/null +++ b/e2e/pages/llm-models.spec.ts @@ -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); + }); +}); diff --git a/e2e/pages/llm-providers.spec.ts b/e2e/pages/llm-providers.spec.ts new file mode 100644 index 0000000..e89acef --- /dev/null +++ b/e2e/pages/llm-providers.spec.ts @@ -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(); + }); +}); diff --git a/e2e/pages/login.spec.ts b/e2e/pages/login.spec.ts new file mode 100644 index 0000000..bec4292 --- /dev/null +++ b/e2e/pages/login.spec.ts @@ -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(); +}); diff --git a/e2e/snapshots/chromium/visual/dashboard.spec.ts/dashboard.png b/e2e/snapshots/chromium/visual/dashboard.spec.ts/dashboard.png new file mode 100644 index 0000000..5d213c6 Binary files /dev/null and b/e2e/snapshots/chromium/visual/dashboard.spec.ts/dashboard.png differ diff --git a/e2e/snapshots/chromium/visual/llm-models.spec.ts/llm-models-list.png b/e2e/snapshots/chromium/visual/llm-models.spec.ts/llm-models-list.png new file mode 100644 index 0000000..84be23a Binary files /dev/null and b/e2e/snapshots/chromium/visual/llm-models.spec.ts/llm-models-list.png differ diff --git a/e2e/snapshots/chromium/visual/llm-providers.spec.ts/llm-providers-list.png b/e2e/snapshots/chromium/visual/llm-providers.spec.ts/llm-providers-list.png new file mode 100644 index 0000000..30ecd64 Binary files /dev/null and b/e2e/snapshots/chromium/visual/llm-providers.spec.ts/llm-providers-list.png differ diff --git a/e2e/visual/dashboard.spec.ts b/e2e/visual/dashboard.spec.ts new file mode 100644 index 0000000..4050179 --- /dev/null +++ b/e2e/visual/dashboard.spec.ts @@ -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, + }); +}); diff --git a/e2e/visual/llm-models.spec.ts b/e2e/visual/llm-models.spec.ts new file mode 100644 index 0000000..e94c188 --- /dev/null +++ b/e2e/visual/llm-models.spec.ts @@ -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, + }); +}); diff --git a/e2e/visual/llm-providers.spec.ts b/e2e/visual/llm-providers.spec.ts new file mode 100644 index 0000000..da8c060 --- /dev/null +++ b/e2e/visual/llm-providers.spec.ts @@ -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, + }); +}); diff --git a/package.json b/package.json index 08f1ea3..145b210 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..775e36a --- /dev/null +++ b/playwright.config.ts @@ -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', + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a38f83b..c5487e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/api/menu.ts b/src/api/menu.ts index a1626a1..bc31485 100644 --- a/src/api/menu.ts +++ b/src/api/menu.ts @@ -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> { await delay(); - console.log(mockMenus); return { code: 200, data: mockMenus, message: 'ok' }; } diff --git a/src/pages/llm/index.tsx b/src/pages/llm/index.tsx index 7fb28d4..0fbbc0e 100644 --- a/src/pages/llm/index.tsx +++ b/src/pages/llm/index.tsx @@ -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
大模型管理
; +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(); + const [models, setModels] = useState(initialModels); + const [keyword, setKeyword] = useState(''); + const [provider, setProvider] = useState(); + const [enabled, setEnabled] = useState(); + const [selectedKeys, setSelectedKeys] = useState([]); + const [editingModel, setEditingModel] = useState(); + const [formOpen, setFormOpen] = useState(false); + const [usageModel, setUsageModel] = useState(); + const [draggingId, setDraggingId] = useState(); + + 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 = [ + { + title: '', + key: 'drag', + width: 48, + align: 'center', + render: (_, record) => ( + + + deleteModels([record.id])}> + + + + ), + }, + ]; + + const recentColumns: ColumnsType = [ + { title: '时间', dataIndex: 'time' }, + { title: 'Token', dataIndex: 'token', align: 'right', render: (value: number) => value.toLocaleString() }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + render: (value: RecentCall['status']) => {value}, + }, + ]; + + 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 ( + <> + + + + + + } + placeholder="搜索模型名称或 ID" + style={{ width: 240 }} + value={keyword} + onChange={event => setKeyword(event.target.value)} + /> + + + + + + deleteModels(selectedKeys)} + > + + + + + + + + + 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} 条` }} + /> + + + + setFormOpen(false)} + > + form={form} layout="vertical" requiredMark="optional"> + + + + + + + + + + + + + + } + placeholder="搜索供应商名称" + style={{ width: 260 }} + value={keyword} + onChange={event => setKeyword(event.target.value)} + /> + + + rowKey="id" + columns={columns} + dataSource={filteredProviders} + scroll={{ x: 900 }} + pagination={{ pageSize: 10, showSizeChanger: true, showTotal: total => `共 ${total} 条` }} + /> + + + setFormOpen(false)} + > + form={form} layout="vertical" requiredMark="optional"> + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/llm/providers/mock.ts b/src/pages/llm/providers/mock.ts new file mode 100644 index 0000000..893f92f --- /dev/null +++ b/src/pages/llm/providers/mock.ts @@ -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, + }, +]; diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 0845c0a..9b07e2a 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -114,7 +114,7 @@ const LoginFrom = () => { /> ), }} - placeholder={'密码: ant.design'} + placeholder={'密码: 123456'} rules={[ { required: true, diff --git a/src/router/routes.tsx b/src/router/routes.tsx index 76d5301..87b278d 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -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: }, { path: 'dashboard', element: }, - { path: 'llm', element: }, + { path: 'llm', element: }, + { path: 'llm/models', element: }, + { path: 'llm/providers', element: }, + { path: 'llm/usage', element:
用量统计
}, + { path: 'llm/usage/details', element:
用量明细
}, + { path: 'llm/quota', element:
限流配额
}, { path: 'system/user', element:
用户管理
}, { path: 'system/role', element:
角色管理
}, { path: 'system/permission/menu', element:
菜单权限
},