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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-DxdnfQve.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9IazBEm.css">
<script type="module" crossorigin src="/assets/index-A95wVqFr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BHf8KXmF.css">
</head>
<body>
<div id="root"></div>

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">

View File

@@ -306,3 +306,117 @@ func AdminCKBDevices(c *gin.Context) {
})
}
// AdminCKBPlans GET /api/admin/ckb/plans 管理端-存客宝获客计划列表
func AdminCKBPlans(c *gin.Context) {
token, err := ckbOpenGetToken()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
pageStr := c.DefaultQuery("page", "1")
limitStr := c.DefaultQuery("limit", "50")
keyword := c.Query("keyword")
values := url.Values{}
values.Set("page", pageStr)
values.Set("limit", limitStr)
if keyword != "" {
values.Set("keyword", keyword)
}
planURL := ckbOpenBaseURL + "/v1/plans?" + values.Encode()
req, err := http.NewRequest(http.MethodGet, planURL, nil)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "构造请求失败"})
return
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求存客宝计划列表失败"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var parsed map[string]interface{}
if err := json.Unmarshal(b, &parsed); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "解析计划列表失败"})
return
}
var listAny interface{}
if dataVal, ok := parsed["data"].(map[string]interface{}); ok {
listAny = dataVal["list"]
if _, ok := parsed["total"]; !ok {
if tv, ok := dataVal["total"]; ok {
parsed["total"] = tv
}
}
} else if la, ok := parsed["list"]; ok {
listAny = la
}
plans := make([]map[string]interface{}, 0)
if arr, ok := listAny.([]interface{}); ok {
for _, item := range arr {
if m, ok := item.(map[string]interface{}); ok {
plans = append(plans, map[string]interface{}{
"id": m["id"],
"name": m["name"],
"apiKey": m["apiKey"],
"sceneId": m["sceneId"],
"scenario": m["scenario"],
"enabled": m["enabled"],
"greeting": m["greeting"],
"tips": m["tips"],
"remarkType": m["remarkType"],
"remarkFormat": m["remarkFormat"],
"addInterval": m["addInterval"],
"startTime": m["startTime"],
"endTime": m["endTime"],
"deviceGroups": m["deviceGroups"],
})
}
}
}
total := 0
switch tv := parsed["total"].(type) {
case float64:
total = int(tv)
case int:
total = tv
case string:
if n, err := strconv.Atoi(tv); err == nil {
total = n
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "plans": plans, "total": total})
}
// AdminCKBPlanDetail GET /api/admin/ckb/plan-detail?planId=xxx 管理端-存客宝获客计划详情
func AdminCKBPlanDetail(c *gin.Context) {
planIDStr := c.Query("planId")
if planIDStr == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 planId"})
return
}
planID, _ := strconv.ParseInt(planIDStr, 10, 64)
if planID <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "planId 无效"})
return
}
token, err := ckbOpenGetToken()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
apiKey, err := ckbOpenGetPlanDetail(token, planID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "apiKey": apiKey})
}

View File

@@ -77,6 +77,8 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
// 存客宝开放 API 辅助接口:设备列表(供链接人与事选择设备)
admin.GET("/ckb/devices", handler.AdminCKBDevices)
admin.GET("/ckb/plans", handler.AdminCKBPlans)
admin.GET("/ckb/plan-detail", handler.AdminCKBPlanDetail)
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
admin.PUT("/orders/refund", handler.AdminOrderRefund)

Binary file not shown.

View File

