diff --git a/.cursor/agent/小程序开发工程师/evolution/2026-03-16.md b/.cursor/agent/小程序开发工程师/evolution/2026-03-16.md new file mode 100644 index 00000000..e9bb7119 --- /dev/null +++ b/.cursor/agent/小程序开发工程师/evolution/2026-03-16.md @@ -0,0 +1,30 @@ +# 2026-03-16 编辑资料页分享名片 + +## 问题/场景 + +编辑资料页转发给朋友、分享到朋友圈时,需做特殊处理:直接变成「分享名片」,而非普通页面分享。分享标题、封面需体现用户身份与名片信息。 + +## 解决方案 + +1. **分享标题**:`昵称+为您分享名片`(如「少年梦想家为您分享名片」) +2. **分享封面**:Canvas 绘制名片图(5:4 比例,符合微信分享图规范) + - 布局:左头像(圆形)+ 右昵称 +「个人名片」副标题 + - 分隔线 + - 四栏信息:地区 | MBTI、行业 | 职位(标签灰、数值白,统一色值) +3. **分享路径**:转发/朋友圈均指向 `member-detail?id=userId`,好友打开即见名片详情 +4. **朋友圈重定向**:分享带 `id` 时,`profile-edit` 的 `onLoad` 检测到 `options.id` 即 `redirectTo` 到 `member-detail` + +## 技术要点 + +- **预生成**:资料加载完成后、头像更新后调用 `generateShareCard()`,将 `shareCardPath` 存入 data +- **Canvas**:隐藏 canvas 500×400,`wx.createCanvasContext` + `ctx.draw(true)` + `wx.canvasToTempFilePath` +- **头像下载**:网络头像需 `wx.downloadFile`,域名须配置 downloadFile 合法域名;失败时用昵称首字母占位 +- **文本截断**:`ctx.measureText` 超宽时截断加「…」 + +## 适用角色 + +小程序开发工程师 + +## 升级 Skill + +miniprogram-dev SKILL 新增 §10 分享名片 diff --git a/.cursor/agent/小程序开发工程师/evolution/索引.md b/.cursor/agent/小程序开发工程师/evolution/索引.md index 31be5043..90b5d8fe 100644 --- a/.cursor/agent/小程序开发工程师/evolution/索引.md +++ b/.cursor/agent/小程序开发工程师/evolution/索引.md @@ -11,3 +11,4 @@ | 2026-03-10 | 管理端迁移 Mycontent-temp:关注内容产物格式与阅读页解析兼容回归 | [2026-03-10.md](./2026-03-10.md) | | 2026-03-12 | 链接标签 mpKey、@ 人物 token 兑换:contentParser、onLinkTagTap、onMentionTap | [2026-03-12.md](./2026-03-12.md) | | 2026-03-14 | 我的页设置入口隐藏;资料修改引导场景梳理(登录后、@某人、找伙伴、链接卡若) | [2026-03-14.md](./2026-03-14.md) | +| 2026-03-16 | 编辑资料页分享名片:转发/朋友圈特殊处理,Canvas 绘制封面,标题「昵称+为您分享名片」 | [2026-03-16.md](./2026-03-16.md) | diff --git a/.cursor/agent/开发助理/经验清单.md b/.cursor/agent/开发助理/经验清单.md index d8d37db9..641b6dce 100644 --- a/.cursor/agent/开发助理/经验清单.md +++ b/.cursor/agent/开发助理/经验清单.md @@ -46,6 +46,7 @@ | 2026-03-16 | 软件测试 | 目录约定 | testing SKILL §5 | scripts/test:miniapp 小程序接口测试、web 管理端测试;测试工程师在此编写用例 | | 2026-03-16 | 软件测试 | 目录约定 | testing SKILL §5 | scripts/test/process:流程测试,跨端多接口串联(下单→支付→分润等) | | 2026-03-16 | 软件测试 | 配置约定 | testing SKILL | pytest 架构、配置从 soul-api/.env* 读取、SOUL_TEST_ENV 必显;运行前报告头部显示测试环境,避免误测正式库 | +| 2026-03-16 | 小程序 | 最佳实践 | miniprogram-dev SKILL §10 | 编辑资料页分享名片:转发/朋友圈特殊处理,Canvas 绘制 5:4 封面,标题「昵称+为您分享名片」,路径 member-detail | --- @@ -56,4 +57,4 @@ --- -**最后更新**:2026-03-14(排名算法修正、设置隐藏、资料引导梳理) +**最后更新**:2026-03-16(编辑资料页分享名片) diff --git a/.cursor/agent/开发助理/项目索引/小程序.md b/.cursor/agent/开发助理/项目索引/小程序.md index a0f78ccc..f407c414 100644 --- a/.cursor/agent/开发助理/项目索引/小程序.md +++ b/.cursor/agent/开发助理/项目索引/小程序.md @@ -32,6 +32,7 @@ | 2025-03-14 | 阅读页文本长按选中复制:text 组件 user-select,已升级 SKILL §10 | 已完成 | | 2026-03-14 | 我的页设置入口隐藏(wx:if);资料修改引导场景梳理(登录后、@某人、找伙伴、链接卡若) | 已完成 | | 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 | +| 2026-03-16 | 编辑资料页分享名片:转发/朋友圈改为分享名片,Canvas 封面(头像+昵称+四栏信息),路径 member-detail | 已完成 | > **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置 diff --git a/.cursor/skills/miniprogram-dev/SKILL.md b/.cursor/skills/miniprogram-dev/SKILL.md index 2caeda60..ed73dec1 100644 --- a/.cursor/skills/miniprogram-dev/SKILL.md +++ b/.cursor/skills/miniprogram-dev/SKILL.md @@ -92,7 +92,19 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑 --- -## 10. 何时使用本 Skill +## 10. 分享名片(编辑资料页) + +- **场景**:编辑资料页转发/朋友圈做特殊处理,直接变成分享名片。 +- **标题**:`昵称+为您分享名片`(如「少年梦想家为您分享名片」)。 +- **封面**:Canvas 绘制 5:4 封面图,布局:左头像(圆形)+ 右昵称 +「个人名片」副标题 + 分隔线 + 四栏信息(地区、MBTI、行业、职位)。 +- **路径**:`member-detail?id=userId`,好友打开即见名片详情。 +- **预生成**:资料加载完成后、头像更新后调用 `generateShareCard()`,将 `shareCardPath` 存入 data;`onShareAppMessage`/`onShareTimeline` 返回 `imageUrl: shareCardPath`。 +- **朋友圈重定向**:分享带 `id` 时,`profile-edit` 的 `onLoad` 检测 `options.id` 即 `redirectTo` 到 `member-detail`。 +- **头像**:网络头像需 `wx.downloadFile`,域名须配置 downloadFile 合法域名;失败时用昵称首字母占位。 + +--- + +## 11. 何时使用本 Skill - 在 **miniprogram/** 下新增或修改页面、组件、utils 时。 - 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。 @@ -101,5 +113,6 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑 - 做表单、input/textarea 样式时(遵循 §6,用 view 包裹,padding 写在 view 上)。 - 做个人中心、设置页布局时(遵循 §7,卡片区边距 16rpx)。 - 做阅读、文章等需长按复制的文本时(遵循 §9,text 加 user-select)。 +- 做编辑资料页分享名片时(遵循 §10)。 遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。 diff --git a/miniprogram/pages/profile-edit/profile-edit.js b/miniprogram/pages/profile-edit/profile-edit.js index 1fb7c0dc..8bf0900e 100644 --- a/miniprogram/pages/profile-edit/profile-edit.js +++ b/miniprogram/pages/profile-edit/profile-edit.js @@ -104,30 +104,28 @@ Page({ goBack() { getApp().goBackOrToHome() }, - // 生成分享名片封面图(头像+昵称,5:4 比例) + // 生成分享名片封面图(参考:头像左+昵称右,分隔线,四栏信息 5:4) async generateShareCard() { - const { avatar, nickname } = this.data + const { avatar, nickname, region, mbti, industry, position } = this.data const userId = app.globalData.userInfo?.id if (!userId) return try { const ctx = wx.createCanvasContext('shareCardCanvas', this) const w = 500 const h = 400 - // 背景渐变(Soul 深色风格) + const pad = 32 + // 背景(深灰卡片感) const grd = ctx.createLinearGradient(0, 0, w, h) - grd.addColorStop(0, '#0F172A') - grd.addColorStop(0.5, '#050B14') + grd.addColorStop(0, '#1E293B') grd.addColorStop(1, '#0F172A') ctx.setFillStyle(grd) ctx.fillRect(0, 0, w, h) - // 顶部装饰条 - ctx.setFillStyle('#5EEAD4') - ctx.fillRect(0, 0, w, 4) - // 头像:居中偏上,圆形 120px - const avatarSize = 120 - const avatarX = (w - avatarSize) / 2 - const avatarY = 80 + // 顶部区域:左头像 + 右昵称 + const avatarSize = 100 + const avatarX = pad + 10 + const avatarY = 50 const avatarRadius = avatarSize / 2 + const rightStart = avatarX + avatarSize + 28 const drawAvatar = () => new Promise((resolve) => { if (avatar && avatar.startsWith('http')) { wx.downloadFile({ @@ -156,22 +154,61 @@ Page({ } }) await drawAvatar() - // 头像外圈描边 - ctx.setStrokeStyle('#5EEAD4') - ctx.setLineWidth(3) + ctx.setStrokeStyle('rgba(94,234,212,0.5)') + ctx.setLineWidth(2) ctx.beginPath() ctx.arc(avatarX + avatarRadius, avatarY + avatarRadius, avatarRadius, 0, Math.PI * 2) ctx.stroke() - // 昵称 + // 右侧:昵称 + 个人名片 const displayName = (nickname || '').trim() || '创业者' ctx.setFillStyle('#ffffff') - ctx.setFontSize(24) - ctx.setTextAlign('center') - ctx.fillText(displayName, w / 2, avatarY + avatarSize + 50) - // 副标题 - ctx.setFillStyle('rgba(94,234,212,0.9)') - ctx.setFontSize(14) - ctx.fillText('Soul创业派对 · 名片', w / 2, avatarY + avatarSize + 78) + ctx.setFontSize(26) + ctx.setTextAlign('left') + ctx.fillText(displayName, rightStart, avatarY + 36) + ctx.setFillStyle('#94A3B8') + ctx.setFontSize(13) + ctx.fillText('个人名片', rightStart, avatarY + 62) + // 分隔线 + const divY = 168 + ctx.setStrokeStyle('rgba(255,255,255,0.08)') + ctx.setLineWidth(1) + ctx.beginPath() + ctx.moveTo(pad, divY) + ctx.lineTo(w - pad, divY) + ctx.stroke() + // 底部四栏:地区 | MBTI,行业 | 职位 + const labelGray = '#64748B' + const valueWhite = '#F1F5F9' + const rowH = 52 + const colW = (w - pad * 2) / 2 + const truncate = (text, maxW) => { + if (!text) return '' + ctx.setFontSize(15) + let m = ctx.measureText(text) + if (m.width <= maxW) return text + for (let i = text.length - 1; i > 0; i--) { + const t = text.slice(0, i) + '…' + if (ctx.measureText(t).width <= maxW) return t + } + return text[0] + '…' + } + const maxValW = colW - 16 + const items = [ + { label: '地区', value: truncate((region || '').trim() || '未填写', maxValW), x: pad }, + { label: 'MBTI', value: (mbti || '').trim() || '未填写', x: pad + colW }, + { label: '行业', value: truncate((industry || '').trim() || '未填写', maxValW), x: pad }, + { label: '职位', value: truncate((position || '').trim() || '未填写', maxValW), x: pad + colW }, + ] + items.forEach((item, i) => { + const row = Math.floor(i / 2) + const baseY = divY + 36 + row * rowH + ctx.setFillStyle(labelGray) + ctx.setFontSize(12) + ctx.fillText(item.label, item.x, baseY - 8) + ctx.setFillStyle(valueWhite) + ctx.setFontSize(15) + ctx.fillText(item.value, item.x, baseY + 14) + }) ctx.draw(true, () => { wx.canvasToTempFilePath({ canvasId: 'shareCardCanvas', @@ -188,14 +225,14 @@ Page({ }, drawAvatarPlaceholder(ctx, x, y, size, nickname) { - ctx.setFillStyle('rgba(94,234,212,0.25)') + ctx.setFillStyle('rgba(94,234,212,0.2)') ctx.beginPath() ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2) ctx.fill() ctx.setFillStyle('#5EEAD4') - ctx.setFontSize(size * 0.45) + ctx.setFontSize(size * 0.42) ctx.setTextAlign('center') - ctx.fillText((nickname || '?')[0], x + size / 2, y + size / 2 + size * 0.15) + ctx.fillText((nickname || '?')[0], x + size / 2, y + size / 2 + size * 0.14) }, onShareAppMessage() { diff --git a/scripts/test/process/test_backfill_persons_ckb_api_key.py b/scripts/test/process/test_backfill_persons_ckb_api_key.py new file mode 100644 index 00000000..b906e54c --- /dev/null +++ b/scripts/test/process/test_backfill_persons_ckb_api_key.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +""" +流程测试:从存客宝获取所有计划的 apiKey,补齐本地 persons.ckb_api_key + +场景:persons 表有 ckb_plan_id 但 ckb_api_key 为空时,调用存客宝 plan/detail 获取 apiKey 并更新本地 + +前置条件: +- 测试环境(SOUL_TEST_ENV=souldev 或 local) +- soul-api 可连通,CKB_OPEN_API_KEY、CKB_OPEN_ACCOUNT 已配置(soul-api/.env) +""" +import hashlib +import time + +import pytest +import requests + +from config import SOUL_API_ENV +from util import admin_headers + +CKB_OPEN_BASE = "https://ckbapi.quwanzhi.com" + + +def _ckb_open_sign(account: str, ts: int, api_key: str) -> str: + """存客宝开放 API 签名:sign = MD5(MD5(account+timestamp) + apiKey)""" + plain = account + str(ts) + first = hashlib.md5(plain.encode()).hexdigest() + return hashlib.md5((first + api_key).encode()).hexdigest() + + +def _ckb_get_token(api_key: str, account: str) -> str: + """获取存客宝开放 API JWT""" + ts = int(time.time()) + sign = _ckb_open_sign(account, ts, api_key) + r = requests.post( + f"{CKB_OPEN_BASE}/v1/open/auth/token", + json={"apiKey": api_key, "account": account, "timestamp": ts, "sign": sign}, + timeout=15, + ) + data = r.json() + if data.get("code") != 200: + raise RuntimeError(f"存客宝鉴权失败: {data.get('message', r.text)}") + token = (data.get("data") or {}).get("token") + if not token: + raise RuntimeError("存客宝返回无 token") + return token + + +def _ckb_get_plan_api_key(token: str, plan_id: int) -> str: + """调用 plan/detail 获取计划级 apiKey""" + r = requests.get( + f"{CKB_OPEN_BASE}/v1/plan/detail", + params={"planId": plan_id}, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + timeout=15, + ) + data = r.json() + if data.get("code") != 200: + raise RuntimeError(f"获取计划详情失败 planId={plan_id}: {data.get('message', r.text)}") + api_key = (data.get("data") or {}).get("apiKey") + if not api_key: + raise RuntimeError(f"计划 {plan_id} 详情中无 apiKey") + return api_key + + +def _load_ckb_config() -> tuple[str, str]: + """从 soul-api/.env 加载 CKB 配置""" + def _parse_env(path): + out = {} + if not path.exists(): + return out + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"').strip("'") + return out + + for name in [".env", ".env.development", ".env.production"]: + env_path = SOUL_API_ENV / name + loaded = _parse_env(env_path) + api_key = (loaded.get("CKB_OPEN_API_KEY") or "").strip() + account = (loaded.get("CKB_OPEN_ACCOUNT") or "").strip() + if api_key and account: + return api_key, account + return "", "" + + +def test_backfill_persons_ckb_api_key(admin_token, base_url): + """ + 从存客宝获取所有计划的 apiKey,补齐本地 persons.ckb_api_key 为空的记录 + """ + if not admin_token: + pytest.skip("admin 登录失败,跳过") + + ckb_api_key, ckb_account = _load_ckb_config() + if not ckb_api_key or not ckb_account: + pytest.skip("CKB_OPEN_API_KEY 或 CKB_OPEN_ACCOUNT 未配置,跳过") + + # 1. 拉取 persons 列表 + r = requests.get( + f"{base_url}/api/db/persons", + headers=admin_headers(admin_token), + timeout=10, + ) + assert r.status_code == 200, f"拉取 persons 失败: {r.text}" + data = r.json() + assert data.get("success") is True, f"拉取 persons 失败: {data}" + persons = data.get("persons") or [] + + # 2. 筛选需要补全的:ckb_plan_id > 0 且 ckb_api_key 为空 + need_backfill = [ + p + for p in persons + if (p.get("ckbPlanId") or 0) > 0 + and not (p.get("ckbApiKey") or "").strip() + ] + + if not need_backfill: + pytest.skip("无需要补全 ckb_api_key 的 Person,跳过") + + # 3. 获取存客宝 JWT + ckb_token = _ckb_get_token(ckb_api_key, ckb_account) + + # 4. 逐个补全 + updated = 0 + failed = [] + for p in need_backfill: + plan_id = p.get("ckbPlanId") or 0 + person_id = p.get("personId") or "" + name = p.get("name") or "" + try: + api_key = _ckb_get_plan_api_key(ckb_token, plan_id) + except Exception as e: + failed.append((name, str(e))) + continue + + # 5. 调用 soul-api 更新 Person(POST 带 personId 为更新,传完整字段避免覆盖) + payload = { + "personId": person_id, + "name": name, + "label": (p.get("label") or ""), + "ckbApiKey": api_key, + "greeting": (p.get("greeting") or ""), + "tips": (p.get("tips") or ""), + "remarkType": (p.get("remarkType") or ""), + "remarkFormat": (p.get("remarkFormat") or ""), + "startTime": (p.get("startTime") or "09:00"), + "endTime": (p.get("endTime") or "18:00"), + } + if p.get("addFriendInterval"): + payload["addFriendInterval"] = p["addFriendInterval"] + r_update = requests.post( + f"{base_url}/api/db/persons", + headers=admin_headers(admin_token), + json=payload, + timeout=15, + ) + if r_update.status_code == 200 and r_update.json().get("success"): + updated += 1 + else: + failed.append((name, r_update.text or "更新失败")) + + assert not failed, f"补全失败: {failed}" + assert updated > 0, f"应至少补全 1 条,实际补全 {updated} 条" + print(f"\n[backfill] 成功补全 {updated} 条 persons.ckb_api_key") diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index 296f8bd6..e71e26dd 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -26,6 +26,7 @@ import { DialogHeader, DialogTitle, DialogFooter, + DialogDescription, } from '@/components/ui/dialog' import { BookOpen, @@ -46,6 +47,7 @@ import { ExternalLink, Pencil, Smartphone, + Copy, } from 'lucide-react' import { LinkedMpPage } from '@/pages/linked-mp/LinkedMpPage' import { get, put, post, del, SAVE_REQUEST_TIMEOUT } from '@/api/client' @@ -125,120 +127,6 @@ interface EditingSection { editionPremium?: boolean } -/** 去除名字中的括号及内容,名字不带符号(如 南风(管理) → 南风) */ -function sanitizeNameOrLabel(s: string): string { - return s.replace(/\s*[((][^))]*(\)|))?/g, '').trim() -} - -// 在保存前自动把纯文本中的 @用户 / #标签 转成带 token / 配置的节点 -function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagItem[]): string { - if (!html || (!html.includes('@') && !html.includes('#'))) return html - if (typeof document === 'undefined') return html - - const container = document.createElement('div') - container.innerHTML = html - - const matchPerson = (name: string): PersonItem | undefined => - persons.find((p) => p.name === name || sanitizeNameOrLabel(p.name) === sanitizeNameOrLabel(name)) - - const matchTag = (label: string): LinkTagItem | undefined => - linkTags.find((t) => t.label === label || sanitizeNameOrLabel(t.label) === sanitizeNameOrLabel(label)) - - const processTextNode = (node: Text) => { - const text = node.textContent || '' - if (!text || (!text.includes('@') && !text.includes('#'))) return - - const parent = node.parentNode - if (!parent) return - - const frag = document.createDocumentFragment() - // 排除 <> 避免把 HTML 标签带入 @/# 匹配(如 @远志 只匹配 @远志) - const regex = /(@[^\s@#<>]+|#[^\s@#<>]+)/g - let lastIndex = 0 - let match: RegExpExecArray | null - - // eslint-disable-next-line no-cond-assign - while ((match = regex.exec(text))) { - const [full] = match - const index = match.index - if (index > lastIndex) { - frag.appendChild(document.createTextNode(text.slice(lastIndex, index))) - } - - if (full.startsWith('@')) { - const rawName = full.slice(1) - const person = matchPerson(rawName) - if (person) { - const span = document.createElement('span') - span.setAttribute('data-type', 'mention') - span.setAttribute('data-id', person.id) - span.className = 'mention-tag' - span.textContent = `@${person.name}` - frag.appendChild(span) - } else { - frag.appendChild(document.createTextNode(full)) - } - } else if (full.startsWith('#')) { - const rawLabel = full.slice(1) - const tag = matchTag(rawLabel) - if (tag) { - const span = document.createElement('span') - span.setAttribute('data-type', 'linkTag') - span.setAttribute('data-url', tag.url || '') - span.setAttribute('data-tag-type', tag.type || 'url') - span.setAttribute('data-tag-id', tag.id || '') - span.setAttribute('data-page-path', tag.pagePath || '') - span.setAttribute('data-app-id', tag.appId || '') - if (tag.type === 'miniprogram' && tag.appId) { - span.setAttribute('data-mp-key', tag.appId) - } - span.className = 'link-tag-node' - span.textContent = `#${tag.label}` - frag.appendChild(span) - } else { - frag.appendChild(document.createTextNode(full)) - } - } else { - frag.appendChild(document.createTextNode(full)) - } - - lastIndex = index + full.length - } - - if (lastIndex < text.length) { - frag.appendChild(document.createTextNode(text.slice(lastIndex))) - } - - parent.replaceChild(frag, node) - } - - const walk = (node: Node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as HTMLElement - const dataType = el.getAttribute('data-type') - // mention 节点:若 data-id 为空,按昵称匹配 persons 回填(修复 TipTap 插入时 id 丢失导致小程序 data-user-id 为空) - if (dataType === 'mention') { - const existingId = el.getAttribute('data-id') - const nickname = (el.getAttribute('data-label') || el.textContent || '').replace(/^@/, '').trim() - if ((!existingId || !existingId.trim()) && nickname) { - const person = matchPerson(nickname) - if (person?.id) el.setAttribute('data-id', person.id) - } - return - } - if (dataType === 'linkTag') return - node.childNodes.forEach((child) => walk(child)) - return - } - if (node.nodeType === Node.TEXT_NODE) { - processTextNode(node as Text) - } - } - - container.childNodes.forEach((n) => walk(n)) - return container.innerHTML -} - function buildTree(sections: SectionListItem[]): Part[] { const partMap = new Map< string, @@ -358,6 +246,7 @@ export function ContentPage() { const [linkTags, setLinkTags] = useState([]) const [personModalOpen, setPersonModalOpen] = useState(false) const [editingPerson, setEditingPerson] = useState(null) + const [personToDelete, setPersonToDelete] = useState(null) const [newLinkTag, setNewLinkTag] = useState({ tagId: '', label: '', @@ -603,73 +492,6 @@ export function ContentPage() { } }, []) - /** 文章编辑时自动创建不存在的 @人物 和 #标签,返回合并后的列表供 autoLinkContent 使用 */ - const ensureMentionsAndTags = useCallback( - async (content: string): Promise<{ persons: PersonItem[]; linkTags: LinkTagItem[] }> => { - // 排除 <> 避免把 HTML 标签带入 @/# 匹配 - const regex = /(@[^\s@#<>]+|#[^\s@#<>]+)/g - const names = new Set() - const labels = new Set() - let m: RegExpExecArray | null - while ((m = regex.exec(content)) !== null) { - const full = m[0] - if (full.startsWith('@')) names.add(sanitizeNameOrLabel(full.slice(1))) - else if (full.startsWith('#')) labels.add(sanitizeNameOrLabel(full.slice(1))) - } - let personsCopy = [...persons] - let linkTagsCopy = [...linkTags] - for (const name of names) { - if (!name || personsCopy.some((p) => p.name === name || sanitizeNameOrLabel(p.name) === name)) continue - try { - const res = await post<{ - success?: boolean - person?: { token: string; personId: string; name: string; ckbPlanId?: number } - }>('/api/db/persons', { name }) - if (res?.success && res.person) { - personsCopy = [ - ...personsCopy, - { - id: res.person.token, - personId: res.person.personId, - name: res.person.name, - ckbPlanId: res.person.ckbPlanId, - } as PersonItem, - ] - } - } catch { - /* ignore */ - } - } - for (const label of labels) { - if (!label || linkTagsCopy.some((t) => t.label === label || sanitizeNameOrLabel(t.label) === label)) continue - try { - const res = await post<{ - success?: boolean - linkTag?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string } - }>('/api/db/link-tags', { label }) - if (res?.success && res.linkTag) { - const t = res.linkTag - linkTagsCopy = [ - ...linkTagsCopy, - { - id: t.tagId, - label: t.label, - url: t.url || '', - type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb', - appId: t.appId || '', - pagePath: t.pagePath || '', - }, - ] - } - } catch { - /* ignore */ - } - } - return { persons: personsCopy, linkTags: linkTagsCopy } - }, - [persons, linkTags], - ) - const [linkedMps, setLinkedMps] = useState<{ key: string; name: string; appId: string; path?: string }[]>([]) const [mpSearchQuery, setMpSearchQuery] = useState('') const [mpDropdownOpen, setMpDropdownOpen] = useState(false) @@ -826,8 +648,6 @@ export function ContentPage() { setIsSaving(true) try { let content = editingSection.content || '' - const { persons: p, linkTags: lt } = await ensureMentionsAndTags(content) - content = autoLinkContent(content, p, lt) const titlePatterns = [ new RegExp(`^#+\\s*${editingSection.id.replace('.', '\\.')}\\s+.*$`, 'gm'), new RegExp(`^#+\\s*${editingSection.id.replace('.', '\\.')}[::].*$`, 'gm'), @@ -886,14 +706,13 @@ export function ContentPage() { try { const currentPart = tree.find((p) => p.id === newSection.partId) const currentChapter = currentPart?.chapters.find((c) => c.id === newSection.chapterId) - const { persons: p, linkTags: lt } = await ensureMentionsAndTags(newSection.content || '') const res = await put<{ success?: boolean; error?: string }>( '/api/db/book', { id: newSection.id, title: newSection.title, price: newSection.isFree ? 0 : newSection.price, - content: autoLinkContent(newSection.content || '', p, lt), + content: newSection.content || '', partId: newSection.partId, partTitle: currentPart?.title ?? '', chapterId: newSection.chapterId, @@ -2426,94 +2245,129 @@ export function ContentPage() {

