|
|
@@ -0,0 +1,327 @@
|
|
|
+import { useEffect, useState, useRef } from 'react'
|
|
|
+import { Card, Form, Input, Button, Tag, Space, message, Popconfirm, Typography, Row, Col, Collapse, Switch, InputNumber, Tooltip, Tabs, Descriptions, Modal, type InputRef } from 'antd'
|
|
|
+import { PlusOutlined, DeleteOutlined, UndoOutlined, SaveOutlined, QuestionCircleOutlined, DownloadOutlined } from '@ant-design/icons'
|
|
|
+import { getGradingConfig, updateGradingConfig, resetGradingConfig, getSystemHealth, getBackupStats, type GradingConfig, type LevelDef, type GradeRule, type SystemHealth } from '../api'
|
|
|
+import { useAppStore } from '../store'
|
|
|
+
|
|
|
+const { Text, Title } = Typography
|
|
|
+const { TextArea } = Input
|
|
|
+
|
|
|
+function GradingTab() {
|
|
|
+ const [config, setConfig] = useState<GradingConfig | null>(null)
|
|
|
+ const [loading, setLoading] = useState(false)
|
|
|
+ const [saving, setSaving] = useState(false)
|
|
|
+ const { hasAction } = useAppStore()
|
|
|
+ const canEdit = hasAction('setting_manage')
|
|
|
+
|
|
|
+ const fetchConfig = async () => {
|
|
|
+ setLoading(true)
|
|
|
+ try {
|
|
|
+ const res = await getGradingConfig()
|
|
|
+ setConfig(res.data)
|
|
|
+ } catch {
|
|
|
+ message.error('加载设置失败')
|
|
|
+ } finally {
|
|
|
+ setLoading(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ useEffect(() => { fetchConfig() }, [])
|
|
|
+
|
|
|
+ const handleSave = async () => {
|
|
|
+ if (!config) return
|
|
|
+ setSaving(true)
|
|
|
+ try {
|
|
|
+ await updateGradingConfig(config)
|
|
|
+ message.success('设置已保存,下次清洗时生效')
|
|
|
+ } catch {
|
|
|
+ message.error('保存失败')
|
|
|
+ } finally {
|
|
|
+ setSaving(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleReset = async () => {
|
|
|
+ try {
|
|
|
+ const res = await resetGradingConfig()
|
|
|
+ setConfig(res.data)
|
|
|
+ message.success('已恢复默认设置')
|
|
|
+ } catch {
|
|
|
+ message.error('重置失败')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const updateLevel = (idx: number, patch: Partial<LevelDef>) => {
|
|
|
+ if (!config) return
|
|
|
+ const levels = [...config.levels]
|
|
|
+ levels[idx] = { ...levels[idx], ...patch }
|
|
|
+ setConfig({ ...config, levels })
|
|
|
+ }
|
|
|
+
|
|
|
+ const addLevel = () => {
|
|
|
+ if (!config) return
|
|
|
+ const newLevel: LevelDef = { key: `Level${config.levels.length + 1}`, label: '新等级', color: 'default', description: '', rules: [] }
|
|
|
+ setConfig({ ...config, levels: [...config.levels, newLevel] })
|
|
|
+ }
|
|
|
+
|
|
|
+ const removeLevel = (idx: number) => {
|
|
|
+ if (!config || config.levels.length <= 1) return
|
|
|
+ setConfig({ ...config, levels: config.levels.filter((_, i) => i !== idx) })
|
|
|
+ }
|
|
|
+
|
|
|
+ const addRule = (levelIdx: number) => {
|
|
|
+ if (!config) return
|
|
|
+ const levels = [...config.levels]
|
|
|
+ levels[levelIdx] = { ...levels[levelIdx], rules: [...levels[levelIdx].rules, {}] }
|
|
|
+ setConfig({ ...config, levels })
|
|
|
+ }
|
|
|
+
|
|
|
+ const removeRule = (levelIdx: number, ruleIdx: number) => {
|
|
|
+ if (!config) return
|
|
|
+ const levels = [...config.levels]
|
|
|
+ levels[levelIdx] = { ...levels[levelIdx], rules: levels[levelIdx].rules.filter((_, i) => i !== ruleIdx) }
|
|
|
+ setConfig({ ...config, levels })
|
|
|
+ }
|
|
|
+
|
|
|
+ const updateRule = (levelIdx: number, ruleIdx: number, patch: Partial<GradeRule>) => {
|
|
|
+ if (!config) return
|
|
|
+ const levels = [...config.levels]
|
|
|
+ const rules = [...levels[levelIdx].rules]
|
|
|
+ rules[ruleIdx] = { ...rules[ruleIdx], ...patch }
|
|
|
+ levels[levelIdx] = { ...levels[levelIdx], rules }
|
|
|
+ setConfig({ ...config, levels })
|
|
|
+ }
|
|
|
+
|
|
|
+ const updateIndustryKeywords = (industry: string, text: string) => {
|
|
|
+ if (!config) return
|
|
|
+ const keywords = text.split(/[,,\n]/).map(s => s.trim()).filter(Boolean)
|
|
|
+ setConfig({ ...config, industry_keywords: { ...config.industry_keywords, [industry]: keywords } })
|
|
|
+ }
|
|
|
+
|
|
|
+ const [addIndustryVisible, setAddIndustryVisible] = useState(false)
|
|
|
+ const [newIndustryName, setNewIndustryName] = useState('')
|
|
|
+ const industryInputRef = useRef<InputRef>(null)
|
|
|
+
|
|
|
+ const addIndustry = () => {
|
|
|
+ setNewIndustryName('')
|
|
|
+ setAddIndustryVisible(true)
|
|
|
+ setTimeout(() => industryInputRef.current?.focus(), 100)
|
|
|
+ }
|
|
|
+
|
|
|
+ const confirmAddIndustry = () => {
|
|
|
+ const name = newIndustryName.trim()
|
|
|
+ if (!name || !config) return
|
|
|
+ if (config.industry_keywords[name] !== undefined) {
|
|
|
+ message.warning('该行业已存在')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ setConfig({ ...config, industry_keywords: { ...config.industry_keywords, [name]: [] } })
|
|
|
+ setAddIndustryVisible(false)
|
|
|
+ setNewIndustryName('')
|
|
|
+ }
|
|
|
+
|
|
|
+ const removeIndustry = (industry: string) => {
|
|
|
+ if (!config) return
|
|
|
+ const kw = { ...config.industry_keywords }
|
|
|
+ delete kw[industry]
|
|
|
+ setConfig({ ...config, industry_keywords: kw })
|
|
|
+ }
|
|
|
+
|
|
|
+ if (loading || !config) return <div>加载中...</div>
|
|
|
+
|
|
|
+ const colorOptions = ['red', 'orange', 'blue', 'green', 'purple', 'magenta', 'gold', 'cyan', 'default']
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ {canEdit && (
|
|
|
+ <Row justify="end" style={{ marginBottom: 16 }}>
|
|
|
+ <Space>
|
|
|
+ <Popconfirm title="确认恢复默认设置?" onConfirm={handleReset}>
|
|
|
+ <Button icon={<UndoOutlined />}>恢复默认</Button>
|
|
|
+ </Popconfirm>
|
|
|
+ <Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>保存设置</Button>
|
|
|
+ </Space>
|
|
|
+ </Row>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <Card title="商户等级定义" style={{ marginBottom: 16 }}>
|
|
|
+ <Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
|
|
+ 等级从上到下优先匹配,第一个满足任意规则的等级生效。最后一个等级为兜底。
|
|
|
+ </Text>
|
|
|
+ {config.levels.map((level, levelIdx) => (
|
|
|
+ <Card key={levelIdx} size="small" style={{ marginBottom: 12, borderLeft: `4px solid ${getAntdColor(level.color)}` }}
|
|
|
+ title={<Space><Tag color={level.color}>{level.label || level.key}</Tag><Text type="secondary">标识: {level.key}</Text></Space>}
|
|
|
+ extra={config.levels.length > 1 && canEdit && (
|
|
|
+ <Popconfirm title="确认删除?" onConfirm={() => removeLevel(levelIdx)}>
|
|
|
+ <Button size="small" danger icon={<DeleteOutlined />} />
|
|
|
+ </Popconfirm>
|
|
|
+ )}>
|
|
|
+ <Row gutter={12} style={{ marginBottom: 8 }}>
|
|
|
+ <Col span={4}><Form.Item label="标识" style={{ marginBottom: 0 }}><Input size="small" value={level.key} disabled={!canEdit} onChange={e => updateLevel(levelIdx, { key: e.target.value })} /></Form.Item></Col>
|
|
|
+ <Col span={4}><Form.Item label="名称" style={{ marginBottom: 0 }}><Input size="small" value={level.label} disabled={!canEdit} onChange={e => updateLevel(levelIdx, { label: e.target.value })} /></Form.Item></Col>
|
|
|
+ <Col span={4}><Form.Item label="颜色" style={{ marginBottom: 0 }}>
|
|
|
+ <Space wrap size={4}>{colorOptions.map(c => (
|
|
|
+ <Tag key={c} color={c} style={{ cursor: canEdit ? 'pointer' : 'default', border: level.color === c ? '2px solid #333' : undefined }}
|
|
|
+ onClick={() => canEdit && updateLevel(levelIdx, { color: c })}>{c === level.color ? '✓' : ' '}</Tag>
|
|
|
+ ))}</Space></Form.Item></Col>
|
|
|
+ <Col span={12}><Form.Item label="说明" style={{ marginBottom: 0 }}><Input size="small" value={level.description} disabled={!canEdit} onChange={e => updateLevel(levelIdx, { description: e.target.value })} /></Form.Item></Col>
|
|
|
+ </Row>
|
|
|
+ <Text strong style={{ fontSize: 12 }}>匹配规则 <Tooltip title="规则间是【或】关系,规则内是【且】关系"><QuestionCircleOutlined /></Tooltip></Text>
|
|
|
+ {level.rules.length === 0 && <div style={{ padding: '4px 0', color: '#999', fontSize: 12 }}>兜底等级</div>}
|
|
|
+ {level.rules.map((rule, ruleIdx) => (
|
|
|
+ <div key={ruleIdx} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '4px 0', borderBottom: '1px solid #f5f5f5' }}>
|
|
|
+ <Text type="secondary" style={{ width: 30, fontSize: 12 }}>#{ruleIdx + 1}</Text>
|
|
|
+ <Switch size="small" checkedChildren="行业" unCheckedChildren="行业" checked={!!rule.has_industry} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { has_industry: v || undefined })} />
|
|
|
+ <Switch size="small" checkedChildren="网站" unCheckedChildren="网站" checked={!!rule.has_website} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { has_website: v || undefined })} />
|
|
|
+ <Switch size="small" checkedChildren="邮箱" unCheckedChildren="邮箱" checked={!!rule.has_email} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { has_email: v || undefined })} />
|
|
|
+ <Switch size="small" checkedChildren="电话" unCheckedChildren="电话" checked={!!rule.has_phone} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { has_phone: v || undefined })} />
|
|
|
+ <Space size={4}><Text style={{ fontSize: 12 }}>来源≥</Text><InputNumber size="small" min={1} max={99} style={{ width: 50 }} value={rule.min_source_count} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { min_source_count: v || undefined })} /></Space>
|
|
|
+ {canEdit && <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => removeRule(levelIdx, ruleIdx)} />}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ {canEdit && <Button size="small" type="dashed" icon={<PlusOutlined />} onClick={() => addRule(levelIdx)} style={{ marginTop: 4 }}>添加规则</Button>}
|
|
|
+ </Card>
|
|
|
+ ))}
|
|
|
+ {canEdit && <Button type="dashed" block icon={<PlusOutlined />} onClick={addLevel}>添加等级</Button>}
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card title="行业关键词">
|
|
|
+ <Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
|
|
+ 商户名称或原始内容中包含任意一个关键词即匹配该行业。
|
|
|
+ </Text>
|
|
|
+ <Collapse items={Object.entries(config.industry_keywords).map(([industry, keywords]) => ({
|
|
|
+ key: industry,
|
|
|
+ label: <Space><Tag color="blue">{industry}</Tag><Text type="secondary">{keywords.length} 个关键词</Text></Space>,
|
|
|
+ extra: canEdit && (
|
|
|
+ <Popconfirm title={`删除行业「${industry}」?`} onConfirm={(e) => { e?.stopPropagation(); removeIndustry(industry) }}>
|
|
|
+ <Button size="small" danger icon={<DeleteOutlined />} onClick={e => e.stopPropagation()} />
|
|
|
+ </Popconfirm>
|
|
|
+ ),
|
|
|
+ children: <TextArea rows={3} value={keywords.join(', ')} disabled={!canEdit} onChange={e => updateIndustryKeywords(industry, e.target.value)} placeholder="逗号或换行分隔" />,
|
|
|
+ }))} />
|
|
|
+ {canEdit && <Button type="dashed" block icon={<PlusOutlined />} onClick={addIndustry} style={{ marginTop: 12 }}>添加行业</Button>}
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Modal
|
|
|
+ title="添加行业"
|
|
|
+ open={addIndustryVisible}
|
|
|
+ onOk={confirmAddIndustry}
|
|
|
+ onCancel={() => setAddIndustryVisible(false)}
|
|
|
+ okText="确定"
|
|
|
+ cancelText="取消"
|
|
|
+ >
|
|
|
+ <Input
|
|
|
+ ref={industryInputRef}
|
|
|
+ placeholder="输入行业名称"
|
|
|
+ value={newIndustryName}
|
|
|
+ onChange={e => setNewIndustryName(e.target.value)}
|
|
|
+ onPressEnter={confirmAddIndustry}
|
|
|
+ style={{ marginTop: 8 }}
|
|
|
+ />
|
|
|
+ </Modal>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function SystemInfoTab() {
|
|
|
+ const [health, setHealth] = useState<SystemHealth | null>(null)
|
|
|
+ const [loading, setLoading] = useState(true)
|
|
|
+ const [backupStats, setBackupStats] = useState<{ table: string; rows: number }[]>([])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ Promise.all([
|
|
|
+ getSystemHealth().then(r => setHealth(r.data)).catch(() => {}),
|
|
|
+ getBackupStats().then(r => setBackupStats(r.data || [])).catch(() => {}),
|
|
|
+ ]).finally(() => setLoading(false))
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ if (loading) return <div>加载中...</div>
|
|
|
+ if (!health) return <div>无法获取系统信息</div>
|
|
|
+
|
|
|
+ const tgAccounts = health.tg_accounts || {}
|
|
|
+ const tasks24h = health.tasks_24h || {}
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ <Card title="系统状态" size="small" style={{ marginBottom: 16 }}>
|
|
|
+ <Descriptions column={2} size="small">
|
|
|
+ <Descriptions.Item label="MySQL">
|
|
|
+ <Tag color={health.mysql.status === 'ok' ? 'green' : 'red'}>{health.mysql.status}</Tag>
|
|
|
+ {health.mysql.status === 'ok' && <Text type="secondary"> 连接 {health.mysql.in_use}/{health.mysql.max_open}</Text>}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="Redis">
|
|
|
+ <Tag color={health.redis.status === 'ok' ? 'green' : 'red'}>{health.redis.status}</Tag>
|
|
|
+ </Descriptions.Item>
|
|
|
+ </Descriptions>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card title="TG 账号状态" size="small" style={{ marginBottom: 16 }}>
|
|
|
+ <Descriptions column={3} size="small">
|
|
|
+ {Object.entries(tgAccounts).map(([status, count]) => (
|
|
|
+ <Descriptions.Item key={status} label={status}>
|
|
|
+ <Text strong>{count as number}</Text>
|
|
|
+ </Descriptions.Item>
|
|
|
+ ))}
|
|
|
+ </Descriptions>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card title="最近24小时任务" size="small" style={{ marginBottom: 16 }}>
|
|
|
+ <Descriptions column={3} size="small">
|
|
|
+ {Object.entries(tasks24h).map(([status, count]) => (
|
|
|
+ <Descriptions.Item key={status} label={status}>
|
|
|
+ <Tag color={status === 'completed' ? 'green' : status === 'failed' ? 'red' : 'blue'}>{count as number}</Tag>
|
|
|
+ </Descriptions.Item>
|
|
|
+ ))}
|
|
|
+ </Descriptions>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card title="数据量" size="small" style={{ marginBottom: 16 }}>
|
|
|
+ <Descriptions column={3} size="small">
|
|
|
+ <Descriptions.Item label="原始商户">{health.data.merchants_raw.toLocaleString()}</Descriptions.Item>
|
|
|
+ <Descriptions.Item label="清洗商户">{health.data.merchants_clean.toLocaleString()}</Descriptions.Item>
|
|
|
+ <Descriptions.Item label="任务详情">{health.data.task_details.toLocaleString()}</Descriptions.Item>
|
|
|
+ </Descriptions>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card title="数据备份" size="small">
|
|
|
+ <div style={{ marginBottom: 12 }}>
|
|
|
+ <Text type="secondary">导出系统核心数据为 JSON 文件(商户、关键词、频道、定时任务、用户、权限配置)。</Text>
|
|
|
+ </div>
|
|
|
+ {backupStats.length > 0 && (
|
|
|
+ <div style={{ marginBottom: 12 }}>
|
|
|
+ <Row gutter={[8, 4]}>
|
|
|
+ {backupStats.map(s => (
|
|
|
+ <Col span={6} key={s.table}>
|
|
|
+ <Text style={{ fontSize: 12 }}>{s.table}: <Text strong>{s.rows.toLocaleString()}</Text></Text>
|
|
|
+ </Col>
|
|
|
+ ))}
|
|
|
+ </Row>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <Button type="primary" icon={<DownloadOutlined />}
|
|
|
+ onClick={() => window.open('/api/v1/backup/export', '_blank')}>
|
|
|
+ 下载备份
|
|
|
+ </Button>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+export default function Settings() {
|
|
|
+ const { isAdmin } = useAppStore()
|
|
|
+
|
|
|
+ const tabs = [
|
|
|
+ { key: 'grading', label: '分级设置', children: <GradingTab /> },
|
|
|
+ ...(isAdmin() ? [{ key: 'system', label: '系统信息', children: <SystemInfoTab /> }] : []),
|
|
|
+ ]
|
|
|
+
|
|
|
+ return <Tabs defaultActiveKey="grading" items={tabs} />
|
|
|
+}
|
|
|
+
|
|
|
+function getAntdColor(color: string): string {
|
|
|
+ const map: Record<string, string> = {
|
|
|
+ red: '#ff4d4f', orange: '#fa8c16', blue: '#1890ff', green: '#52c41a',
|
|
|
+ purple: '#722ed1', magenta: '#eb2f96', gold: '#faad14', cyan: '#13c2c2',
|
|
|
+ }
|
|
|
+ return map[color] || '#d9d9d9'
|
|
|
+}
|