feat: 人物编辑弹窗改为CKB计划选择下拉框

- 新增 GET /api/admin/ckb/plans 获取存客宝获客计划列表
- 新增 GET /api/admin/ckb/plan-detail 获取计划详情
- PersonAddEditModal: 密钥字段改为可搜索的计划选择器
  选择计划后自动覆盖 greeting/tips/设备/时间等参数
- 删除"修复 CKB 密钥"按钮

Made-with: Cursor
This commit is contained in:
卡若
2026-03-15 23:12:43 +08:00
parent aca006e1b2
commit fa9903d235
17 changed files with 343 additions and 91 deletions

View File

@@ -53,4 +53,35 @@ export function getPersonDetail(personId: string) {
return get<PersonDetailResponse>(`/api/db/person?personId=${encodeURIComponent(personId)}`)
}
export interface CkbPlan {
id: number | string
name: string
apiKey?: string
sceneId?: number
scenario?: number
enabled?: boolean
greeting?: string
tips?: string
remarkType?: string
remarkFormat?: string
addInterval?: number
startTime?: string
endTime?: string
deviceGroups?: (number | string)[]
}
export interface CkbPlansResponse {
success?: boolean
error?: string
plans?: CkbPlan[]
total?: number
}
export function getCkbPlans(params?: { page?: number; limit?: number; keyword?: string }) {
const search = new URLSearchParams()
if (params?.page) search.set('page', String(params.page))
if (params?.limit) search.set('limit', String(params.limit))
if (params?.keyword?.trim()) search.set('keyword', params.keyword.trim())
const qs = search.toString()
return get<CkbPlansResponse>(qs ? `/api/admin/ckb/plans?${qs}` : '/api/admin/ckb/plans')
}

View File