添加人物时同步创建存客宝场景获客计划,配置与存客宝 API 获客一致

- +
+ + +
-
- {persons.length > 0 && ( -
- token - @的人 - 获客计划活动名 -
+
+ {persons.length > 0 ? ( + + + + + + + + + + + + + {persons.map(p => ( + + + + + + + + + ))} + +
token@的人获客计划活动名planIdapiKey操作
{p.id}{p.name}SOUL链接人与事-{p.name}{p.ckbPlanId ?? '-'} +
+ {p.ckbApiKey ? ( + + ) : null} + {p.ckbApiKey ?? '-'} +
+
+
+ + + +
+
+ ) : ( +
暂无AI人物,添加后可在编辑器中 @链接
)} - {persons.map(p => ( -
-
- {p.id} - {p.name} - SOUL链接人与事-{p.name} -
-
- - - -
-
- ))} - {persons.length === 0 &&
暂无AI人物,添加后可在编辑器中 @链接
}
@@ -2574,7 +2428,8 @@ export function ContentPage() {

小程序端点击 #标签 可直接跳转对应链接,进入流量池

-
+
+
setNewLinkTag({ ...newLinkTag, tagId: e.target.value })} /> @@ -2680,6 +2535,16 @@ export function ContentPage() { {editingLinkTagId ? '保存' : '添加'} +
+
{linkTags.map((t) => ( @@ -2824,6 +2689,42 @@ export function ContentPage() { } }} /> + + {/* 删除人物二次确认弹窗 */} + { if (!open) setPersonToDelete(null) }}> + + + 确认删除 + + {personToDelete && ( + <> +

确定删除「SOUL链接人与事-{personToDelete.name}」?将同时删除存客宝对应获客计划。

+

二次确认:删除后无法恢复,文章中的 @{personToDelete.name} 将无法正常跳转。

+ + )} +
+
+ + + + +
+
) } diff --git a/soul-admin/src/pages/linked-mp/LinkedMpPage.tsx b/soul-admin/src/pages/linked-mp/LinkedMpPage.tsx index 1327c1c5..54eeb52e 100644 --- a/soul-admin/src/pages/linked-mp/LinkedMpPage.tsx +++ b/soul-admin/src/pages/linked-mp/LinkedMpPage.tsx @@ -20,7 +20,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { Smartphone, Plus, Pencil, Trash2 } from 'lucide-react' +import { Smartphone, Plus, Pencil, Trash2, RefreshCw } from 'lucide-react' import { get, post, put, del } from '@/api/client' interface LinkedMpItem { @@ -149,7 +149,16 @@ export function LinkedMpPage() { -
+
+