P1-07: billing get_or_create_usage 同步 max_* 列到当前计划限额 P1-08: relay handler 增加直接配额检查 (relay_requests/input/output_tokens) P2-09: relay failover 成功后记录 tokens 并标记 completed P2-10: Tauri agentStore saas-relay 模式下从 SaaS API 获取真实用量 P2-14: super_admin 合成 subscription + check_quota 放行 P3-19: 新建 ApiKeys.tsx 页面替代 ModelServices 路由 P3-15: antd destroyOnClose → destroyOnHidden (3处) P3-16: ProTable onSearch → onSubmit (2处)
380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
// ============================================================
|
||
// 行业配置管理
|
||
// ============================================================
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import {
|
||
Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm,
|
||
Tabs, Typography, Spin, Empty,
|
||
} from 'antd'
|
||
import {
|
||
PlusOutlined, EditOutlined, CheckCircleOutlined, StopOutlined,
|
||
ShopOutlined, SettingOutlined,
|
||
} from '@ant-design/icons'
|
||
import type { ProColumns } from '@ant-design/pro-components'
|
||
import { ProTable } from '@ant-design/pro-components'
|
||
import { industryService } from '@/services/industries'
|
||
import type { IndustryListItem, IndustryFullConfig, UpdateIndustryRequest } from '@/services/industries'
|
||
import { PageHeader } from '@/components/PageHeader'
|
||
|
||
const { TextArea } = Input
|
||
const { Text } = Typography
|
||
|
||
const statusLabels: Record<string, string> = { active: '启用', inactive: '禁用' }
|
||
const statusColors: Record<string, string> = { active: 'green', inactive: 'default' }
|
||
const sourceLabels: Record<string, string> = { builtin: '内置', admin: '自定义', custom: '自定义' }
|
||
|
||
// === 行业列表 ===
|
||
|
||
function IndustryListPanel() {
|
||
const queryClient = useQueryClient()
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(20)
|
||
const [filters, setFilters] = useState<{ status?: string; source?: string }>({})
|
||
const [editId, setEditId] = useState<string | null>(null)
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['industries', page, pageSize, filters],
|
||
queryFn: ({ signal }) => industryService.list({ page, page_size: pageSize, ...filters }, signal),
|
||
})
|
||
|
||
const updateStatusMutation = useMutation({
|
||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||
industryService.update(id, { status }),
|
||
onSuccess: () => {
|
||
message.success('状态已更新')
|
||
queryClient.invalidateQueries({ queryKey: ['industries'] })
|
||
},
|
||
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||
})
|
||
|
||
const columns: ProColumns<IndustryListItem>[] = [
|
||
{
|
||
title: '图标',
|
||
dataIndex: 'icon',
|
||
width: 50,
|
||
search: false,
|
||
render: (_, r) => <span className="text-xl">{r.icon}</span>,
|
||
},
|
||
{
|
||
title: '行业名称',
|
||
dataIndex: 'name',
|
||
width: 150,
|
||
},
|
||
{
|
||
title: '描述',
|
||
dataIndex: 'description',
|
||
width: 250,
|
||
search: false,
|
||
ellipsis: true,
|
||
},
|
||
{
|
||
title: '来源',
|
||
dataIndex: 'source',
|
||
width: 80,
|
||
valueType: 'select',
|
||
valueEnum: {
|
||
builtin: { text: '内置' },
|
||
admin: { text: '自定义' },
|
||
custom: { text: '自定义' },
|
||
},
|
||
render: (_, r) => <Tag color={r.source === 'builtin' ? 'blue' : 'purple'}>{sourceLabels[r.source] || r.source}</Tag>,
|
||
},
|
||
{
|
||
title: '关键词数',
|
||
dataIndex: 'keywords_count',
|
||
width: 90,
|
||
search: false,
|
||
render: (_, r) => <Tag>{r.keywords_count}</Tag>,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 80,
|
||
valueType: 'select',
|
||
valueEnum: {
|
||
active: { text: '启用', status: 'Success' },
|
||
inactive: { text: '禁用', status: 'Default' },
|
||
},
|
||
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
|
||
},
|
||
{
|
||
title: '更新时间',
|
||
dataIndex: 'updated_at',
|
||
width: 160,
|
||
valueType: 'dateTime',
|
||
search: false,
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 180,
|
||
search: false,
|
||
render: (_, r) => (
|
||
<Space>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<EditOutlined />}
|
||
onClick={() => setEditId(r.id)}
|
||
>
|
||
编辑
|
||
</Button>
|
||
{r.status === 'active' ? (
|
||
<Popconfirm title="确定禁用此行业?" onConfirm={() => updateStatusMutation.mutate({ id: r.id, status: 'inactive' })}>
|
||
<Button type="link" size="small" danger icon={<StopOutlined />}>禁用</Button>
|
||
</Popconfirm>
|
||
) : (
|
||
<Popconfirm title="确定启用此行业?" onConfirm={() => updateStatusMutation.mutate({ id: r.id, status: 'active' })}>
|
||
<Button type="link" size="small" icon={<CheckCircleOutlined />}>启用</Button>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div>
|
||
<ProTable<IndustryListItem>
|
||
columns={columns}
|
||
dataSource={data?.items || []}
|
||
loading={isLoading}
|
||
rowKey="id"
|
||
search={{
|
||
onReset: () => { setFilters({}); setPage(1) },
|
||
onSubmit: (values) => { setFilters(values); setPage(1) },
|
||
}}
|
||
toolBarRender={() => [
|
||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||
新建行业
|
||
</Button>,
|
||
]}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total || 0,
|
||
showSizeChanger: true,
|
||
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||
}}
|
||
options={{ density: false, fullScreen: false, reload: () => queryClient.invalidateQueries({ queryKey: ['industries'] }) }}
|
||
/>
|
||
|
||
<IndustryEditModal
|
||
open={!!editId}
|
||
industryId={editId}
|
||
onClose={() => setEditId(null)}
|
||
/>
|
||
|
||
<IndustryCreateModal
|
||
open={createOpen}
|
||
onClose={() => setCreateOpen(false)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// === 行业编辑弹窗 ===
|
||
|
||
function IndustryEditModal({ open, industryId, onClose }: {
|
||
open: boolean
|
||
industryId: string | null
|
||
onClose: () => void
|
||
}) {
|
||
const queryClient = useQueryClient()
|
||
const [form] = Form.useForm()
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['industry-full-config', industryId],
|
||
queryFn: ({ signal }) => industryService.getFullConfig(industryId!, signal),
|
||
enabled: !!industryId,
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (data && open && data.id === industryId) {
|
||
form.setFieldsValue({
|
||
name: data.name,
|
||
icon: data.icon,
|
||
description: data.description,
|
||
keywords: data.keywords,
|
||
system_prompt: data.system_prompt,
|
||
cold_start_template: data.cold_start_template,
|
||
pain_seed_categories: data.pain_seed_categories,
|
||
})
|
||
}
|
||
}, [data, open, industryId, form])
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: (body: UpdateIndustryRequest) =>
|
||
industryService.update(industryId!, body),
|
||
onSuccess: () => {
|
||
message.success('行业配置已更新')
|
||
queryClient.invalidateQueries({ queryKey: ['industries'] })
|
||
queryClient.invalidateQueries({ queryKey: ['industry-full-config'] })
|
||
onClose()
|
||
},
|
||
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||
})
|
||
|
||
return (
|
||
<Modal
|
||
title={<span className="text-base font-semibold">编辑行业配置 — {data?.name || ''}</span>}
|
||
open={open}
|
||
onCancel={() => { onClose(); form.resetFields() }}
|
||
onOk={() => form.submit()}
|
||
confirmLoading={updateMutation.isPending}
|
||
width={720}
|
||
destroyOnHidden
|
||
>
|
||
{isLoading ? (
|
||
<div className="flex justify-center py-8"><Spin /></div>
|
||
) : data ? (
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
className="mt-4"
|
||
onFinish={(values) => updateMutation.mutate(values)}
|
||
>
|
||
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="icon" label="图标">
|
||
<Input placeholder="行业图标 emoji,如 🏥" className="w-32" />
|
||
</Form.Item>
|
||
<Form.Item name="description" label="描述">
|
||
<TextArea rows={2} placeholder="行业简要描述" />
|
||
</Form.Item>
|
||
<Form.Item name="keywords" label="关键词列表" extra="用于语义路由匹配,回车添加">
|
||
<Select mode="tags" placeholder="输入关键词后回车添加" />
|
||
</Form.Item>
|
||
<Form.Item name="system_prompt" label="系统提示词" extra="匹配到此行业时注入的 system prompt">
|
||
<TextArea rows={6} placeholder="行业专属系统提示词模板" />
|
||
</Form.Item>
|
||
<Form.Item name="cold_start_template" label="冷启动模板" extra="首次匹配时的引导消息模板">
|
||
<TextArea rows={3} placeholder="冷启动引导消息" />
|
||
</Form.Item>
|
||
<Form.Item name="pain_seed_categories" label="痛点种子分类" extra="预置的痛点分类维度">
|
||
<Select mode="tags" placeholder="输入痛点分类后回车添加" />
|
||
</Form.Item>
|
||
<div className="mb-2">
|
||
<Text type="secondary">
|
||
来源: <Tag color={data.source === 'builtin' ? 'blue' : 'purple'}>{sourceLabels[data.source]}</Tag>
|
||
{' '}状态: <Tag color={statusColors[data.status]}>{statusLabels[data.status]}</Tag>
|
||
</Text>
|
||
</div>
|
||
</Form>
|
||
) : (
|
||
<Empty description="未找到行业配置" />
|
||
)}
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
// === 新建行业弹窗 ===
|
||
|
||
function IndustryCreateModal({ open, onClose }: {
|
||
open: boolean
|
||
onClose: () => void
|
||
}) {
|
||
const queryClient = useQueryClient()
|
||
const [form] = Form.useForm()
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (data: Parameters<typeof industryService.create>[0]) =>
|
||
industryService.create(data),
|
||
onSuccess: () => {
|
||
message.success('行业已创建')
|
||
queryClient.invalidateQueries({ queryKey: ['industries'] })
|
||
onClose()
|
||
form.resetFields()
|
||
},
|
||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||
})
|
||
|
||
return (
|
||
<Modal
|
||
title="新建行业"
|
||
open={open}
|
||
onCancel={() => { onClose(); form.resetFields() }}
|
||
onOk={() => form.submit()}
|
||
confirmLoading={createMutation.isPending}
|
||
width={640}
|
||
destroyOnHidden
|
||
>
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
className="mt-4"
|
||
initialValues={{ icon: '🏢' }}
|
||
onFinish={(values) => {
|
||
// Auto-generate id from name if not provided
|
||
if (!values.id && values.name) {
|
||
// Strip non-ASCII, keep only lowercase alphanumeric + hyphens
|
||
const generated = values.name.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
if (generated) {
|
||
values.id = generated
|
||
} else {
|
||
// Name has no ASCII chars — require manual ID entry
|
||
message.warning('中文行业名称无法自动生成标识,请手动填写行业标识')
|
||
return
|
||
}
|
||
}
|
||
createMutation.mutate(values)
|
||
}}
|
||
>
|
||
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}>
|
||
<Input placeholder="如:医疗健康、教育培训" />
|
||
</Form.Item>
|
||
<Form.Item name="id" label="行业标识" extra="唯一标识,留空则从名称自动生成。仅限小写字母、数字、连字符" rules={[
|
||
{ pattern: /^[a-z0-9-]*$/, message: '仅限小写字母、数字、连字符' },
|
||
{ max: 63, message: '最长 63 字符' },
|
||
]}>
|
||
<Input placeholder="如:healthcare、education" />
|
||
</Form.Item>
|
||
<Form.Item name="icon" label="图标">
|
||
<Input placeholder="行业图标 emoji" className="w-32" />
|
||
</Form.Item>
|
||
<Form.Item name="description" label="描述" rules={[{ required: true, message: '请输入行业描述' }]}>
|
||
<TextArea rows={2} placeholder="行业简要描述" />
|
||
</Form.Item>
|
||
<Form.Item name="keywords" label="关键词列表" extra="用于语义路由匹配,回车添加">
|
||
<Select mode="tags" placeholder="输入关键词后回车添加" />
|
||
</Form.Item>
|
||
<Form.Item name="system_prompt" label="系统提示词">
|
||
<TextArea rows={4} placeholder="行业专属系统提示词" />
|
||
</Form.Item>
|
||
<Form.Item name="cold_start_template" label="冷启动模板" extra="新用户首次对话时使用的引导模板">
|
||
<TextArea rows={3} placeholder="如:您好!我是您的{行业}管家,可以帮您处理..." />
|
||
</Form.Item>
|
||
<Form.Item name="pain_seed_categories" label="痛点种子类别" extra="预置的痛点分类,用逗号或回车分隔">
|
||
<Select mode="tags" placeholder="如:库存管理、客户服务、合规" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
// === 主页面 ===
|
||
|
||
export default function Industries() {
|
||
return (
|
||
<div>
|
||
<PageHeader title="行业配置" description="管理行业关键词、系统提示词、痛点种子,驱动管家语义路由" />
|
||
<Tabs
|
||
defaultActiveKey="list"
|
||
items={[
|
||
{
|
||
key: 'list',
|
||
label: '行业列表',
|
||
icon: <ShopOutlined />,
|
||
children: <IndustryListPanel />,
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|