@@ -2454,37 +2454,17 @@ export function ContentPage() {
<CardContent className="space-y-3">
<div className="flex justify-between items-center">
<p className="text-xs text-gray-500"> API </p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="border-amber-600 text-amber-400 hover:bg-amber-900/30 bg-transparent"
onClick={async () => {
try {
const res = await post<{ success?: boolean; fixed?: number; total?: number; results?: { personId: string; name: string; apiKey?: string; error?: string }[] }>('/api/db/persons/fix-ckb', {})
if (res?.success) {
alert(`修复完成:${res.fixed}/${res.total} 个人物已补充 CKB 密钥`)
loadPersons()
} else {
alert('修复失败')
}
} catch { alert('修复请求失败') }
}}
>
CKB
</Button>
<Button
size="sm"
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
onClick={() => {
setEditingPerson(null)
setPersonModalOpen(true)
}}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<Button
size="sm"
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
onClick={() => {
setEditingPerson(null)
setPersonModalOpen(true)
}}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{persons.length > 0 && (

View File

@@ -23,7 +23,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import toast from '@/utils/toast'
import { getCkbDevices, type CkbDevice } from '@/api/ckb'
import { getCkbDevices, getCkbPlans, type CkbDevice, type CkbPlan } from '@/api/ckb'
export interface PersonFormData {
personId: string
@@ -93,6 +93,10 @@ export function PersonAddEditModal({
const [deviceOptions, setDeviceOptions] = useState<CkbDevice[]>([])
const [deviceLoading, setDeviceLoading] = useState(false)
const [deviceKeyword, setDeviceKeyword] = useState('')
const [planOptions, setPlanOptions] = useState<CkbPlan[]>([])
const [planLoading, setPlanLoading] = useState(false)
const [planKeyword, setPlanKeyword] = useState('')
const [planDropdownOpen, setPlanDropdownOpen] = useState(false)
/** 必填项校验错误,用于红色边框与提示 */
const [errors, setErrors] = useState<{
name?: string
@@ -126,10 +130,13 @@ export function PersonAddEditModal({
}
setErrors({})
// 懒加载设备列表:仅在第一次需要时加载
// 懒加载设备列表和计划列表
if (deviceOptions.length === 0) {
void loadDevices('')
}
if (planOptions.length === 0) {
void loadPlans('')
}
}
}, [open, editingPerson])
@@ -149,6 +156,44 @@ export function PersonAddEditModal({
}
}
const loadPlans = async (keyword: string) => {
setPlanLoading(true)
try {
const res = await getCkbPlans({ page: 1, limit: 100, keyword })
if (res?.success && Array.isArray(res.plans)) {
setPlanOptions(res.plans)
} else if (res?.error) {
toast.error(res.error)
}
} catch {
toast.error('加载计划列表失败')
} finally {
setPlanLoading(false)
}
}
const selectPlan = (plan: CkbPlan) => {
const dg = Array.isArray(plan.deviceGroups) ? plan.deviceGroups.map(String).join(',') : ''
setForm(f => ({
...f,
ckbApiKey: plan.apiKey || '',
greeting: plan.greeting || f.greeting,
tips: plan.tips || f.tips,
remarkType: plan.remarkType || f.remarkType,
remarkFormat: plan.remarkFormat || f.remarkFormat,
addFriendInterval: plan.addInterval || f.addFriendInterval,
startTime: plan.startTime || f.startTime,
endTime: plan.endTime || f.endTime,
deviceGroups: dg || f.deviceGroups,
}))
setPlanDropdownOpen(false)
toast.success(`已选择计划「${plan.name}」,参数已覆盖`)
}
const filteredPlans = planKeyword.trim()
? planOptions.filter(p => (p.name || '').includes(planKeyword.trim()) || String(p.id).includes(planKeyword.trim()))
: planOptions
const handleSubmit = async () => {
const nextErrors: {
name?: string
@@ -256,16 +301,74 @@ export function PersonAddEditModal({
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
{/* 左列:计划密钥与设备 */}
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs"> apiKey</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="创建计划成功后自动回填,不可手动修改"
value={form.ckbApiKey}
readOnly
/>
<div className="space-y-1.5 relative">
<Label className="text-gray-400 text-xs"></Label>
<div className="flex gap-2">
<div
className="flex-1 flex items-center bg-[#0a1628] border border-gray-700 rounded-md px-3 py-2 cursor-pointer hover:border-[#38bdac]/60 text-sm"
onClick={() => setPlanDropdownOpen(!planDropdownOpen)}
>
{form.ckbApiKey ? (
<span className="text-white truncate">
{planOptions.find(p => p.apiKey === form.ckbApiKey)?.name || `密钥: ${form.ckbApiKey.slice(0, 20)}...`}
</span>
) : (
<span className="text-gray-500"> / </span>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-200 shrink-0"
onClick={() => { void loadPlans(planKeyword); setPlanDropdownOpen(true) }}
disabled={planLoading}
>
{planLoading ? '加载...' : '刷新'}
</Button>
</div>
{planDropdownOpen && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-[#0b1828] border border-gray-700 rounded-lg shadow-xl max-h-64 flex flex-col">
<div className="p-2 border-b border-gray-700/60">
<Input
className="bg-[#050c18] border-gray-700 text-white h-8 text-xs"
placeholder="搜索计划名称..."
value={planKeyword}
onChange={e => setPlanKeyword(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void loadPlans(planKeyword) }}
autoFocus
/>
</div>
<div className="flex-1 overflow-y-auto">
{filteredPlans.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-xs">
{planLoading ? '加载中...' : '暂无计划'}
</div>
) : filteredPlans.map(plan => (
<div
key={String(plan.id)}
className={`px-3 py-2 cursor-pointer hover:bg-[#38bdac]/10 text-sm flex items-center justify-between ${form.ckbApiKey === plan.apiKey ? 'bg-[#38bdac]/20 text-[#38bdac]' : 'text-white'}`}
onClick={() => selectPlan(plan)}
>
<div className="truncate">
<span className="font-medium">{plan.name}</span>
<span className="text-xs text-gray-500 ml-2">ID:{String(plan.id)}</span>
</div>
{plan.enabled ? (
<span className="text-[10px] text-green-400 bg-green-400/10 px-1.5 rounded shrink-0 ml-2"></span>
) : (
<span className="text-[10px] text-gray-500 bg-gray-500/10 px-1.5 rounded shrink-0 ml-2"></span>
)}
</div>
))}
</div>
<div className="p-2 border-t border-gray-700/60 flex justify-end">
<Button type="button" size="sm" variant="ghost" className="text-gray-400 h-7 text-xs" onClick={() => setPlanDropdownOpen(false)}></Button>
</div>
</div>
)}
<p className="text-xs text-gray-500">
apiKey @人物
</p>
</div>
<div className="space-y-1.5">