@@ -0,0 +1,7 @@
功能一:
![](images/2026-03-15-23-04-54.png)这里话是可以选择密钥,是选择存克宝。密钥同时是选择纯科宝的上面这个 API 获客的那个场景获客的计划,并且这个场景符合计划,可以搜索的,可以直接选择是哪个计划,选择完之后可以调用相应的参数直接覆盖掉,再存个把,那这里都快修复,存克宝密钥,这个直接关掉删除。不要这个按钮。
![](images/2026-03-15-23-05-29.png)你可以调用存克宝的接口,里面调用相应的那个计划,然后直接可以选择下拉框,可以选择匹配,嗯,也可以直接新建。

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -81,6 +81,7 @@ python3 scripts/feishu_wiki_upload.py --title "文档标题" --content "内容"
| `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | tenant_access_tokenAPI 调用) |
| `FEISHU_WEBHOOK_SOUL_TEAM` | Soul 彩民团队群 webhook |
| `FEISHU_WEBHOOK_CONTENT` | 内容推送群 webhook |
| `FEISHU_WEBHOOK_PARTY_AI` | 派对AI开发群 webhook复盘/迭代汇总发这里) |
| `FEISHU_WIKI_NODE_TOKEN` | 知识库目标节点 |
飞书凭证配置见 `项目AI/config/.env``.env.feishu` 也兼容(`scripts/.env.feishu`)。

View File

@@ -19,7 +19,7 @@ updated: "2026-03-15"
| 端 | 当前版本 | 状态 | 最后更新 |
|:---|:---|:---|:---|
| 小程序C端 | **v1.3.3** | 已上传微信平台,待设体验版/提审 | 2026-03-15 |
| 小程序C端 | **v1.2.6** | 已上传微信平台,待设体验版/提审 | 2026-03-15 |
| API 后端 | soul-api | 运行中 | 2026-03-15 |
| 管理端 | soul-admin | 运行中 | 2026-03-14 |
@@ -155,9 +155,7 @@ bash 从GitHub下载最新_devlop.sh
| 版本 | 日期 | 变更 |
|:---|:---|:---|
| v1.3.3 | 2026-03-15 | 修复上下篇溢出、删除退款按钮、地球一键复制文案、分享按钮纵向布局 |
| v1.3.2 | 2026-03-15 | 修复@提及点击@纯文本解析、上下篇排序 |
| v1.3.1 | 2026-03-15 | 支付修复(代付分享/充值)、退款原路返回 |
| v1.2.6 | 2026-03-15 | 支付全链路修复、@提及解析修复、上下篇排序修复、样式溢出修复、删除退款按钮、地球一键复制文案、分享按钮纵向布局 |
---

View File

@@ -128,7 +128,22 @@ Soul创业实验AI魂AI
- 已完成需求:`开发文档/1、需求/已完成/`
- 完成后由魂AI 负责将文档移到 `已完成/`
### 7.4 与卡若AI 的协同
### 7.4 飞书群复盘推送(强制)
每次完成开发迭代后,**必须将复盘推送到派对AI开发群**
- **派对AI开发群 Webhook**`https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494`
- 格式:飞书 post 富文本,包含完成清单、技术成果、版本状态、下一步
- 所有派对AI开发的功能完成后的复盘、迭代汇总统一发到此群
推送命令模板:
```bash
curl -s -X POST "https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494" \
-H "Content-Type: application/json" \
-d '{"msg_type":"post","content":{"post":{"zh_cn":{"title":"标题","content":[[{"tag":"text","text":"内容"}]]}}}}'
```
### 7.5 与卡若AI 的协同
- 派对AI 跑通的确定性能力沉淀到卡若AI 经验库:`卡若AI/02_卡人/水溪_整理归档/经验库/待沉淀/`
- 可复制的模式CLI上传小程序、Go后端部署、支付链路等供卡若AI 其他项目复用

View File

@@ -21,8 +21,9 @@
- **获取**
1. 飞书群 → 设置 → 群机器人 → 添加机器人 → 自定义机器人
2. 复制 Webhook 地址
- **配置键**`FEISHU_WEBHOOK_SOUL_TEAM`Soul团队群`FEISHU_WEBHOOK_CONTENT`(内容推送群)
- **当前默认**`https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1`
- **配置键**`FEISHU_WEBHOOK_SOUL_TEAM`Soul团队群`FEISHU_WEBHOOK_CONTENT`(内容推送群)`FEISHU_WEBHOOK_PARTY_AI`派对AI开发群
- **Soul团队群**`https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1`
- **派对AI开发群主群复盘/迭代汇总发这里)**`https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494`
### 1.3 Wiki Node Token长期有效