优化平台套餐定价页并对齐效果图
固定 56px 顶栏,三列套餐卡片含权益勾选与推荐角标,支持编辑/上下架与定价规则说明。
This commit is contained in:
+395
-141
@@ -1,11 +1,23 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Card, Tag, Switch, Button, Table, message, Spin, Empty, Modal, Form, Input, InputNumber } from 'antd'
|
import { Switch, Button, message, Spin, Empty, Modal, Form, Input, InputNumber, Select } from 'antd'
|
||||||
import { CheckCircleOutlined, CloseCircleOutlined, PlusOutlined } from '@ant-design/icons'
|
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'
|
import { createPlan, getPlans, getTenants, updatePlan, type Plan, type Tenant } from '@/services/api'
|
||||||
|
|
||||||
function parseFeatures(raw: string): Record<string, unknown> {
|
type FeatureFlags = {
|
||||||
|
stats?: string
|
||||||
|
api?: boolean
|
||||||
|
channels?: string[] | string
|
||||||
|
brand?: boolean
|
||||||
|
dedicated?: boolean | string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeatures(raw: string): FeatureFlags {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw || '{}')
|
return JSON.parse(raw || '{}') as FeatureFlags
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
@@ -21,11 +33,60 @@ function formatKB(limit: number) {
|
|||||||
return `${limit} 条`
|
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<string, string> = { 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 = () => {
|
||||||
const [plans, setPlans] = useState<Plan[]>([])
|
const [plans, setPlans] = useState<Plan[]>([])
|
||||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Plan | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
@@ -56,161 +117,354 @@ const Plans = () => {
|
|||||||
return map
|
return map
|
||||||
}, [tenants])
|
}, [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) => {
|
const toggleStatus = async (plan: Plan, active: boolean) => {
|
||||||
try {
|
try {
|
||||||
const res = await updatePlan(plan.id, { status: active ? 'active' : 'inactive' })
|
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 ? '已上架' : '已下架')
|
message.success(active ? '已上架' : '已下架')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '更新失败')
|
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 <div className="py-20 text-center"><Spin size="large" /></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||||
<div className="flex items-center justify-between mb-5">
|
<header
|
||||||
<h2 className="text-lg font-semibold text-neutral-800">套餐管理</h2>
|
className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200 bg-white"
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); form.setFieldsValue({ price_monthly: 299, seats: 2, storage_days: 30, kb_limit: 50 }); setModalOpen(true) }}>
|
style={{ height: 'var(--header-height)' }}
|
||||||
|
>
|
||||||
|
<h1 className="text-base font-semibold text-neutral-900 m-0">套餐管理</h1>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} className="!h-8" onClick={openCreate}>
|
||||||
新建套餐
|
新建套餐
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{sorted.length === 0 ? (
|
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
|
||||||
<Empty description="暂无套餐" />
|
{loading ? (
|
||||||
) : (
|
<div className="h-64 flex items-center justify-center"><Spin size="large" /></div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8">
|
) : sorted.length === 0 ? (
|
||||||
{sorted.map((plan, index) => {
|
<Empty description="暂无套餐">
|
||||||
const features = parseFeatures(plan.features)
|
<Button type="primary" onClick={openCreate}>新建套餐</Button>
|
||||||
const recommended = index === 1
|
</Empty>
|
||||||
return (
|
) : (
|
||||||
<Card
|
<div className="flex flex-col gap-6 w-full max-w-[1200px]">
|
||||||
key={plan.id}
|
{/* 套餐卡片 */}
|
||||||
className={`!rounded-xl ${recommended ? 'ring-2 ring-blue-500 shadow-lg' : ''}`}
|
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||||
bordered={false}
|
{sorted.map(plan => {
|
||||||
title={(
|
const recommended = plan.id === recommendId
|
||||||
<div className="flex items-center justify-between">
|
const active = plan.status === 'active'
|
||||||
<div className="flex items-center gap-2">
|
const rows = featureRows(plan)
|
||||||
<span className="text-base font-semibold text-neutral-800">{plan.name}</span>
|
const checkColor = recommended ? '#2563eb' : '#16a34a'
|
||||||
{recommended && <Tag color="blue" className="text-xs">推荐</Tag>}
|
return (
|
||||||
<Tag color={plan.status === 'active' ? 'green' : 'default'} className="text-xs">
|
<div
|
||||||
{plan.status === 'active' ? '在售' : '下架'}
|
key={plan.id}
|
||||||
</Tag>
|
className={`relative flex flex-col rounded-xl bg-white p-6 shadow-sm ${
|
||||||
|
recommended
|
||||||
|
? 'border-2 border-[#2563eb] shadow-md'
|
||||||
|
: 'border border-neutral-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{recommended && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 right-0"
|
||||||
|
style={{
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderLeft: '48px solid transparent',
|
||||||
|
borderTop: '48px solid #2563eb',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute top-2 right-1.5 text-[10px] font-semibold text-white whitespace-nowrap"
|
||||||
|
style={{ transform: 'rotate(45deg)' }}
|
||||||
|
>
|
||||||
|
推荐
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-4 pr-6">
|
||||||
|
<h2 className="text-lg font-bold text-neutral-900 m-0 truncate">{plan.name}</h2>
|
||||||
|
<span
|
||||||
|
className={`inline-flex px-2 py-0.5 rounded-md text-[11px] font-medium whitespace-nowrap ${
|
||||||
|
active ? 'bg-[#f0fdf4] text-[#16a34a]' : 'bg-neutral-100 text-neutral-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active ? '在售' : '已下架'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5">
|
||||||
|
<span
|
||||||
|
className={`text-4xl font-bold tabular-nums ${
|
||||||
|
recommended ? 'text-[#2563eb]' : 'text-neutral-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
¥{plan.price_monthly}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-neutral-500 ml-1">/月</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2.5 flex-1 mb-5 m-0 p-0 list-none">
|
||||||
|
{rows.map(row => (
|
||||||
|
<li
|
||||||
|
key={row.label}
|
||||||
|
className={`flex items-start gap-2 text-sm ${
|
||||||
|
row.ok ? 'text-neutral-700' : 'text-neutral-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.ok ? (
|
||||||
|
<CheckOutlined className="mt-0.5 shrink-0 text-xs" style={{ color: checkColor }} />
|
||||||
|
) : (
|
||||||
|
<CloseOutlined className="mt-0.5 shrink-0 text-xs text-neutral-300" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{row.label}
|
||||||
|
{row.ok && row.detail != null && row.detail !== '' && (
|
||||||
|
<>:<strong className="font-semibold">{row.detail}</strong></>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between pt-4 border-t gap-2 ${
|
||||||
|
recommended ? 'border-blue-100' : 'border-neutral-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-neutral-500 flex items-center gap-1 min-w-0">
|
||||||
|
<TeamOutlined className="shrink-0" />
|
||||||
|
<span className="truncate">{tenantCountByPlan[plan.id] || 0} 个租户</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={active}
|
||||||
|
checkedChildren="上架"
|
||||||
|
unCheckedChildren="下架"
|
||||||
|
onChange={v => toggleStatus(plan, v)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEdit(plan)}
|
||||||
|
className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium border-0 cursor-pointer ${
|
||||||
|
recommended
|
||||||
|
? 'bg-[#2563eb] text-white hover:bg-[#1d4ed8]'
|
||||||
|
: 'bg-white text-neutral-700 border border-neutral-200 hover:bg-neutral-50'
|
||||||
|
}`}
|
||||||
|
style={recommended ? undefined : { border: '1px solid #e2e8f0' }}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-xs" />
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
>
|
})}
|
||||||
<div className="text-center mb-4">
|
</section>
|
||||||
<span className="text-3xl font-bold text-neutral-800">¥{plan.price_monthly}</span>
|
|
||||||
<span className="text-sm text-neutral-400">/月</span>
|
{/* 定价规则 */}
|
||||||
</div>
|
<section className="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||||
<div className="space-y-2.5 mb-4">
|
<h3 className="text-base font-bold text-neutral-900 m-0 mb-4">定价规则说明</h3>
|
||||||
{[
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{ label: '坐席数量', value: `${plan.seats} 坐席` },
|
{[
|
||||||
{ label: '对话记录', value: formatStorage(plan.storage_days) },
|
{
|
||||||
{ label: '知识库', value: formatKB(plan.kb_limit) },
|
icon: <CalendarOutlined className="text-[#2563eb]" />,
|
||||||
{ label: 'API', value: features.api ? '支持' : '视配置' },
|
title: '按月订阅',
|
||||||
].map((item, i) => (
|
desc: '标准月度计费,年付享受 8 折优惠,支持随时升降配',
|
||||||
<div key={i} className="flex justify-between text-sm">
|
},
|
||||||
<span className="text-neutral-400">{item.label}</span>
|
{
|
||||||
<span className="text-neutral-700 font-medium">{String(item.value)}</span>
|
icon: <ThunderboltOutlined className="text-[#2563eb]" />,
|
||||||
|
title: '超额坐席',
|
||||||
|
desc: '超出套餐坐席数后按每坐席 ¥49/月 额外计费',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CustomerServiceOutlined className="text-[#2563eb]" />,
|
||||||
|
title: '下架与续费',
|
||||||
|
desc: '下架后新租户不可购买,已购租户可正常续费;企业版可联系销售定制',
|
||||||
|
},
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.title} className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-md bg-[#dbeafe] flex items-center justify-center shrink-0">
|
||||||
|
{item.icon}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="min-w-0">
|
||||||
</div>
|
<p className="text-sm font-semibold text-neutral-800 m-0">{item.title}</p>
|
||||||
<div className="flex items-center justify-between pt-3 border-t border-neutral-100">
|
<p className="text-sm text-neutral-500 m-0 mt-1 leading-relaxed">{item.desc}</p>
|
||||||
<span className="text-sm text-neutral-400">{tenantCountByPlan[plan.id] || 0} 个租户使用</span>
|
</div>
|
||||||
<Switch
|
</div>
|
||||||
checked={plan.status === 'active'}
|
))}
|
||||||
checkedChildren="上架"
|
</div>
|
||||||
unCheckedChildren="下架"
|
</section>
|
||||||
onChange={v => toggleStatus(plan, v)}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sorted.length > 0 && (
|
<Modal
|
||||||
<Card title="套餐权益对比" className="!rounded-lg" bordered={false}>
|
title={editing ? '编辑套餐' : '新建套餐'}
|
||||||
<Table
|
open={modalOpen}
|
||||||
dataSource={compareRows.map((row, i) => ({ key: i, label: row.label, ...Object.fromEntries(sorted.map((_, idx) => [`p${idx}`, row.values[idx]])) }))}
|
onCancel={() => { setModalOpen(false); setEditing(null) }}
|
||||||
pagination={false}
|
onOk={() => form.submit()}
|
||||||
size="middle"
|
confirmLoading={saving}
|
||||||
columns={[
|
destroyOnClose
|
||||||
{ title: '权益项', dataIndex: 'label', key: 'label', width: 160, render: (t: string) => <span className="font-medium text-neutral-700">{t}</span> },
|
width={520}
|
||||||
...sorted.map((p, idx) => ({
|
okText="保存"
|
||||||
title: p.name,
|
>
|
||||||
dataIndex: `p${idx}`,
|
<Form form={form} layout="vertical" onFinish={handleSubmit} className="mt-2">
|
||||||
key: `p${idx}`,
|
<Form.Item name="name" label="套餐名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||||
render: (t: string) => {
|
<Input maxLength={30} placeholder="如:专业版" />
|
||||||
if (t === '不支持') return <CloseCircleOutlined className="text-neutral-300" />
|
</Form.Item>
|
||||||
if (t === '支持') return <CheckCircleOutlined className="text-green-500" />
|
<div className="grid grid-cols-2 gap-3">
|
||||||
return <span className="text-sm text-neutral-600">{t}</span>
|
<Form.Item name="price_monthly" label="月费(元)" rules={[{ required: true }]}>
|
||||||
},
|
<InputNumber min={0} className="!w-full" />
|
||||||
})),
|
</Form.Item>
|
||||||
|
<Form.Item name="seats" label="坐席数" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} className="!w-full" placeholder="0 表示无限" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storage_days" label="记录保存(天)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} className="!w-full" placeholder="0=永久" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="kb_limit" label="知识库容量" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} className="!w-full" placeholder="0=不限" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="stats" label="统计能力">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'basic', label: '基础' },
|
||||||
|
{ value: 'advanced', label: '高级' },
|
||||||
|
{ value: 'custom', label: '自定义' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Form.Item>
|
||||||
)}
|
<Form.Item name="channels" label="渠道">
|
||||||
|
<Select
|
||||||
<Card title="定价规则" className="!rounded-lg mt-4" bordered={false}>
|
mode="multiple"
|
||||||
<ul className="text-sm text-neutral-600 space-y-2 ml-4">
|
options={[
|
||||||
<li>按月订阅,年付享受 <span className="text-blue-600 font-medium">8 折</span> 优惠</li>
|
{ value: 'web', label: '网页' },
|
||||||
<li>超出套餐坐席按 <span className="text-blue-600 font-medium">¥49/月/坐席</span> 额外计费</li>
|
{ value: 'wechat', label: '微信' },
|
||||||
<li>下架后新租户不可购买该套餐,已购买租户可正常续费</li>
|
{ value: 'app', label: 'APP' },
|
||||||
<li>企业版支持自定义报价,请联系销售团队</li>
|
{ value: 'all', label: '全渠道' },
|
||||||
</ul>
|
]}
|
||||||
</Card>
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Modal title="新建套餐" open={modalOpen} onCancel={() => setModalOpen(false)} onOk={() => form.submit()} confirmLoading={saving} destroyOnClose>
|
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||||
<Form form={form} layout="vertical" onFinish={handleCreate} className="mt-2">
|
<Form.Item name="api" label="API" valuePropName="checked" className="!mb-0">
|
||||||
<Form.Item name="name" label="套餐名称" rules={[{ required: true }]}><Input maxLength={30} /></Form.Item>
|
<Switch />
|
||||||
<Form.Item name="price_monthly" label="月费(元)" rules={[{ required: true }]}><InputNumber min={0} className="w-full" /></Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="seats" label="坐席数" rules={[{ required: true }]}><InputNumber min={1} className="w-full" /></Form.Item>
|
<Form.Item name="brand" label="自定义品牌" valuePropName="checked" className="!mb-0">
|
||||||
<Form.Item name="storage_days" label="记录保存天数(0=永久)" rules={[{ required: true }]}><InputNumber min={0} className="w-full" /></Form.Item>
|
<Switch />
|
||||||
<Form.Item name="kb_limit" label="知识库容量(0=不限)" rules={[{ required: true }]}><InputNumber min={0} className="w-full" /></Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="dedicated" label="专属客服" valuePropName="checked" className="!mb-0">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
{editing && (
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'active', label: '在售' },
|
||||||
|
{ value: 'inactive', label: '下架' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user