feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本

- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整
- soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新
- soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL
- .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle

Made-with: Cursor
This commit is contained in:
卡若
2026-03-23 18:38:23 +08:00
parent cb6e2bff56
commit fa3da12b16
82 changed files with 5621 additions and 2723 deletions

File diff suppressed because one or more lines are too long

965
soul-admin/dist/assets/index-CW7Mmh6Q.js vendored Normal file

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-etcBHhA9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DYq6N0y0.css">
<script type="module" crossorigin src="/assets/index-CW7Mmh6Q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CDCvtX8z.css">
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,9 @@ import {
Link2,
FileText,
Cloud,
Smile,
Eye,
EyeOff,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
@@ -72,6 +75,8 @@ interface MpConfig {
mchId?: string
minWithdraw?: number
auditMode?: boolean
/** 小程序界面文案与跳转,与 soul-api defaultMpUi 结构一致,服务端会与默认值深合并 */
mpUi?: Record<string, unknown>
}
interface OssConfig {
@@ -113,6 +118,39 @@ const defaultFeatures: FeatureConfig = {
aboutEnabled: true,
}
/** 与管理端保存后、后端 deepMergeMpUi 的默认结构对齐,供「填入模板」与文档说明 */
const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
tabBar: { home: '首页', chapters: '目录', match: '找伙伴', my: '我的' },
chaptersPage: {
bookTitle: '一场SOUL的创业实验场',
bookSubtitle: '来自Soul派对房的真实商业故事',
},
homePage: {
logoTitle: '卡若创业派对',
logoSubtitle: '来自派对房的真实故事',
linkKaruoText: '点击链接卡若',
searchPlaceholder: '搜索章节标题或内容...',
bannerTag: '推荐',
bannerReadMoreText: '点击阅读',
superSectionTitle: '超级个体',
superSectionLinkText: '获客入口',
superSectionLinkPath: '/pages/match/match',
pickSectionTitle: '精选推荐',
latestSectionTitle: '最新新增',
},
myPage: {
cardLabel: '名片',
vipLabelVip: '会员中心',
vipLabelGuest: '成为会员',
cardPath: '',
vipPath: '/pages/vip/vip',
readStatLabel: '已读章节',
recentReadTitle: '最近阅读',
readStatPath: '/pages/reading-records/reading-records?focus=all',
recentReadPath: '/pages/reading-records/reading-records?focus=recent',
},
}
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
type TabKey = (typeof TAB_KEYS)[number]
@@ -124,6 +162,7 @@ export function SettingsPage() {
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [mpUiJson, setMpUiJson] = useState('{}')
const [ossConfig, setOssConfig] = useState<OssConfig>({})
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
@@ -133,6 +172,39 @@ export function SettingsPage() {
const [dialogIsError, setDialogIsError] = useState(false)
const [featureSwitchSaving, setFeatureSwitchSaving] = useState(false)
const MBTI_TYPES = [
'INTJ','INTP','ENTJ','ENTP',
'INFJ','INFP','ENFJ','ENFP',
'ISTJ','ISFJ','ESTJ','ESFJ',
'ISTP','ISFP','ESTP','ESFP',
]
const [mbtiAvatars, setMbtiAvatars] = useState<Record<string, string>>({})
const [mbtiLoading, setMbtiLoading] = useState(false)
const [mbtiSaving, setMbtiSaving] = useState(false)
const loadMbtiAvatars = async () => {
setMbtiLoading(true)
try {
const res = await get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
if (res?.avatars) setMbtiAvatars(res.avatars)
} catch { /* ignore */ }
finally { setMbtiLoading(false) }
}
const saveMbtiAvatars = async () => {
setMbtiSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/mbti-avatars', { avatars: mbtiAvatars })
if (!res || res.success === false) {
showResult('保存失败', res?.error ?? '未知错误', true)
return
}
showResult('已保存', 'MBTI 头像映射已保存,无头像的超级个体将自动使用对应性格头像。')
} catch (error) {
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
} finally { setMbtiSaving(false) }
}
const showResult = (title: string, message: string, isError = false) => {
setDialogTitle(title)
setDialogMessage(message)
@@ -153,8 +225,18 @@ export function SettingsPage() {
if (!res || (res as { success?: boolean }).success === false) return
if (res.featureConfig && Object.keys(res.featureConfig).length)
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
if (res.mpConfig && typeof res.mpConfig === 'object')
setMpConfig((prev) => ({ ...prev, ...res.mpConfig }))
if (res.mpConfig && typeof res.mpConfig === 'object') {
const merged = { ...res.mpConfig } as MpConfig
setMpConfig((prev) => ({ ...prev, ...merged }))
const raw = merged.mpUi
setMpUiJson(
JSON.stringify(
raw != null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {},
null,
2,
),
)
}
if (res.ossConfig && typeof res.ossConfig === 'object')
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
if (res.siteSettings && typeof res.siteSettings === 'object') {
@@ -175,6 +257,7 @@ export function SettingsPage() {
}
}
load()
loadMbtiAvatars()
}, [])
const saveFeatureConfigOnly = async (
@@ -235,6 +318,25 @@ export function SettingsPage() {
const handleSave = async () => {
setIsSaving(true)
try {
let mpUi: Record<string, unknown> = {}
try {
const t = mpUiJson.trim()
if (t) {
const parsed: unknown = JSON.parse(t)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
mpUi = parsed as Record<string, unknown>
} else {
showResult('保存失败', '小程序文案 mpUi 须为 JSON 对象(非数组)', true)
setIsSaving(false)
return
}
}
} catch {
showResult('保存失败', '小程序文案 mpUi 不是合法 JSON', true)
setIsSaving(false)
return
}
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
featureConfig,
siteSettings: {
@@ -251,6 +353,7 @@ export function SettingsPage() {
mchId: mpConfig.mchId || '',
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
auditMode: mpConfig.auditMode ?? false,
mpUi,
},
ossConfig: Object.keys(ossConfig).length
? {
@@ -599,6 +702,31 @@ export function SettingsPage() {
/>
</div>
</div>
<div className="space-y-2 pt-2 border-t border-gray-700/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-gray-300"> mpUiJSON</Label>
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-200"
onClick={() => setMpUiJson(JSON.stringify(MP_UI_TEMPLATE_OBJECT, null, 2))}
>
</Button>
</div>
<p className="text-xs text-gray-500">
Tab / 5
config
</p>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm min-h-[280px]"
spellCheck={false}
value={mpUiJson}
onChange={(e) => setMpUiJson(e.target.value)}
/>
</div>
</CardContent>
</Card>
@@ -804,6 +932,86 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Smile className="w-5 h-5 text-[#38bdac]" />
MBTI
</CardTitle>
<CardDescription className="text-gray-400">
16 MBTI URL使
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{mbtiLoading ? (
<p className="text-gray-500 text-sm">...</p>
) : (
<>
<div className="grid grid-cols-2 gap-3">
{MBTI_TYPES.map((t) => (
<div key={t} className="flex items-center gap-2">
<span className="text-xs text-[#38bdac] font-mono w-10 flex-shrink-0">{t}</span>
{mbtiAvatars[t] && (
<img src={mbtiAvatars[t]} alt={t} className="w-8 h-8 rounded-full flex-shrink-0 object-cover border border-gray-600" />
)}
<Input
className="bg-[#0a1628] border-gray-700 text-white text-xs h-8 flex-1"
placeholder="头像 URL"
value={mbtiAvatars[t] ?? ''}
onChange={(e) =>
setMbtiAvatars((prev) => ({ ...prev, [t]: e.target.value }))
}
/>
</div>
))}
</div>
<Button
onClick={saveMbtiAvatars}
disabled={mbtiSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
size="sm"
>
<Save className="w-3 h-3 mr-1" />
{mbtiSaving ? '保存中...' : '保存头像映射'}
</Button>
</>
)}
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Eye className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 text-xs">
{[
{ mod: '找伙伴', ctrl: '找伙伴功能开关', icon: <Users className="w-3 h-3" /> },
{ mod: '推广中心 / 推荐好友', ctrl: '推广功能开关', icon: <Gift className="w-3 h-3" /> },
{ mod: '搜索', ctrl: '搜索功能开关', icon: <BookOpen className="w-3 h-3" /> },
{ mod: '关于页面', ctrl: '关于页面开关', icon: <UserCircle className="w-3 h-3" /> },
{ mod: '支付 / VIP / 充值 / 收益', ctrl: '审核模式', icon: <ShieldCheck className="w-3 h-3" /> },
{ mod: '超级个体名片', ctrl: '审核模式', icon: <Smile className="w-3 h-3" /> },
{ mod: '首页获客入口', ctrl: '已移除', icon: <EyeOff className="w-3 h-3" /> },
].map((r) => (
<div key={r.mod} className="flex items-center gap-2 p-2 rounded bg-[#0a1628] border border-gray-700/30">
{r.icon}
<div>
<span className="text-white">{r.mod}</span>
<span className="text-gray-500 ml-1"> {r.ctrl}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>

View File

@@ -177,6 +177,13 @@ export function UsersPage() {
// ===== 用户旅程总览 =====
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
const [journeyLoading, setJourneyLoading] = useState(false)
const [journeyStage, setJourneyStage] = useState<string | null>(null)
const [journeyUsers, setJourneyUsers] = useState<{ id: string; nickname: string; phone: string; createdAt: string }[]>([])
const [journeyUsersLoading, setJourneyUsersLoading] = useState(false)
const [trackUserId, setTrackUserId] = useState<string | null>(null)
const [trackUserNick, setTrackUserNick] = useState('')
const [userTracks, setUserTracks] = useState<{ id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[]>([])
const [userTracksLoading, setUserTracksLoading] = useState(false)
// ===== 获客列表(存客宝) =====
const [leadsRecords, setLeadsRecords] = useState<{
@@ -599,6 +606,23 @@ export function UsersPage() {
if (data?.success && data.stats) setJourneyStats(data.stats)
} catch { } finally { setJourneyLoading(false) }
}, [])
const loadJourneyUsers = useCallback(async (stage: string) => {
setJourneyStage(stage)
setJourneyUsersLoading(true)
try {
const data = await get<{ success?: boolean; users?: { id: string; nickname: string; phone: string; createdAt: string }[] }>(`/api/db/users/journey-users?stage=${stage}&limit=50`)
if (data?.success && data.users) setJourneyUsers(data.users)
} catch { } finally { setJourneyUsersLoading(false) }
}, [])
const loadUserTracks = useCallback(async (userId: string, nick: string) => {
setTrackUserId(userId)
setTrackUserNick(nick)
setUserTracksLoading(true)
try {
const data = await get<{ success?: boolean; tracks?: { id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[] }>(`/api/db/users/tracks?userId=${userId}&limit=50`)
if (data?.success && data.tracks) setUserTracks(data.tracks)
} catch { } finally { setUserTracksLoading(false) }
}, [])
// ===== 批量用户补全 =====
const [batchEnrichLoading, setBatchEnrichLoading] = useState(false)
@@ -1124,13 +1148,8 @@ export function UsersPage() {
<div key={stage.id} className="relative flex flex-col items-center">
{/* 阶段卡片 */}
<div
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer hover:opacity-80 transition-opacity`}
onClick={() => {
const sp = new URLSearchParams(searchParams)
sp.delete('tab')
sp.set('search', stage.label)
setSearchParams(sp)
}}
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer hover:opacity-80 transition-opacity ${journeyStage === stage.id ? 'ring-2 ring-[#38bdac]' : ''}`}
onClick={() => loadJourneyUsers(stage.id)}
title={`点击查看「${stage.label}」阶段的用户`}
>
<div className="text-2xl mb-1">{stage.icon}</div>
@@ -1167,7 +1186,7 @@ export function UsersPage() {
</div>
<div className="space-y-2 text-sm">
{[
{ step: '① 注册', action: '微信 OAuth 或手机号注册', next: '引导填写头像' },
{ step: '① 注册', action: '微信 OAuth 或手机号注册', next: '提示设置头像与昵称' },
{ step: '② 浏览', action: '点击章节/阅读免费内容', next: '触发绑定手机' },
{ step: '③ 首付', action: '购买单章或全书', next: '推送分销功能' },
{ step: '④ VIP', action: '¥1980 购买全书', next: '进入 VIP 私域群' },
@@ -1217,12 +1236,95 @@ export function UsersPage() {
)}
</div>
</div>
{/* 阶段用户列表(点击阶段卡片后展开) */}
{journeyStage && (
<div className="mt-6 bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-[#38bdac]" />
<span className="text-white font-medium">
{JOURNEY_STAGES.find(s => s.id === journeyStage)?.icon}{' '}
{JOURNEY_STAGES.find(s => s.id === journeyStage)?.label}
</span>
<Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-xs">{journeyUsers.length} </Badge>
</div>
<Button variant="ghost" size="sm" onClick={() => setJourneyStage(null)} className="text-gray-400 hover:text-white"><X className="w-4 h-4" /></Button>
</div>
{journeyUsersLoading ? (
<div className="flex items-center justify-center py-8"><RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" /></div>
) : journeyUsers.length === 0 ? (
<p className="text-gray-500 text-center py-6"></p>
) : (
<Table>
<TableHeader>
<TableRow className="border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{journeyUsers.map(u => (
<TableRow key={u.id} className="border-gray-700/50 hover:bg-[#0a1628]">
<TableCell className="text-white">{u.nickname || '微信用户'}</TableCell>
<TableCell className="text-gray-300">{u.phone || '-'}</TableCell>
<TableCell className="text-gray-400 text-xs">{u.createdAt ? new Date(u.createdAt).toLocaleString('zh-CN') : '-'}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" className="text-[#38bdac] hover:bg-[#38bdac]/10" onClick={() => loadUserTracks(u.id, u.nickname || '微信用户')}>
<Eye className="w-4 h-4 mr-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)}
{/* 用户行为轨迹弹窗 */}
<Dialog open={!!trackUserId} onOpenChange={(open) => { if (!open) setTrackUserId(null) }}>
<DialogContent className="sm:max-w-[600px] bg-[#0f2137] border-gray-700 text-white max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Navigation className="w-5 h-5 text-[#38bdac]" />
{trackUserNick}
</DialogTitle>
</DialogHeader>
{userTracksLoading ? (
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /></div>
) : userTracks.length === 0 ? (
<p className="text-gray-500 text-center py-8"></p>
) : (
<div className="relative pl-6 space-y-0">
<div className="absolute left-[11px] top-2 bottom-2 w-0.5 bg-gray-700" />
{userTracks.map((t, idx) => (
<div key={t.id || idx} className="relative flex items-start gap-3 py-2">
<div className="absolute left-[-13px] top-3 w-2.5 h-2.5 rounded-full bg-[#38bdac] border-2 border-[#0f2137] z-10" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-white text-sm font-medium">{t.actionLabel}</span>
{t.module && <Badge className="bg-purple-500/10 text-purple-400 border border-purple-500/30 text-[10px]">{t.module}</Badge>}
</div>
{(t.chapterTitle || t.target) && (
<p className="text-gray-400 text-xs mt-0.5 truncate">{t.chapterTitle || t.target}</p>
)}
<p className="text-gray-600 text-[10px] mt-0.5">{t.timeAgo} · {t.createdAt ? new Date(t.createdAt).toLocaleString('zh-CN') : ''}</p>
</div>
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
</TabsContent>
{/* ===== 规则配置 ===== */}
<TabsContent value="rules">
<div className="mb-4 flex items-center justify-between">
<p className="text-gray-400 text-sm"></p>
<p className="text-gray-400 text-sm"></p>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadRules} disabled={rulesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${rulesLoading ? 'animate-spin' : ''}`} />
@@ -1244,23 +1346,26 @@ export function UsersPage() {
) : (
<div className="space-y-2">
{rules.map((rule) => (
<div key={rule.id} className={`p-4 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<PenLine className="w-4 h-4 text-[#38bdac] shrink-0" />
<span className="text-white font-medium">{rule.title}</span>
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-xs">{rule.trigger}</Badge>}
<Badge className={`text-xs border-0 ${rule.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}`}>{rule.enabled ? '启用' : '禁用'}</Badge>
</div>
{rule.description && <p className="text-gray-400 text-sm ml-6">{rule.description}</p>}
<div key={rule.id} className={`p-3 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-gray-600 text-xs font-mono w-5 shrink-0 text-right">#{rule.sort}</span>
<PenLine className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
<span className="text-white font-medium text-sm truncate">{rule.title}</span>
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-[10px] shrink-0">{rule.trigger}</Badge>}
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
<div className="flex items-center gap-1.5 ml-3 shrink-0">
<Switch checked={rule.enabled} onCheckedChange={() => handleToggleRule(rule)} />
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10"><Trash2 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"><Edit3 className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 w-7 p-0"><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</div>
{rule.description && (
<details className="ml-[52px] mt-1">
<summary className="text-gray-500 text-xs cursor-pointer hover:text-gray-400 select-none"></summary>
<p className="text-gray-400 text-sm mt-1 pl-1 border-l-2 border-gray-700">{rule.description}</p>
</details>
)}
</div>
))}
</div>