diff --git a/web/src/pages/admin/Plans.tsx b/web/src/pages/admin/Plans.tsx index 9426d0f..b3a73a3 100644 --- a/web/src/pages/admin/Plans.tsx +++ b/web/src/pages/admin/Plans.tsx @@ -1,8 +1,7 @@ import { useEffect, useMemo, useState } from 'react' -import { Switch, Button, message, Spin, Empty, Modal, Form, Input, InputNumber, Select } from 'antd' +import { Switch, Button, message, Spin, Empty, Modal, Form, Input, InputNumber, Select, Tooltip } from 'antd' import { - CheckOutlined, CloseOutlined, PlusOutlined, EditOutlined, TeamOutlined, - CalendarOutlined, ThunderboltOutlined, CustomerServiceOutlined, + CheckOutlined, PlusOutlined, EditOutlined, TeamOutlined, } from '@ant-design/icons' import { createPlan, getPlans, getTenants, updatePlan, type Plan, type Tenant } from '@/services/api' @@ -12,7 +11,6 @@ type FeatureFlags = { channels?: string[] | string brand?: boolean dedicated?: boolean | string - source?: string } function parseFeatures(raw: string): FeatureFlags { @@ -24,61 +22,94 @@ function parseFeatures(raw: string): FeatureFlags { } function formatStorage(days: number) { - if (!days || days <= 0) return '永久' + if (!days || days <= 0) return '永久保存' return `${days} 天` } function formatKB(limit: number) { - if (!limit || limit <= 0) return '不限' + if (!limit || limit <= 0) return '不限容量' return `${limit} 条` } function formatSeats(n: number) { - if (!n || n <= 0 || n >= 999) return '无限' - return `${n} 个` + if (!n || n <= 0 || n >= 999) return '不限坐席' + return `${n} 坐席` } function channelLabel(features: FeatureFlags) { const ch = features.channels - if (!ch) return '网页' + if (!ch) return '网页渠道' if (typeof ch === 'string') { - if (ch === 'all') return '全渠道' + if (ch === 'all') return '全渠道接入' return ch } - if (ch.includes('all')) return '全渠道' + if (ch.includes('all')) return '全渠道接入' const map: Record = { web: '网页', wechat: '微信', app: 'APP', phone: '电话' } - return ch.map(c => map[c] || c).join(' + ') || '网页' + 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 '基础' + if (s === 'advanced') return '高级统计' + if (s === 'custom') return '自定义报表' + return '基础统计' } -type FeatureRow = { label: string; ok: boolean; detail?: string } +type Highlight = { text: string; muted?: boolean } -function featureRows(plan: Plan): FeatureRow[] { +function planHighlights(plan: Plan): Highlight[] { 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) }, + const items: Highlight[] = [ + { text: formatSeats(plan.seats) }, + { text: formatStorage(plan.storage_days) }, + { text: formatKB(plan.kb_limit) }, + { text: statsLabel(f) }, + { text: channelLabel(f) }, + ] + if (f.api) items.push({ text: '开放 API' }) + else items.push({ text: '不含 API', muted: true }) + if (f.brand) items.push({ text: '自定义品牌' }) + else items.push({ text: '不含品牌定制', muted: true }) + if (f.dedicated) { + items.push({ + text: typeof f.dedicated === 'string' && f.dedicated !== 'true' + ? `专属客服 · ${f.dedicated}` + : '专属客服 · 7×24', + }) + } else { + items.push({ text: '不含专属客服', muted: true }) + } + return items +} + +/** 卡片主题色:按价格档位 */ +function planTheme(index: number, recommended: boolean) { + if (recommended) { + return { + ring: 'ring-2 ring-[#2563eb]/50', + price: 'text-[#2563eb]', + accent: 'bg-[#2563eb]', + soft: 'from-[#eff6ff] to-white', + check: 'bg-[#dbeafe] text-[#2563eb]', + } + } + const themes = [ { - label: '专属客服支持', - ok: Boolean(f.dedicated), - detail: f.dedicated === true || f.dedicated === 'true' - ? '7×24' - : typeof f.dedicated === 'string' - ? f.dedicated - : undefined, + ring: 'ring-1 ring-neutral-200/80', + price: 'text-neutral-900', + accent: 'bg-neutral-800', + soft: 'from-neutral-50 to-white', + check: 'bg-neutral-100 text-neutral-600', + }, + { + ring: 'ring-1 ring-neutral-200/80', + price: 'text-neutral-900', + accent: 'bg-violet-600', + soft: 'from-violet-50/40 to-white', + check: 'bg-violet-50 text-violet-600', }, ] + return themes[index % themes.length] } const Plans = () => { @@ -117,16 +148,34 @@ const Plans = () => { return map }, [tenants]) - const sorted = useMemo( - () => [...plans].sort((a, b) => a.price_monthly - b.price_monthly), - [plans], - ) + const displayPlans = useMemo(() => { + type Row = Plan & { aliasIds: number[] } + const map = new Map() + const ordered = [...plans].sort((a, b) => a.id - b.id) + for (const p of ordered) { + const key = p.name.trim() + const existing = map.get(key) + if (!existing) { + map.set(key, { ...p, aliasIds: [p.id] }) + } else { + existing.aliasIds.push(p.id) + if (p.status === 'active') existing.status = 'active' + } + } + return [...map.values()].sort((a, b) => a.price_monthly - b.price_monthly) + }, [plans]) + + const tenantCountForPlan = (plan: Plan & { aliasIds?: number[] }) => { + const ids = plan.aliasIds || [plan.id] + return ids.reduce((s, id) => s + (tenantCountByPlan[id] || 0), 0) + } - /** 价格居中的套餐标为推荐(通常专业版) */ const recommendId = useMemo(() => { - if (sorted.length < 2) return sorted[0]?.id - return sorted[Math.min(1, sorted.length - 1)]?.id - }, [sorted]) + const pro = displayPlans.find(p => p.name.includes('专业')) + if (pro) return pro.id + if (displayPlans.length < 2) return displayPlans[0]?.id + return displayPlans[Math.floor(displayPlans.length / 2)]?.id + }, [displayPlans]) const openCreate = () => { setEditing(null) @@ -223,179 +272,176 @@ const Plans = () => { } return ( -
+
-

套餐管理

-
-
- {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 && ( - <> -
- - 推荐 - - - )} +
+
+ {loading ? ( +
+ ) : displayPlans.length === 0 ? ( +
+ + + +
+ ) : ( + <> +

+ 管理订阅档位与权益。下架后新租户不可购买,已订阅租户不受影响。 +

-
-

{plan.name}

- - {active ? '在售' : '已下架'} - -
+
+ {displayPlans.map((plan, index) => { + const recommended = plan.id === recommendId + const active = plan.status === 'active' + const theme = planTheme(index, recommended) + const highlights = planHighlights(plan) + const tenantN = tenantCountForPlan(plan) -
- - ¥{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)} - /> + {recommended && ( +
+ + 最受欢迎 + +
+ )} + +
+
+
+

+ {plan.name} +

+

+ {active ? '当前可售' : '已对新增租户关闭'} +

+
+ + {active ? '在售' : '下架'} + +
+ +
+
+ + ¥{plan.price_monthly.toLocaleString()} + + / 月 +
+

+ 年付约 ¥{Math.round(plan.price_monthly * 12 * 0.8).toLocaleString()}(8 折) +

+
+ +
    + {highlights.map(h => ( +
  • + + + + + {h.text} + +
  • + ))} +
+
+ +
+
+ + + + {tenantN} + 个租户 + + +
+ 上架 + toggleStatus(plan, v)} + /> +
+
-
-
- ) - })} -
- - {/* 定价规则 */} -
-

定价规则说明

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

{item.title}

-

{item.desc}

-
-
- ))} + + ) + })}
-
-
- )} + + {/* 底部说明 — 紧凑一行信息条 */} +
+
+ 计费说明 + | + 年付 8 折 + · + 超额坐席 ¥49/月 + · + 下架不影响已购租户续费 +
+
+ + )} +
{ destroyOnClose width={520} okText="保存" + styles={{ body: { paddingTop: 12 } }} > -
+ - + -
+
- + - - + + - - + + - - + +
- { ]} /> -
+
@@ -457,11 +507,13 @@ const Plans = () => {
{editing && ( - - )}