From dad3990996c10fd1433da4abf57ff56fae5abcf0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 13:50:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B9=B3=E5=8F=B0=E5=A5=97?= =?UTF-8?q?=E9=A4=90=E5=AE=9A=E4=BB=B7=E9=A1=B5=E5=B9=B6=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E6=95=88=E6=9E=9C=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定 56px 顶栏,三列套餐卡片含权益勾选与推荐角标,支持编辑/上下架与定价规则说明。 --- web/src/pages/admin/Plans.tsx | 536 +++++++++++++++++++++++++--------- 1 file changed, 395 insertions(+), 141 deletions(-) diff --git a/web/src/pages/admin/Plans.tsx b/web/src/pages/admin/Plans.tsx index 4f9f9f2..9426d0f 100644 --- a/web/src/pages/admin/Plans.tsx +++ b/web/src/pages/admin/Plans.tsx @@ -1,11 +1,23 @@ import { useEffect, useMemo, useState } from 'react' -import { Card, Tag, Switch, Button, Table, message, Spin, Empty, Modal, Form, Input, InputNumber } from 'antd' -import { CheckCircleOutlined, CloseCircleOutlined, PlusOutlined } from '@ant-design/icons' +import { Switch, Button, message, Spin, Empty, Modal, Form, Input, InputNumber, Select } from 'antd' +import { + CheckOutlined, CloseOutlined, PlusOutlined, EditOutlined, TeamOutlined, + CalendarOutlined, ThunderboltOutlined, CustomerServiceOutlined, +} from '@ant-design/icons' import { createPlan, getPlans, getTenants, updatePlan, type Plan, type Tenant } from '@/services/api' -function parseFeatures(raw: string): Record { +type FeatureFlags = { + stats?: string + api?: boolean + channels?: string[] | string + brand?: boolean + dedicated?: boolean | string + source?: string +} + +function parseFeatures(raw: string): FeatureFlags { try { - return JSON.parse(raw || '{}') + return JSON.parse(raw || '{}') as FeatureFlags } catch { return {} } @@ -21,11 +33,60 @@ function formatKB(limit: number) { return `${limit} 条` } +function formatSeats(n: number) { + if (!n || n <= 0 || n >= 999) return '无限' + return `${n} 个` +} + +function channelLabel(features: FeatureFlags) { + const ch = features.channels + if (!ch) return '网页' + if (typeof ch === 'string') { + if (ch === 'all') return '全渠道' + return ch + } + if (ch.includes('all')) return '全渠道' + const map: Record = { web: '网页', wechat: '微信', app: 'APP', phone: '电话' } + return ch.map(c => map[c] || c).join(' + ') || '网页' +} + +function statsLabel(features: FeatureFlags) { + const s = features.stats + if (s === 'advanced') return '高级' + if (s === 'custom') return '自定义' + return '基础' +} + +type FeatureRow = { label: string; ok: boolean; detail?: string } + +function featureRows(plan: Plan): FeatureRow[] { + const f = parseFeatures(plan.features) + return [ + { label: '坐席数量', ok: true, detail: formatSeats(plan.seats) }, + { label: '对话记录保存', ok: true, detail: formatStorage(plan.storage_days) }, + { label: '知识库容量', ok: true, detail: formatKB(plan.kb_limit) }, + { label: '数据统计报表', ok: true, detail: statsLabel(f) }, + { label: 'API 接口', ok: Boolean(f.api), detail: f.api ? undefined : undefined }, + { label: '多渠道接入', ok: true, detail: channelLabel(f) }, + { label: '自定义品牌', ok: Boolean(f.brand) }, + { + label: '专属客服支持', + ok: Boolean(f.dedicated), + detail: f.dedicated === true || f.dedicated === 'true' + ? '7×24' + : typeof f.dedicated === 'string' + ? f.dedicated + : undefined, + }, + ] +} + const Plans = () => { const [plans, setPlans] = useState([]) const [tenants, setTenants] = useState([]) const [loading, setLoading] = useState(true) const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) const [saving, setSaving] = useState(false) const [form] = Form.useForm() @@ -56,161 +117,354 @@ const Plans = () => { return map }, [tenants]) + const sorted = useMemo( + () => [...plans].sort((a, b) => a.price_monthly - b.price_monthly), + [plans], + ) + + /** 价格居中的套餐标为推荐(通常专业版) */ + const recommendId = useMemo(() => { + if (sorted.length < 2) return sorted[0]?.id + return sorted[Math.min(1, sorted.length - 1)]?.id + }, [sorted]) + + const openCreate = () => { + setEditing(null) + form.resetFields() + form.setFieldsValue({ + price_monthly: 299, + seats: 5, + storage_days: 30, + kb_limit: 100, + api: false, + brand: false, + dedicated: false, + stats: 'basic', + channels: ['web'], + status: 'active', + }) + setModalOpen(true) + } + + const openEdit = (plan: Plan) => { + setEditing(plan) + const f = parseFeatures(plan.features) + form.setFieldsValue({ + name: plan.name, + price_monthly: plan.price_monthly, + seats: plan.seats, + storage_days: plan.storage_days, + kb_limit: plan.kb_limit, + status: plan.status, + api: Boolean(f.api), + brand: Boolean(f.brand), + dedicated: Boolean(f.dedicated), + stats: f.stats || 'basic', + channels: Array.isArray(f.channels) + ? f.channels + : f.channels === 'all' + ? ['all'] + : ['web'], + }) + setModalOpen(true) + } + + const buildFeatures = (values: { + api?: boolean; brand?: boolean; dedicated?: boolean; stats?: string; channels?: string[] + }) => JSON.stringify({ + stats: values.stats || 'basic', + api: Boolean(values.api), + brand: Boolean(values.brand), + dedicated: Boolean(values.dedicated), + channels: values.channels?.includes('all') ? 'all' : (values.channels || ['web']), + }) + + const handleSubmit = async (values: { + name: string; price_monthly: number; seats: number; storage_days: number; kb_limit: number + status?: string; api?: boolean; brand?: boolean; dedicated?: boolean; stats?: string; channels?: string[] + }) => { + setSaving(true) + try { + const payload = { + name: values.name.trim(), + price_monthly: values.price_monthly, + seats: values.seats, + storage_days: values.storage_days, + kb_limit: values.kb_limit, + status: values.status || 'active', + features: buildFeatures(values), + } + if (editing) { + await updatePlan(editing.id, payload) + message.success('套餐已更新') + } else { + await createPlan(payload) + message.success('套餐已创建') + } + setModalOpen(false) + form.resetFields() + setEditing(null) + await load() + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败') + } finally { + setSaving(false) + } + } + const toggleStatus = async (plan: Plan, active: boolean) => { try { const res = await updatePlan(plan.id, { status: active ? 'active' : 'inactive' }) - setPlans(prev => prev.map(p => p.id === plan.id ? res.data : p)) + setPlans(prev => prev.map(p => (p.id === plan.id ? res.data : p))) message.success(active ? '已上架' : '已下架') } catch (e) { message.error(e instanceof Error ? e.message : '更新失败') } } - const handleCreate = async (values: { - name: string; price_monthly: number; seats: number; storage_days: number; kb_limit: number - }) => { - setSaving(true) - try { - await createPlan({ - name: values.name.trim(), - price_monthly: values.price_monthly, - seats: values.seats, - storage_days: values.storage_days, - kb_limit: values.kb_limit, - status: 'active', - features: JSON.stringify({ source: 'admin' }), - }) - message.success('套餐已创建') - setModalOpen(false) - form.resetFields() - await load() - } catch (e) { - message.error(e instanceof Error ? e.message : '创建失败') - } finally { - setSaving(false) - } - } - - const sorted = [...plans].sort((a, b) => a.price_monthly - b.price_monthly) - const compareRows = [ - { label: '月费', values: sorted.map(p => `¥${p.price_monthly}/月`) }, - { label: '坐席数量', values: sorted.map(p => String(p.seats)) }, - { label: '对话记录保存', values: sorted.map(p => formatStorage(p.storage_days)) }, - { label: '知识库容量', values: sorted.map(p => formatKB(p.kb_limit)) }, - { - label: '状态', - values: sorted.map(p => (p.status === 'active' ? '上架' : '下架')), - }, - ] - - if (loading) { - return
- } - return ( -
-
-

套餐管理

- -
+ - {sorted.length === 0 ? ( - - ) : ( -
- {sorted.map((plan, index) => { - const features = parseFeatures(plan.features) - const recommended = index === 1 - return ( - -
- {plan.name} - {recommended && 推荐} - - {plan.status === 'active' ? '在售' : '下架'} - +
+ {loading ? ( +
+ ) : sorted.length === 0 ? ( + + + + ) : ( +
+ {/* 套餐卡片 */} +
+ {sorted.map(plan => { + const recommended = plan.id === recommendId + const active = plan.status === 'active' + const rows = featureRows(plan) + const checkColor = recommended ? '#2563eb' : '#16a34a' + return ( +
+ {recommended && ( + <> +
+ + 推荐 + + + )} + +
+

{plan.name}

+ + {active ? '在售' : '已下架'} + +
+ +
+ + ¥{plan.price_monthly} + + /月 +
+ +
    + {rows.map(row => ( +
  • + {row.ok ? ( + + ) : ( + + )} + + {row.label} + {row.ok && row.detail != null && row.detail !== '' && ( + <>:{row.detail} + )} + +
  • + ))} +
+ +
+ + + {tenantCountByPlan[plan.id] || 0} 个租户 + +
+ toggleStatus(plan, v)} + /> + +
- )} - > -
- ¥{plan.price_monthly} - /月 -
-
- {[ - { label: '坐席数量', value: `${plan.seats} 坐席` }, - { label: '对话记录', value: formatStorage(plan.storage_days) }, - { label: '知识库', value: formatKB(plan.kb_limit) }, - { label: 'API', value: features.api ? '支持' : '视配置' }, - ].map((item, i) => ( -
- {item.label} - {String(item.value)} + ) + })} +
+ + {/* 定价规则 */} +
+

定价规则说明

+
+ {[ + { + icon: , + title: '按月订阅', + desc: '标准月度计费,年付享受 8 折优惠,支持随时升降配', + }, + { + icon: , + title: '超额坐席', + desc: '超出套餐坐席数后按每坐席 ¥49/月 额外计费', + }, + { + icon: , + title: '下架与续费', + desc: '下架后新租户不可购买,已购租户可正常续费;企业版可联系销售定制', + }, + ].map(item => ( +
+
+ {item.icon}
- ))} -
-
- {tenantCountByPlan[plan.id] || 0} 个租户使用 - toggleStatus(plan, v)} - /> -
- - ) - })} -
- )} +
+

{item.title}

+

{item.desc}

+
+
+ ))} +
+ +
+ )} +
- {sorted.length > 0 && ( - - ({ key: i, label: row.label, ...Object.fromEntries(sorted.map((_, idx) => [`p${idx}`, row.values[idx]])) }))} - pagination={false} - size="middle" - columns={[ - { title: '权益项', dataIndex: 'label', key: 'label', width: 160, render: (t: string) => {t} }, - ...sorted.map((p, idx) => ({ - title: p.name, - dataIndex: `p${idx}`, - key: `p${idx}`, - render: (t: string) => { - if (t === '不支持') return - if (t === '支持') return - return {t} - }, - })), + { setModalOpen(false); setEditing(null) }} + onOk={() => form.submit()} + confirmLoading={saving} + destroyOnClose + width={520} + okText="保存" + > +
+ + + +
+ + + + + + + + + + + + +
+ + - - - - + /> + + + + + )}