Merge branch 'devlop' into yongxu-dev

# Conflicts:
#	miniprogram/app.js   resolved by devlop version
#	miniprogram/pages/chapters/chapters.js   resolved by devlop version
#	miniprogram/pages/match/match.js   resolved by devlop version
#	miniprogram/pages/member-detail/member-detail.js   resolved by devlop version
#	miniprogram/pages/my/my.js   resolved by devlop version
#	miniprogram/pages/read/read.js   resolved by devlop version
#	miniprogram/pages/referral/referral.js   resolved by devlop version
#	soul-api/internal/model/person.go   resolved by devlop version
This commit is contained in:
Alex-larget
2026-03-24 15:44:56 +08:00
127 changed files with 9196 additions and 3504 deletions

File diff suppressed because one or more lines are too long

1006
soul-admin/dist/assets/index-Dk6CvQRe.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-Dk6CvQRe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qjssBjc3.css">
</head>
<body>
<div id="root"></div>

View File

@@ -37,9 +37,11 @@ export interface LinkTagItem {
label: string
aliases?: string
url: string
type: 'url' | 'miniprogram' | 'ckb'
type: 'url' | 'miniprogram' | 'ckb' | 'wxlink'
appId?: string
pagePath?: string
/** 管理端列表用:库内是否已存目标小程序 AppSecret接口不下发明文 */
hasAppSecret?: boolean
}
/** 插入附件 HTML 时转义,防 XSS */

View File

@@ -0,0 +1,173 @@
import { useState, useEffect, useCallback } from 'react'
import { get, post } from '@/api/client'
import toast from '@/utils/toast'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Smile, Save, RefreshCw, WandSparkles } from 'lucide-react'
import {
MBTI_TYPES_ORDERED,
MBTI_AVATAR_PROFILES,
buildMbtiSvgAvatarDataUrl,
type MbtiType,
} from '@/lib/mbtiAvatarPrompts'
export function MbtiAvatarsManager() {
const [avatars, setAvatars] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [generating, setGenerating] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
if (res?.avatars) setAvatars(res.avatars)
else setAvatars({})
} catch {
toast.error('加载 MBTI 头像配置失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const save = async () => {
setSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/mbti-avatars', { avatars })
if (!res || res.success === false) {
toast.error(res?.error || '保存失败')
return
}
toast.success('已保存,后台与小程序默认头像同步生效')
load()
} catch {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
const generateOne = (type: MbtiType) => {
const dataUrl = buildMbtiSvgAvatarDataUrl(type)
setAvatars((prev) => ({ ...prev, [type]: dataUrl }))
toast.success(`${type} 已生成`)
}
const generateAll = () => {
setGenerating(true)
try {
const next = { ...avatars }
MBTI_TYPES_ORDERED.forEach((t) => {
next[t] = buildMbtiSvgAvatarDataUrl(t)
})
setAvatars(next)
toast.success('16 型头像已生成(仅人物)')
} finally {
setGenerating(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-gray-400">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-[#38bdac]" />
</div>
)
}
return (
<div className="space-y-4">
<Card className="bg-[#0f2137] border-[#38bdac]/25 shadow-xl">
<CardHeader className="pb-2">
<CardTitle className="text-white flex items-center gap-2 text-lg">
<Smile className="w-5 h-5 text-[#38bdac]" />
MBTI
</CardTitle>
<CardDescription className="text-gray-400 text-sm leading-relaxed">
MBTI
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button type="button" size="sm" className="bg-[#38bdac] hover:bg-[#2da396]" onClick={generateAll} disabled={generating}>
<WandSparkles className="w-3.5 h-3.5 mr-1" />
{generating ? '生成中…' : '一键生成16头像'}
</Button>
<Button type="button" size="sm" variant="outline" className="border-gray-600 text-gray-300" onClick={load}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
</Button>
<Button type="button" size="sm" className="bg-emerald-600 hover:bg-emerald-500" onClick={save} disabled={saving}>
<Save className="w-3.5 h-3.5 mr-1" />
{saving ? '保存中…' : '保存映射'}
</Button>
</CardContent>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{MBTI_TYPES_ORDERED.map((t) => {
const url = avatars[t] ?? ''
const meta = MBTI_AVATAR_PROFILES[t]
return (
<div
key={t}
className="rounded-xl border border-gray-700/60 bg-[#0a1628] p-3 flex flex-col gap-2 hover:border-[#38bdac]/35 transition-colors"
>
<div className="flex items-center gap-2">
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0 font-mono text-xs">{t}</Badge>
<span className="text-xs text-gray-400 truncate" title={meta.title}>
{meta.title}
</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-16 rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-[#081322] ring-2 ring-[#38bdac]/40 ring-offset-2 ring-offset-[#0a1628]">
{url ? (
<img src={url} alt={t} className="w-full h-full object-cover scale-110" />
) : (
<span className="text-gray-600 text-[10px]"></span>
)}
</div>
<div className="flex-1 min-w-0">
<Input
className="bg-[#162840] border-gray-700 text-white h-8 text-xs"
placeholder="https://... 或 data:image/..."
value={url}
onChange={(e) => setAvatars((prev) => ({ ...prev, [t]: e.target.value }))}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px] border-[#38bdac]/40 text-[#38bdac]"
onClick={() => generateOne(t)}
>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-[11px] text-gray-400"
onClick={() => setAvatars((prev) => ({ ...prev, [t]: '' }))}
>
</Button>
</div>
</div>
)
})}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
export const MBTI_TYPES_ORDERED = [
'INTJ',
'INTP',
'ENTJ',
'ENTP',
'INFJ',
'INFP',
'ENFJ',
'ENFP',
'ISTJ',
'ISFJ',
'ESTJ',
'ESFJ',
'ISTP',
'ISFP',
'ESTP',
'ESFP',
] as const
export type MbtiType = (typeof MBTI_TYPES_ORDERED)[number]
type MbtiGroup = 'NT' | 'NF' | 'SJ' | 'SP'
type MbtiAvatarMood = 'calm' | 'sharp' | 'warm' | 'playful'
export interface MbtiAvatarProfile {
title: string
group: MbtiGroup
mood: MbtiAvatarMood
}
/**
* 以用户给的参考图为基准:多边形人物、无中英文字,仅保留人物头像。
* 颜色与网站深色主题融合(青绿/琥珀/紫青等低饱和高对比)。
*/
export const MBTI_AVATAR_PROFILES: Record<MbtiType, MbtiAvatarProfile> = {
INTJ: { title: '战略家', group: 'NT', mood: 'sharp' },
INTP: { title: '逻辑学家', group: 'NT', mood: 'calm' },
ENTJ: { title: '指挥官', group: 'NT', mood: 'sharp' },
ENTP: { title: '辩论家', group: 'NT', mood: 'playful' },
INFJ: { title: '提倡者', group: 'NF', mood: 'warm' },
INFP: { title: '调停者', group: 'NF', mood: 'warm' },
ENFJ: { title: '主人公', group: 'NF', mood: 'warm' },
ENFP: { title: '竞选者', group: 'NF', mood: 'playful' },
ISTJ: { title: '物流师', group: 'SJ', mood: 'calm' },
ISFJ: { title: '守卫者', group: 'SJ', mood: 'warm' },
ESTJ: { title: '总经理', group: 'SJ', mood: 'sharp' },
ESFJ: { title: '执政官', group: 'SJ', mood: 'warm' },
ISTP: { title: '鉴赏家', group: 'SP', mood: 'sharp' },
ISFP: { title: '探险家', group: 'SP', mood: 'playful' },
ESTP: { title: '企业家', group: 'SP', mood: 'playful' },
ESFP: { title: '表演者', group: 'SP', mood: 'playful' },
}
function paletteByGroup(group: MbtiGroup) {
switch (group) {
case 'NT':
return { bg: '#0d1424', body: '#c89a2c', accent: '#ffd66b', hair: '#6d540f', line: '#111827' }
case 'NF':
return { bg: '#0a1721', body: '#2e9f7c', accent: '#84e9c9', hair: '#2d6a4f', line: '#11212a' }
case 'SJ':
return { bg: '#101828', body: '#4f8cb8', accent: '#9bd4ff', hair: '#2e4a66', line: '#111f2d' }
case 'SP':
return { bg: '#161225', body: '#8b6bc0', accent: '#ccb3ff', hair: '#574183', line: '#211832' }
default:
return { bg: '#0e1422', body: '#38bdac', accent: '#7ee7db', hair: '#1f6f66', line: '#10202d' }
}
}
function faceByMood(mood: MbtiAvatarMood): { eye: string; brow: string; mouth: string; tilt: number } {
switch (mood) {
case 'sharp':
return { eye: 'M222 222 L242 220 M270 220 L290 222', brow: 'M218 210 L244 202 M268 202 L294 210', mouth: 'M234 256 Q256 246 278 256', tilt: -5 }
case 'warm':
return { eye: 'M222 224 Q232 230 242 224 M270 224 Q280 230 290 224', brow: 'M220 210 Q232 206 244 210 M268 210 Q280 206 292 210', mouth: 'M232 254 Q256 272 280 254', tilt: 2 }
case 'playful':
return { eye: 'M222 224 Q232 236 242 224 M270 224 Q280 236 290 224', brow: 'M220 210 Q234 200 246 208 M266 208 Q278 200 292 210', mouth: 'M232 256 Q256 266 280 250', tilt: 8 }
default:
return { eye: 'M222 224 Q232 220 242 224 M270 224 Q280 220 290 224', brow: 'M220 210 Q232 208 244 210 M268 210 Q280 208 292 210', mouth: 'M236 256 Q256 260 276 256', tilt: 0 }
}
}
function shouldersByMood(mood: MbtiAvatarMood): string {
switch (mood) {
case 'sharp':
return 'M168 370 L206 300 L256 332 L306 300 L344 370 L306 392 L256 374 L206 392 Z'
case 'warm':
return 'M166 368 Q188 318 226 314 L256 340 L286 314 Q324 318 346 368 L314 392 Q286 404 256 396 Q226 404 198 392 Z'
case 'playful':
return 'M164 370 L198 304 L252 332 L318 300 L350 374 L316 394 L258 378 L196 396 Z'
default:
return 'M166 370 L202 306 L256 336 L310 306 L346 370 L310 392 L256 380 L202 392 Z'
}
}
export function buildMbtiSvgAvatarDataUrl(type: MbtiType): string {
const p = MBTI_AVATAR_PROFILES[type]
const palette = paletteByGroup(p.group)
const face = faceByMood(p.mood)
const shoulder = shouldersByMood(p.mood)
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="g" cx="50%" cy="35%" r="70%">
<stop offset="0%" stop-color="#1a2a42"/>
<stop offset="100%" stop-color="${palette.bg}"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="url(#g)"/>
<circle cx="256" cy="256" r="228" fill="none" stroke="#e6bd4f" stroke-width="8"/>
<circle cx="256" cy="256" r="214" fill="#f6f8fb"/>
<g transform="rotate(${face.tilt} 256 256)">
<path d="${shoulder}" fill="${palette.body}" stroke="${palette.line}" stroke-width="5" stroke-linejoin="round"/>
<polygon points="214,190 256,164 298,190 290,250 222,250" fill="#f8e9d8" stroke="${palette.line}" stroke-width="5"/>
<path d="M206 196 L226 150 L286 150 L306 196 L286 206 L226 206 Z" fill="${palette.hair}" stroke="${palette.line}" stroke-width="5" stroke-linejoin="round"/>
<circle cx="220" cy="186" r="9" fill="${palette.hair}"/>
<circle cx="292" cy="186" r="9" fill="${palette.hair}"/>
<path d="${face.brow}" fill="none" stroke="${palette.line}" stroke-width="5" stroke-linecap="round"/>
<path d="${face.eye}" fill="none" stroke="${palette.line}" stroke-width="5" stroke-linecap="round"/>
<path d="M256 228 L250 240 L262 240" fill="none" stroke="${palette.line}" stroke-width="4" stroke-linecap="round"/>
<path d="${face.mouth}" fill="none" stroke="${palette.line}" stroke-width="5" stroke-linecap="round"/>
<path d="M212 332 L256 356 L300 332" fill="none" stroke="${palette.accent}" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>`
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
}

View File

@@ -44,6 +44,12 @@ interface ListRes {
error?: string
}
function confirmDangerousDelete(entity: string): boolean {
if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false
const verifyText = window.prompt(`请输入「删除」以确认删除${entity}`)
return verifyText === '删除'
}
export function AdminUsersPage() {
const [records, setRecords] = useState<AdminUser[]>([])
const [total, setTotal] = useState(0)
@@ -72,7 +78,7 @@ export function AdminUsersPage() {
pageSize: String(pageSize),
})
if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim())
const data = await get<ListRes>(`/api/admin/admin-users?${params}`)
const data = await get<ListRes>(`/api/admin/users?${params}`)
if (data?.success) {
setRecords((data as ListRes).records || [])
setTotal((data as ListRes).total ?? 0)
@@ -130,7 +136,7 @@ export function AdminUsersPage() {
setSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
const data = await put<{ success?: boolean; error?: string }>('/api/admin/users', {
id: editingUser.id,
password: formPassword || undefined,
name: formName.trim(),
@@ -144,7 +150,7 @@ export function AdminUsersPage() {
setError(data?.error || '保存失败')
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
const data = await post<{ success?: boolean; error?: string }>('/api/admin/users', {
username: formUsername.trim(),
password: formPassword,
name: formName.trim(),
@@ -166,9 +172,12 @@ export function AdminUsersPage() {
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该管理员')) return
if (!confirmDangerousDelete('管理员')) {
setError('已取消删除')
return
}
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/admin-users?id=${id}`)
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/users?id=${id}`)
if (data?.success) loadList()
else setError(data?.error || '删除失败')
} catch (e: unknown) {

View File

@@ -260,8 +260,9 @@ export function ContentPage() {
label: '',
aliases: '',
url: '',
type: 'url' as 'url' | 'miniprogram' | 'ckb',
type: 'url' as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
appId: '',
appSecret: '',
pagePath: '',
})
const [linkTagSaving, setLinkTagSaving] = useState(false)
@@ -537,7 +538,15 @@ export function ContentPage() {
try {
const data = await get<{
success?: boolean
linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[]
linkTags?: {
tagId: string
label: string
url: string
type: string
appId?: string
pagePath?: string
hasAppSecret?: boolean
}[]
}>('/api/db/link-tags')
if (data?.success && data.linkTags) {
setLinkTags(
@@ -545,9 +554,10 @@ export function ContentPage() {
id: t.tagId,
label: t.label,
url: t.url,
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
appId: t.appId || '',
pagePath: t.pagePath || '',
hasAppSecret: !!t.hasAppSecret,
})),
)
}
@@ -608,7 +618,16 @@ export function ContentPage() {
if (s) qs.set('search', s)
const data = await get<{
success?: boolean
linkTags?: { tagId: string; label: string; aliases?: string; url: string; type: string; appId?: string; pagePath?: string }[]
linkTags?: {
tagId: string
label: string
aliases?: string
url: string
type: string
appId?: string
pagePath?: string
hasAppSecret?: boolean
}[]
total?: number
page?: number
pageSize?: number
@@ -622,9 +641,10 @@ export function ContentPage() {
label: t.label,
aliases: t.aliases || '',
url: t.url,
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
appId: t.appId || '',
pagePath: t.pagePath || '',
hasAppSecret: !!t.hasAppSecret,
})),
)
setLinkTagTotal(typeof data.total === 'number' ? data.total : 0)
@@ -2810,7 +2830,7 @@ export function ContentPage() {
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
onClick={() => {
setLinkTagEditing(null)
setLinkTagForm({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', pagePath: '' })
setLinkTagForm({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', appSecret: '', pagePath: '' })
setMpSearchQuery('')
setMpDropdownOpen(false)
setLinkTagModalOpen(true)
@@ -2862,6 +2882,7 @@ export function ContentPage() {
url: t.url,
type: t.type,
appId: t.appId ?? '',
appSecret: '',
pagePath: t.pagePath ?? '',
})
setMpSearchQuery(t.appId ?? '')
@@ -2882,12 +2903,12 @@ export function ContentPage() {
className={`text-[10px] ${
t.type === 'ckb'
? 'bg-green-500/20 text-green-300 border-green-500/30'
: t.type === 'miniprogram'
: t.type === 'miniprogram' || t.type === 'wxlink'
? 'bg-[#38bdac]/20 text-[#38bdac] border-[#38bdac]/30'
: 'bg-gray-700 text-gray-300'
}`}
>
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : t.type === 'wxlink' ? '小程序链接' : '小程序'}
</Badge>
</td>
<td className="px-3 py-2 text-gray-300">
@@ -2903,6 +2924,18 @@ export function ContentPage() {
)
})()}
{t.pagePath && <div className="text-xs text-gray-500 font-mono">{t.pagePath}</div>}
<div
className={`text-xs ${t.hasAppSecret ? 'text-emerald-400/90' : 'text-amber-500/80'}`}
>
AppSecret{t.hasAppSecret ? '已保存(仅服务端)' : '未配置'}
</div>
</div>
) : t.type === 'wxlink' ? (
<div className="space-y-0.5">
<div className="text-xs text-[#38bdac] truncate max-w-[420px] font-mono" title={t.url}>
{t.url || '—'}
</div>
<div className="text-[11px] text-gray-500"> web-view </div>
</div>
) : t.url ? (
<a
@@ -2932,6 +2965,7 @@ export function ContentPage() {
url: t.url,
type: t.type,
appId: t.appId ?? '',
appSecret: '',
pagePath: t.pagePath ?? '',
})
setMpSearchQuery(t.appId ?? '')
@@ -2996,7 +3030,7 @@ export function ContentPage() {
<DialogHeader className="gap-1">
<DialogTitle className="text-base">{linkTagEditing ? '编辑链接标签' : '添加链接标签'}</DialogTitle>
<DialogDescription className="text-gray-400 text-xs">
#
# mpKey AppIDAppSecret 使
</DialogDescription>
</DialogHeader>
@@ -3006,7 +3040,7 @@ export function ContentPage() {
<Label className="text-gray-300 text-sm">ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="留空自动生成;或填 12位数字 / z开头12位"
placeholder="留空自动生成;或自定义短 ID如 kr最长 50 字符"
value={linkTagForm.tagId}
disabled={!!linkTagEditing}
onChange={(e) => setLinkTagForm((p) => ({ ...p, tagId: e.target.value }))}
@@ -3038,7 +3072,7 @@ export function ContentPage() {
<Select
value={linkTagForm.type}
onValueChange={(v) =>
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' }))
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' | 'wxlink' }))
}
>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8">
@@ -3046,7 +3080,8 @@ export function ContentPage() {
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700 text-white">
<SelectItem value="url"></SelectItem>
<SelectItem value="miniprogram"></SelectItem>
<SelectItem value="miniprogram">API跳转</SelectItem>
<SelectItem value="wxlink"></SelectItem>
<SelectItem value="ckb"></SelectItem>
</SelectContent>
</Select>
@@ -3057,9 +3092,18 @@ export function ContentPage() {
? 'URL地址'
: linkTagForm.type === 'ckb'
? '存客宝计划URL'
: '小程序(选密钥)'}
: linkTagForm.type === 'wxlink'
? '小程序链接'
: '小程序 mpKey / 微信 AppID'}
</Label>
{linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? (
{linkTagForm.type === 'wxlink' ? (
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
placeholder="粘贴小程序右上角 ... → 复制链接 得到的 URL"
value={linkTagForm.url}
onChange={(e) => setLinkTagForm((p) => ({ ...p, url: e.target.value }))}
/>
) : linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? (
<div ref={mpDropdownRef} className="relative">
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
@@ -3110,7 +3154,7 @@ export function ContentPage() {
? 'https://...'
: linkTagForm.type === 'ckb'
? 'https://ckbapi.quwanzhi.com/...'
: '关联小程序的32位密钥'
: '关联配置的 key或直接填 wx 开头的 AppID'
}
value={linkTagForm.type === 'url' || linkTagForm.type === 'ckb' ? linkTagForm.url : linkTagForm.appId}
onChange={(e) => {
@@ -3123,16 +3167,38 @@ export function ContentPage() {
</div>
</div>
{linkTagForm.type === 'wxlink' && (
<p className="text-[11px] text-amber-400/80 leading-snug px-0.5">
... web-view
</p>
)}
{linkTagForm.type === 'miniprogram' && (
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="pages/index/index"
value={linkTagForm.pagePath}
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
/>
</div>
<>
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="pages/index/index"
value={linkTagForm.pagePath}
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-300 text-sm">AppSecret · </Label>
<Input
type="password"
autoComplete="new-password"
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder={linkTagEditing?.hasAppSecret ? '已保存密钥,留空不改;填写则覆盖' : '粘贴目标小程序 AppSecret'}
value={linkTagForm.appSecret}
onChange={(e) => setLinkTagForm((p) => ({ ...p, appSecret: e.target.value }))}
/>
<p className="text-[11px] text-gray-500 leading-snug">
AppID
</p>
</div>
</>
)}
</div>
@@ -3149,13 +3215,18 @@ export function ContentPage() {
url: linkTagForm.url.trim(),
type: linkTagForm.type,
appId: linkTagForm.appId.trim(),
appSecret: linkTagForm.appSecret.trim(),
pagePath: linkTagForm.pagePath.trim(),
}
// 新增:允许留空后端自动生成;编辑tagId 已锁定
// 留空后端自动生成;自定义时与库一致150 字符,勿含 #、逗号、换行
if (payload.tagId) {
const ok = /^\d{12}$/.test(payload.tagId) || /^z[a-z0-9]{11}$/.test(payload.tagId)
if (!ok) {
toast.error('标签ID需为12位数字或 z 开头的12位z+11位小写字母数字')
const id = payload.tagId
if ([...id].length > 50) {
toast.error('标签ID 最长 50 个字符')
return
}
if (/[#,\n\r\t]/.test(id)) {
toast.error('标签ID 不能含 #、逗号或换行')
return
}
}
@@ -3164,6 +3235,7 @@ export function ContentPage() {
return
}
if (payload.type === 'miniprogram') payload.url = ''
if (payload.type === 'wxlink') { payload.appId = ''; payload.pagePath = '' }
setLinkTagSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/link-tags', payload)

View File

@@ -50,6 +50,32 @@ interface DashboardOverviewRes {
newUsers?: UserRow[]
}
interface MatchStatsRes {
success?: boolean
data?: {
totalMatches?: number
todayMatches?: number
uniqueUsers?: number
paidMatchCount?: number
}
}
interface DistributionOverviewRes {
success?: boolean
overview?: {
todayClicks?: number
todayBindings?: number
todayConversions?: number
monthClicks?: number
monthBindings?: number
monthConversions?: number
totalClicks?: number
totalBindings?: number
totalConversions?: number
conversionRate?: string
}
}
interface UsersRes {
success?: boolean
users?: UserRow[]
@@ -62,6 +88,13 @@ interface OrdersRes {
total?: number
}
interface VipMemberLite {
id: string
name?: string
nickname?: string
token?: string
}
export function DashboardPage() {
const navigate = useNavigate()
const [statsLoading, setStatsLoading] = useState(true)
@@ -84,12 +117,32 @@ export function DashboardPage() {
Array<{ userId: string; nickname?: string; avatar?: string; phone?: string; clicks: number; uniqueClicks: number; leadCount?: number }>
>([])
const [superLoading, setSuperLoading] = useState(false)
const [trackPeriod, setTrackPeriod] = useState<string>('week')
const [trackPeriod, setTrackPeriod] = useState<string>('today')
const [trackStats, setTrackStats] = useState<{
total: number
byModule: Record<string, { action: string; target: string; module: string; page: string; count: number }[]>
} | null>(null)
const [trackLoading, setTrackLoading] = useState(false)
const [partnerPromoLoading, setPartnerPromoLoading] = useState(true)
const [matchStats, setMatchStats] = useState<{
totalMatches: number
todayMatches: number
uniqueUsers: number
paidMatchCount: number
} | null>(null)
const [distributionOverview, setDistributionOverview] = useState<{
todayClicks: number
todayBindings: number
todayConversions: number
monthClicks: number
monthBindings: number
monthConversions: number
totalClicks: number
totalBindings: number
totalConversions: number
conversionRate?: string
} | null>(null)
const [vipMembers, setVipMembers] = useState<VipMemberLite[]>([])
const showError = (err: unknown) => {
const e = err as Error & { status?: number; name?: string }
@@ -153,6 +206,60 @@ export function DashboardPage() {
setCkbStats(null)
}
// 加载「找伙伴 × 推广中心」共统计
setPartnerPromoLoading(true)
try {
const [matchRes, distRes] = await Promise.allSettled([
get<MatchStatsRes>('/api/db/match-records?stats=true', init),
get<DistributionOverviewRes>('/api/admin/distribution/overview', init),
])
if (matchRes.status === 'fulfilled' && matchRes.value?.success && matchRes.value.data) {
setMatchStats({
totalMatches: matchRes.value.data.totalMatches ?? 0,
todayMatches: matchRes.value.data.todayMatches ?? 0,
uniqueUsers: matchRes.value.data.uniqueUsers ?? 0,
paidMatchCount: matchRes.value.data.paidMatchCount ?? 0,
})
} else {
setMatchStats(null)
}
if (distRes.status === 'fulfilled' && distRes.value?.success && distRes.value.overview) {
setDistributionOverview({
todayClicks: distRes.value.overview.todayClicks ?? 0,
todayBindings: distRes.value.overview.todayBindings ?? 0,
todayConversions: distRes.value.overview.todayConversions ?? 0,
monthClicks: distRes.value.overview.monthClicks ?? 0,
monthBindings: distRes.value.overview.monthBindings ?? 0,
monthConversions: distRes.value.overview.monthConversions ?? 0,
totalClicks: distRes.value.overview.totalClicks ?? 0,
totalBindings: distRes.value.overview.totalBindings ?? 0,
totalConversions: distRes.value.overview.totalConversions ?? 0,
conversionRate: distRes.value.overview.conversionRate,
})
} else {
setDistributionOverview(null)
}
} catch {
setMatchStats(null)
setDistributionOverview(null)
} finally {
setPartnerPromoLoading(false)
}
// 加载超级个体名单(用于点击统计里把 ID 显示为名字)
try {
const vipRes = await get<{ success?: boolean; data?: VipMemberLite[] }>('/api/db/vip-members?limit=500', init)
if (vipRes?.success && Array.isArray(vipRes.data)) {
setVipMembers(vipRes.data)
} else {
setVipMembers([])
}
} catch {
setVipMembers([])
}
// 2. 并行加载订单和用户
setOrdersLoading(true)
setUsersLoading(true)
@@ -220,6 +327,130 @@ export function DashboardPage() {
}
}
const moduleLabels: Record<string, string> = {
home: '首页',
chapters: '目录',
read: '阅读页',
my: '我的',
vip: '超级个体',
wallet: '钱包',
match: '找伙伴',
referral: '推广中心',
search: '搜索',
settings: '设置',
about: '关于',
member_detail: '成员详情',
other: '其他',
}
const actionLabels: Record<string, string> = {
btn_click: '按钮点击',
nav_click: '导航点击',
card_click: '卡片点击',
tab_click: '标签切换',
page_view: '页面浏览',
share: '分享',
purchase: '购买',
register: '注册',
rule_trigger: '规则触发',
view_chapter: '浏览章节',
link_click: '链接点击',
}
const normalizeTrackToken = (value?: string) => {
if (!value) return ''
return value
.replace(/^part-/, '')
.replace(/^soulvip_/, '')
.replace(/^super_?/, '')
.replace(/^user_/, '')
.replace(/[_-]+/g, ' ')
.trim()
}
const resolveVipNameByToken = (value?: string) => {
if (!value) return ''
const key = value.trim().toLowerCase()
if (!key) return ''
const byId = vipMembers.find((m) => {
const id = String(m.id || '').toLowerCase()
return id === key || id.includes(key) || key.includes(id)
})
if (byId) return byId.name || byId.nickname || ''
const byToken = vipMembers.find((m) => {
const t = String(m.token || '').toLowerCase()
return t && (t === key || t.includes(key) || key.includes(t))
})
if (byToken) return byToken.name || byToken.nickname || ''
return ''
}
const prettyTrackTarget = (target?: string) => {
if (!target) return '未命名点击'
const t = target.trim()
const lower = t.toLowerCase()
if (/^链接头像[_-]/.test(t)) {
const rawName = normalizeTrackToken(t.replace(/^链接头像[_-]/, ''))
return rawName ? `头像:${rawName}` : '头像点击'
}
if (/^member[_-]?detail$/i.test(lower) || lower.includes('member detail')) return '成员详情'
if (/^giftpay$/i.test(lower) || lower.includes('gift pay')) return '代付入口'
if (/^part[-_]/i.test(lower)) return `章节:${normalizeTrackToken(t)}`
if (lower.includes('soulvip') || lower.includes('super')) {
const raw = t
.replace(/^超级个体[:]?/i, '')
.replace(/^super[_-]?/i, '')
.replace(/^soulvip[_-]?/i, '')
.replace(/^user[_-]?/i, '')
.trim()
const vipName = resolveVipNameByToken(raw) || resolveVipNameByToken(normalizeTrackToken(raw))
if (vipName) return `超级个体:${vipName}`
return `超级个体:${normalizeTrackToken(raw)}`
}
if (lower.includes('qgdtw') || lower.includes('token') || lower.includes('0000')) return `对象:${normalizeTrackToken(t)}`
const targetLabels: Record<string, string> = {
'开始匹配': '开始匹配',
mentor: '导师顾问',
team: '团队招募',
investor: '资源对接',
'充值': '充值',
'退款': '退款',
wallet: '钱包',
'设置': '设置',
'VIP': 'VIP会员',
'推广': '推广中心',
'目录': '目录',
'搜索': '搜索',
'匹配': '找伙伴',
settings: '设置',
expired: '已过期',
active: '活跃',
converted: '已转化',
fill_profile: '完善资料',
register: '注册',
purchase: '购买',
'链接卡若': '链接卡若',
'更多分享': '更多分享',
'分享朋友圈文案': '分享朋友圈',
'选择金额10': '选择金额10元',
member_detail: '成员详情',
giftPay: '代付入口',
}
if (targetLabels[t]) return targetLabels[t]
if (/^[a-z0-9_-]+$/i.test(t)) return normalizeTrackToken(t) || t
return t
}
const buildTrackLocationLabel = (item: { module: string; page: string; action: string; target: string }) => {
const moduleName = moduleLabels[item.module] || moduleLabels[item.page] || item.module || item.page || '其他'
const actionName = actionLabels[item.action] || item.action || '点击'
const targetName = prettyTrackTarget(item.target)
return `${moduleName} · ${actionName} · ${targetName}`
}
async function loadSuperStats() {
setSuperLoading(true)
try {
@@ -342,6 +573,19 @@ export function DashboardPage() {
bg: 'bg-cyan-500/20',
link: '/users?tab=leads',
},
{
title: '伙伴&推广协同',
value: partnerPromoLoading
? null
: (matchStats?.totalMatches ?? 0) + (distributionOverview?.totalClicks ?? 0),
sub: partnerPromoLoading
? null
: `找伙伴 ${(matchStats?.totalMatches ?? 0)} / 推广 ${(distributionOverview?.totalClicks ?? 0)}`,
icon: BarChart3,
color: 'text-emerald-400',
bg: 'bg-emerald-500/20',
link: '/find-partner',
},
]
return (
@@ -359,11 +603,11 @@ export function DashboardPage() {
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="flex flex-nowrap gap-6 mb-8 overflow-x-auto pb-1">
{stats.map((stat, index) => (
<Card
key={index}
className="bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
className="min-w-[220px] flex-1 bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
onClick={() => stat.link && navigate(stat.link)}
>
<CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -433,6 +677,63 @@ export function DashboardPage() {
</div>
{bottomTab === 'overview' && (
<div className="space-y-8">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white"> × 广</CardTitle>
<button
type="button"
onClick={() => loadAll()}
disabled={partnerPromoLoading}
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1 disabled:opacity-50"
title="刷新共统计"
>
<RefreshCw className={`w-3.5 h-3.5 ${partnerPromoLoading ? 'animate-spin' : ''}`} />
</button>
</CardHeader>
<CardContent>
{partnerPromoLoading && !matchStats && !distributionOverview ? (
<div className="flex items-center justify-center py-10 text-gray-500">
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-white mt-1">{matchStats?.totalMatches ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-white mt-1">{matchStats?.todayMatches ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-white mt-1">{matchStats?.uniqueUsers ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400">广</p>
<p className="text-2xl font-bold text-white mt-1">{distributionOverview?.totalClicks ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400">广</p>
<p className="text-2xl font-bold text-white mt-1">{distributionOverview?.totalBindings ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400">广</p>
<p className="text-2xl font-bold text-white mt-1">{distributionOverview?.totalConversions ?? 0}</p>
</div>
</div>
)}
{distributionOverview?.conversionRate && (
<p className="text-xs text-gray-500 mt-3">
广{distributionOverview.conversionRate}
</p>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="flex flex-row items-center justify-between">
@@ -488,7 +789,7 @@ export function DashboardPage() {
<img
src={p.userAvatar}
alt={buyer}
className="w-9 h-9 rounded-full object-cover flex-shrink-0 mt-0.5"
className="w-9 h-9 rounded-full object-cover shrink-0 mt-0.5"
onError={(e) => {
e.currentTarget.style.display = 'none'
const next = e.currentTarget.nextElementSibling as HTMLElement
@@ -497,7 +798,7 @@ export function DashboardPage() {
/>
) : null}
<div
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
>
{buyer.charAt(0)}
</div>
@@ -537,7 +838,7 @@ export function DashboardPage() {
</div>
</div>
<div className="text-right ml-4 flex-shrink-0">
<div className="text-right ml-4 shrink-0">
<p className="text-sm font-bold text-[#38bdac]">
+¥{Number(p.amount).toFixed(2)}
</p>
@@ -620,6 +921,7 @@ export function DashboardPage() {
</CardContent>
</Card>
</div>
</div>
)}
{bottomTab === 'tags' && (
@@ -663,11 +965,6 @@ export function DashboardPage() {
.slice(0, 5)
.map(([mod, items]) => {
const moduleTotal = items.reduce((s, i) => s + i.count, 0)
const moduleLabels: Record<string, string> = {
home: '首页', chapters: '目录', read: '阅读', my: '我的',
vip: 'VIP', wallet: '钱包', match: '找伙伴', referral: '推广',
search: '搜索', settings: '设置', about: '关于', other: '其他',
}
return (
<div key={mod} className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
<div className="flex items-center justify-between mb-3">
@@ -681,28 +978,10 @@ export function DashboardPage() {
.sort((a, b) => b.count - a.count)
.slice(0, 8)
.map((item, i) => {
const targetLabels: Record<string, string> = {
'开始匹配': '开始匹配', 'mentor': '导师顾问', 'team': '团队招募',
'investor': '资源对接', '充值': '充值', '退款': '退款',
'wallet': '钱包', '设置': '设置', 'VIP': 'VIP会员',
'推广': '推广中心', '目录': '目录', '搜索': '搜索',
'匹配': '找伙伴', 'settings': '设置', 'expired': '已过期',
'active': '活跃', 'converted': '已转化', 'fill_profile': '完善资料',
'register': '注册', 'purchase': '购买', 'btn_click': '按钮点击',
'nav_click': '导航点击', 'card_click': '卡片点击', 'tab_click': '标签切换',
'rule_trigger': '规则触发', 'view_chapter': '浏览章节',
'链接卡若': '链接卡若', '更多分享': '更多分享', '分享朋友圈文案': '分享朋友圈',
'选择金额10': '选择金额10元',
}
const actionLabels: Record<string, string> = {
'btn_click': '按钮点击', 'nav_click': '导航点击', 'card_click': '卡片点击',
'tab_click': '标签切换', 'purchase': '购买', 'register': '注册',
'rule_trigger': '规则触发', 'view_chapter': '浏览章节',
}
const label = targetLabels[item.target] || item.target || actionLabels[item.action] || item.action
const label = buildTrackLocationLabel(item)
return (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate mr-2" title={`${item.action}: ${item.target}`}>
<span className="text-gray-300 truncate mr-2" title={label}>
{label}
</span>
<div className="flex items-center gap-2 shrink-0">

View File

@@ -44,6 +44,10 @@ interface Purchase {
giftPayRequestId?: string
payerUserId?: string
payerNickname?: string
webhookPushStatus?: 'sent' | 'failed' | ''
webhookPushedAt?: string
webhookPushAttempts?: number
webhookPushError?: string
}
interface UsersItem {
@@ -362,23 +366,35 @@ export function OrdersPage() {
: purchase.paymentMethod || '微信支付'}
</TableCell>
<TableCell>
{purchase.status === 'refunded' ? (
<Badge className="bg-gray-500/20 text-gray-400 hover:bg-gray-500/20 border-0">
退
</Badge>
) : purchase.status === 'paid' || purchase.status === 'completed' ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
) : purchase.status === 'pending' || purchase.status === 'created' ? (
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
</Badge>
)}
<div className="flex items-center gap-2 flex-wrap">
{purchase.status === 'refunded' ? (
<Badge className="bg-gray-500/20 text-gray-400 hover:bg-gray-500/20 border-0">
退
</Badge>
) : purchase.status === 'paid' || purchase.status === 'completed' ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
) : purchase.status === 'pending' || purchase.status === 'created' ? (
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
</Badge>
)}
{(purchase.status === 'paid' || purchase.status === 'completed') &&
(purchase.webhookPushStatus === 'sent' ? (
<Badge className="bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/20 border-0">
</Badge>
) : (
<Badge className="bg-orange-500/20 text-orange-300 hover:bg-orange-500/20 border-0">
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-gray-400 text-sm max-w-[120px] truncate" title={purchase.refundReason}>
{purchase.status === 'refunded' && purchase.refundReason ? purchase.refundReason : '-'}

View File

@@ -36,6 +36,10 @@ import {
Link2,
FileText,
Cloud,
Eye,
EyeOff,
LayoutGrid,
Sparkles,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
@@ -72,6 +76,8 @@ interface MpConfig {
mchId?: string
minWithdraw?: number
auditMode?: boolean
/** 小程序界面文案与跳转,与 soul-api defaultMpUi 结构一致,服务端会与默认值深合并 */
mpUi?: Record<string, unknown>
}
interface OssConfig {
@@ -113,17 +119,60 @@ 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.linkKaruoAvatar首页「链接卡若」头像 HTTPS空则小程序用「卡」字占位
homePage: {
logoTitle: '卡若创业派对',
logoSubtitle: '来自派对房的真实故事',
linkKaruoText: '点击链接卡若',
linkKaruoAvatar: '',
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]
const SYSTEM_SECTION_KEYS = ['basic', 'mp', 'oss', 'features'] as const
type SystemSectionKey = (typeof SYSTEM_SECTION_KEYS)[number]
export function SettingsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const tabParam = searchParams.get('tab') ?? 'system'
const activeTab = TAB_KEYS.includes(tabParam as TabKey) ? (tabParam as TabKey) : 'system'
const systemSectionParam = searchParams.get('section') ?? 'basic'
const systemSection: SystemSectionKey = SYSTEM_SECTION_KEYS.includes(systemSectionParam as SystemSectionKey)
? (systemSectionParam as SystemSectionKey)
: 'basic'
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)
@@ -153,8 +202,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') {
@@ -235,6 +294,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 +329,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
? {
@@ -276,7 +355,23 @@ export function SettingsPage() {
}
const handleTabChange = (v: string) => {
setSearchParams(v === 'system' ? {} : { tab: v })
if (v === 'system') {
const sp = new URLSearchParams(searchParams)
sp.delete('tab')
if (!SYSTEM_SECTION_KEYS.includes((sp.get('section') || 'basic') as SystemSectionKey)) {
sp.set('section', 'basic')
}
setSearchParams(sp)
return
}
setSearchParams({ tab: v })
}
const handleSystemSectionChange = (v: string) => {
const sp = new URLSearchParams(searchParams)
sp.delete('tab')
sp.set('section', v)
setSearchParams(sp)
}
if (loading) return <div className="p-8 text-gray-500">...</div>
@@ -337,7 +432,45 @@ export function SettingsPage() {
</TabsList>
<TabsContent value="system" className="mt-0">
<div className="space-y-6">
<p className="text-xs text-gray-500 mb-3">
MBTI {' '}
<Link to="/users" className="text-[#38bdac] underline">
</Link>
</p>
<Tabs value={systemSection} onValueChange={handleSystemSectionChange} className="w-full">
<TabsList className="mb-4 bg-[#0a1628] border border-gray-700/50 p-1 flex-wrap h-auto gap-1">
<TabsTrigger
value="basic"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<LayoutGrid className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="mp"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<Smartphone className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="oss"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<Cloud className="w-3.5 h-3.5 mr-1" />
OSS
</TabsTrigger>
<TabsTrigger
value="features"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<Settings className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -538,7 +671,9 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="mp" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -599,9 +734,71 @@ 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>
<Card className={`bg-[#0f2137] shadow-xl ${mpConfig.auditMode ? 'border-amber-500/50 border-2' : 'border-gray-700/50'}`}>
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-amber-400" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent>
<div className={`flex items-center justify-between p-4 rounded-lg border ${mpConfig.auditMode ? 'bg-amber-500/10 border-amber-500/30' : 'bg-[#0a1628] border-gray-700/50'}`}>
<div className="space-y-1">
<div className="flex items-center gap-2">
<ShieldCheck className={`w-4 h-4 ${mpConfig.auditMode ? 'text-amber-400' : 'text-gray-400'}`} />
<Label htmlFor="audit-mode" className="text-white font-medium cursor-pointer">
{mpConfig.auditMode ? '审核模式(已开启)' : '审核模式(已关闭)'}
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
{mpConfig.auditMode
? '当前已隐藏所有支付、VIP、充值、收益等入口审核员看不到任何付费内容'
: '关闭状态小程序正常显示所有功能含支付、VIP 等)'}
</p>
</div>
<Switch
id="audit-mode"
checked={mpConfig.auditMode ?? false}
disabled={auditModeSaving}
onCheckedChange={handleAuditModeSwitch}
/>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="oss" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -673,42 +870,9 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</TabsContent>
<Card className={`bg-[#0f2137] shadow-xl ${mpConfig.auditMode ? 'border-amber-500/50 border-2' : 'border-gray-700/50'}`}>
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-amber-400" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent>
<div className={`flex items-center justify-between p-4 rounded-lg border ${mpConfig.auditMode ? 'bg-amber-500/10 border-amber-500/30' : 'bg-[#0a1628] border-gray-700/50'}`}>
<div className="space-y-1">
<div className="flex items-center gap-2">
<ShieldCheck className={`w-4 h-4 ${mpConfig.auditMode ? 'text-amber-400' : 'text-gray-400'}`} />
<Label htmlFor="audit-mode" className="text-white font-medium cursor-pointer">
{mpConfig.auditMode ? '审核模式(已开启)' : '审核模式(已关闭)'}
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
{mpConfig.auditMode
? '当前已隐藏所有支付、VIP、充值、收益等入口审核员看不到任何付费内容'
: '关闭状态小程序正常显示所有功能含支付、VIP 等)'}
</p>
</div>
<Switch
id="audit-mode"
checked={mpConfig.auditMode ?? false}
disabled={auditModeSaving}
onCheckedChange={handleAuditModeSwitch}
/>
</div>
</CardContent>
</Card>
<TabsContent value="features" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -804,7 +968,41 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</div>
<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: <Sparkles 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>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="author" className="mt-0">

View File

@@ -48,6 +48,7 @@ import {
UserPlus as LeadIcon,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { MbtiAvatarsManager } from '@/components/modules/mbti/MbtiAvatarsManager'
import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
import { useSearchParams } from 'react-router-dom'
@@ -62,6 +63,7 @@ interface User {
avatar?: string | null
isAdmin?: boolean | number
hasFullBook?: boolean | number
purchasedSectionCount?: number
referralCode?: string
earnings: number | string
pendingEarnings?: number | string
@@ -83,17 +85,73 @@ interface UserRule {
title: string
description: string
trigger: string
triggerConditions?: string[]
actionType?: string
actionConfig?: Record<string, unknown>
sort: number
enabled: boolean
createdAt?: string
}
const TRIGGER_OPTIONS: { value: string; label: string; group: string }[] = [
{ value: 'after_login', label: '注册/登录成功', group: '用户状态' },
{ value: 'bind_phone', label: '绑定手机号', group: '用户状态' },
{ value: 'update_avatar', label: '完善头像(非默认图,与昵称分开配置)', group: '用户状态' },
{ value: 'update_nickname', label: '修改昵称(非默认微信昵称,与头像分开)', group: '用户状态' },
{ value: 'fill_profile', label: '完善资料MBTI/行业/职位,不含头像昵称)', group: '用户状态' },
{ value: 'view_chapter', label: '浏览章节', group: '阅读行为' },
{ value: 'browse_5_chapters', label: '累计浏览5个章节', group: '阅读行为' },
{ value: 'purchase_section', label: '购买单章', group: '付费行为' },
{ value: 'purchase_fullbook', label: '购买全书/VIP', group: '付费行为' },
{ value: 'after_pay', label: '任意付款成功', group: '付费行为' },
{ value: 'after_match', label: '完成派对匹配', group: '社交行为' },
{ value: 'click_super_individual', label: '点击超级个体头像', group: '社交行为' },
{ value: 'lead_submit', label: '提交留资/链接', group: '社交行为' },
{ value: 'referral_bind', label: '被推荐人绑定', group: '分销行为' },
{ value: 'share_action', label: '分享给好友/朋友圈', group: '分销行为' },
{ value: 'withdraw_request', label: '申请提现', group: '分销行为' },
{ value: 'add_wechat', label: '添加微信联系方式', group: '用户状态' },
]
const ACTION_TYPE_OPTIONS: { value: string; label: string; desc: string }[] = [
{ value: 'popup', label: '弹窗提示', desc: '在小程序内弹窗引导用户完成下一步' },
{ value: 'navigate', label: '跳转页面', desc: '引导用户跳转到指定页面' },
{ value: 'webhook', label: '推送飞书群', desc: '触发后推送消息到飞书群Webhook' },
{ value: 'tag', label: '自动打标签', desc: '触发后自动给用户打上指定标签' },
]
/** 后端曾将 []byte 编成 base64或历史脏数据。统一为 string[],避免规则列表 .map 白屏 */
function normalizeTriggerConditions(v: unknown): string[] {
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string')
if (typeof v === 'string') {
try {
const p = JSON.parse(v) as unknown
if (Array.isArray(p)) return p.filter((x): x is string => typeof x === 'string')
} catch {
try {
const decoded = typeof atob === 'function' ? atob(v) : ''
const p = JSON.parse(decoded) as unknown
if (Array.isArray(p)) return p.filter((x): x is string => typeof x === 'string')
} catch {
/* ignore */
}
}
}
return []
}
function normalizeUserRule(r: UserRule): UserRule {
return { ...r, triggerConditions: normalizeTriggerConditions(r.triggerConditions) }
}
interface VipMember {
id: string
name: string
avatar?: string | null
mbti?: string | null
vipRole?: string | null
vipSort?: number | null
webhookUrl?: string | null
/** 首页超级个体卡片点击次数(/api/db/vip-members 聚合 user_tracks */
clickCount?: number | null
/** 绑定人物后的去重获客人数 */
@@ -121,10 +179,17 @@ const JOURNEY_STAGES = [
{ id: 'distribution', label: '开启分销', icon: '🔗', color: 'bg-[#38bdac]/20 border-[#38bdac]/40 text-[#38bdac]', desc: '生成推广码并推荐好友' },
]
function confirmDangerousDelete(entity: string): boolean {
if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false
const verifyText = window.prompt(`请输入「删除」以确认删除${entity}`)
return verifyText === '删除'
}
export function UsersPage() {
const [searchParams, setSearchParams] = useSearchParams()
const poolParam = searchParams.get('pool') // 'vip' | 'complete' | 'all' | null
const tabParam = searchParams.get('tab') || 'users' // users | journey | rules | vip-roles | leads
const rawTabParam = searchParams.get('tab') || 'users'
const tabParam = ['users', 'journey', 'rules', 'vip-roles', 'leads'].includes(rawTabParam) ? rawTabParam : 'users'
// ===== 用户列表 state =====
const [users, setUsers] = useState<User[]>([])
@@ -159,6 +224,7 @@ export function UsersPage() {
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(null)
const [showMbtiAvatarDialog, setShowMbtiAvatarDialog] = useState(false)
const [formData, setFormData] = useState({ phone: '', nickname: '', password: '', isAdmin: false, hasFullBook: false })
// ===== 规则管理 =====
@@ -166,7 +232,7 @@ export function UsersPage() {
const [rulesLoading, setRulesLoading] = useState(false)
const [showRuleModal, setShowRuleModal] = useState(false)
const [editingRule, setEditingRule] = useState<UserRule | null>(null)
const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', sort: 0, enabled: true })
const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', triggerConditions: [] as string[], actionType: 'popup', sort: 0, enabled: true })
// ===== 超级个体VIP 用户列表) =====
const [vipMembers, setVipMembers] = useState<VipMember[]>([])
@@ -177,6 +243,14 @@ 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 [mbtiAvatarsMap, setMbtiAvatarsMap] = useState<Record<string, string>>({})
// ===== 获客列表(存客宝) =====
const [leadsRecords, setLeadsRecords] = useState<{
@@ -239,10 +313,56 @@ export function UsersPage() {
setLeadsLoading(false)
}
}, [leadsPage, leadsPageSize, debouncedLeadsSearch, leadsSourceFilter])
const loadMbtiAvatarsMap = useCallback(async () => {
try {
const data = await get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
const map = (data?.avatars && typeof data.avatars === 'object') ? data.avatars : {}
setMbtiAvatarsMap(map)
} catch {
setMbtiAvatarsMap({})
}
}, [])
useEffect(() => {
if (searchParams.get('tab') === 'leads') loadLeads()
}, [searchParams.get('tab'), leadsPage, loadLeads])
useEffect(() => {
loadMbtiAvatarsMap()
}, [loadMbtiAvatarsMap])
const resolveUserAvatarByMbti = useCallback((avatar: string | null | undefined, mbti: string | null | undefined): string => {
const av = (avatar || '').trim()
if (av) return av
const key = (mbti || '').trim().toUpperCase()
if (!/^[EI][NS][FT][JP]$/.test(key)) return ''
return (mbtiAvatarsMap[key] || '').trim()
}, [mbtiAvatarsMap])
const getPurchaseState = useCallback((user: User) => {
const hasFull = !!user.hasFullBook
const sectionCount = Number(user.purchasedSectionCount || 0)
if (hasFull) {
return {
tone: 'vip' as const,
main: '已购全书',
sub: sectionCount > 0 ? `另购单章 ${sectionCount}` : '购买项VIP / 全书',
}
}
if (sectionCount > 0) {
return {
tone: 'paid' as const,
main: `已购 ${sectionCount}`,
sub: '购买项:章节',
}
}
return {
tone: 'free' as const,
main: '未购买',
sub: '',
}
}, [])
// ===== 在线人数WSS 占位) =====
const [onlineCount, setOnlineCount] = useState<number | null>(null)
const loadOnlineStats = useCallback(async () => {
@@ -337,7 +457,10 @@ export function UsersPage() {
}
async function handleDelete(userId: string) {
if (!confirm('确定要删除这个用户吗?')) return
if (!confirmDangerousDelete('用户')) {
toast.info('已取消删除')
return
}
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/users?id=${encodeURIComponent(userId)}`)
if (data?.success) {
@@ -395,7 +518,7 @@ export function UsersPage() {
setRulesLoading(true)
try {
const data = await get<{ success?: boolean; rules?: UserRule[] }>('/api/db/user-rules')
if (data?.success) setRules(data.rules || [])
if (data?.success) setRules((data.rules || []).map((r) => normalizeUserRule(r)))
} catch { } finally { setRulesLoading(false) }
}, [])
@@ -415,7 +538,10 @@ export function UsersPage() {
}
async function handleDeleteRule(id: number) {
if (!confirm('确定删除?')) return
if (!confirmDangerousDelete('规则')) {
toast.info('已取消删除')
return
}
try {
const data = await del<{ success?: boolean }>(`/api/db/user-rules?id=${id}`)
if (data?.success) loadRules()
@@ -454,6 +580,10 @@ export function UsersPage() {
const [vipRoleModalMember, setVipRoleModalMember] = useState<VipMember | null>(null)
const [vipRoleInput, setVipRoleInput] = useState('')
const [vipRoleSaving, setVipRoleSaving] = useState(false)
const [showVipWebhookModal, setShowVipWebhookModal] = useState(false)
const [vipWebhookModalMember, setVipWebhookModalMember] = useState<VipMember | null>(null)
const [vipWebhookInput, setVipWebhookInput] = useState('')
const [vipWebhookSaving, setVipWebhookSaving] = useState(false)
const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手']
@@ -463,6 +593,12 @@ export function UsersPage() {
setShowVipRoleModal(true)
}
const openVipWebhookModal = (member: VipMember) => {
setVipWebhookModalMember(member)
setVipWebhookInput((member.webhookUrl || '').trim())
setShowVipWebhookModal(true)
}
const handleSetVipRole = async (value: string) => {
const trimmed = value.trim()
if (!vipRoleModalMember) return
@@ -491,6 +627,34 @@ export function UsersPage() {
}
}
const handleSetVipWebhook = async () => {
if (!vipWebhookModalMember) return
const val = vipWebhookInput.trim()
if (val && !/^https?:\/\//i.test(val)) {
toast.error('Webhook 地址需以 http/https 开头')
return
}
setVipWebhookSaving(true)
try {
const res = await put<{ success?: boolean; error?: string }>('/api/db/vip-members/webhook', {
userId: vipWebhookModalMember.id,
webhookUrl: val,
})
if (!res?.success) {
toast.error(res?.error || '保存飞书群 Webhook 失败')
return
}
toast.success(val ? '已保存该超级个体的飞书群 Webhook' : '已清空该超级个体的飞书群 Webhook')
setShowVipWebhookModal(false)
setVipWebhookModalMember(null)
await loadVipMembers()
} catch {
toast.error('保存飞书群 Webhook 失败')
} finally {
setVipWebhookSaving(false)
}
}
const [showVipSortModal, setShowVipSortModal] = useState(false)
const [vipSortModalMember, setVipSortModalMember] = useState<VipMember | null>(null)
const [vipSortInput, setVipSortInput] = useState('')
@@ -599,6 +763,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)
@@ -855,34 +1036,48 @@ export function UsersPage() {
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] overflow-hidden">
{user.avatar ? (
<img
src={user.avatar}
className="w-full h-full rounded-full object-cover"
alt=""
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
const parent = (e.target as HTMLImageElement).parentElement
if (parent) parent.textContent = user.nickname?.charAt(0) || '?'
}}
/>
) : user.nickname?.charAt(0) || '?'}
</div>
<div>
{(() => {
const avatarUrl = resolveUserAvatarByMbti(user.avatar, user.mbti)
const initial = user.nickname?.charAt(0) || '?'
return (
<button
type="button"
title="点击管理 MBTI 默认头像库"
onClick={() => setShowMbtiAvatarDialog(true)}
className="w-10 h-10 shrink-0 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] overflow-hidden ring-1 ring-transparent hover:ring-[#38bdac]/60 transition"
>
{avatarUrl ? (
<img
src={avatarUrl}
className="w-full h-full rounded-full object-cover"
alt=""
onError={(e) => {
const img = e.target as HTMLImageElement
img.style.display = 'none'
if (img.nextElementSibling) return
const span = document.createElement('span')
span.textContent = initial
img.parentElement?.appendChild(span)
}}
/>
) : initial}
</button>
)
})()}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }}
className="font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
className="font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left truncate max-w-[120px]"
>
{user.nickname}
</button>
{user.isAdmin && <Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs"></Badge>}
{user.openId && !user.id?.startsWith('user_') && <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs"></Badge>}
</div>
<p className="text-xs text-gray-500 font-mono">
{user.openId ? user.openId.slice(0, 12) + '...' : user.id?.slice(0, 12)}
<p className="text-xs text-gray-500 font-mono truncate max-w-[140px]" title={user.id}>
{user.id?.slice(0, 16)}{(user.id?.length ?? 0) > 16 ? '…' : ''}
</p>
</div>
</div>
@@ -891,16 +1086,38 @@ export function UsersPage() {
<div className="space-y-1">
{user.phone && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">📱</span><span className="text-gray-300">{user.phone}</span></div>}
{user.wechatId && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">💬</span><span className="text-gray-300">{user.wechatId}</span></div>}
{user.openId && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">🔗</span><span className="text-gray-500 truncate max-w-[100px]" title={user.openId}>{user.openId.slice(0, 12)}...</span></div>}
{!user.phone && !user.wechatId && !user.openId && <span className="text-gray-600 text-xs"></span>}
{!user.phone && !user.wechatId && <span className="text-gray-600 text-xs"></span>}
</div>
</TableCell>
<TableCell>
{user.hasFullBook ? (
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0">VIP</Badge>
) : (
<Badge variant="outline" className="text-gray-500 border-gray-600"></Badge>
)}
{(() => {
const purchase = getPurchaseState(user)
if (purchase.tone === 'vip') {
return (
<div className="space-y-1">
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0">
{purchase.main}
</Badge>
{purchase.sub && <p className="text-[11px] text-amber-300/80">{purchase.sub}</p>}
</div>
)
}
if (purchase.tone === 'paid') {
return (
<div className="space-y-1">
<Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">
{purchase.main}
</Badge>
{purchase.sub && <p className="text-[11px] text-blue-300/80">{purchase.sub}</p>}
</div>
)
}
return (
<Badge variant="outline" className="text-gray-500 border-gray-600">
{purchase.main}
</Badge>
)
})()}
</TableCell>
<TableCell>
<div className="space-y-1">
@@ -915,15 +1132,13 @@ export function UsersPage() {
</TableCell>
{/* RFM 分值列 */}
<TableCell>
{user.rfmScore !== undefined ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<span className="text-white font-bold text-base">{user.rfmScore}</span>
<Badge className={`border-0 text-xs ${getRFMLevelColor(user.rfmLevel)}`}>{user.rfmLevel}</Badge>
</div>
{user.rfmScore != null && user.rfmScore !== undefined ? (
<div className="flex items-center gap-1.5">
<span className="text-white font-bold text-base">{user.rfmScore}</span>
<Badge className={`border-0 text-xs ${getRFMLevelColor(user.rfmLevel)}`}>{user.rfmLevel}</Badge>
</div>
) : (
<span className="text-gray-600 text-sm"> <span className="text-xs text-gray-700"></span></span>
<span className="text-gray-600 text-xs"></span>
)}
</TableCell>
<TableCell>
@@ -1124,13 +1339,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 +1377,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,17 +1427,100 @@ 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' : ''}`} />
</Button>
<Button onClick={() => { setEditingRule(null); setRuleForm({ title: '', description: '', trigger: '', sort: 0, enabled: true }); setShowRuleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Button onClick={() => { setEditingRule(null); setRuleForm({ title: '', description: '', trigger: '', triggerConditions: [], actionType: 'popup', sort: 0, enabled: true }); setShowRuleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
@@ -1243,26 +1536,49 @@ export function UsersPage() {
</div>
) : (
<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>}
{rules.map((rule) => {
const triggerList = normalizeTriggerConditions(rule.triggerConditions)
return (
<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>}
{triggerList.length > 0 && (
<div className="flex flex-wrap gap-0.5 ml-1">
{triggerList.slice(0, 3).map((tc) => {
const opt = TRIGGER_OPTIONS.find((o) => o.value === tc)
return <Badge key={tc} className="bg-purple-500/10 text-purple-400 border border-purple-500/30 text-[9px]">{opt?.label || tc}</Badge>
})}
{triggerList.length > 3 && <span className="text-gray-500 text-[9px]">+{triggerList.length - 3}</span>}
</div>
)}
{rule.actionType && rule.actionType !== 'popup' && (
<Badge className="bg-amber-500/10 text-amber-400 border border-amber-500/30 text-[9px] shrink-0">
{ACTION_TYPE_OPTIONS.find((o) => o.value === rule.actionType)?.label || rule.actionType}
</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, triggerConditions: triggerList, actionType: rule.actionType || 'popup', 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">
<span className="text-gray-600 ml-1">{rule.description.length} </span>
</summary>
<p className="text-gray-400 text-sm mt-1 pl-1 border-l-2 border-gray-700 whitespace-pre-wrap">{rule.description}</p>
</details>
)}
</div>
))}
)
})}
</div>
)}
</TabsContent>
@@ -1312,9 +1628,10 @@ export function UsersPage() {
<TableHead className="text-gray-400 w-12"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 min-w-40"></TableHead>
<TableHead className="text-gray-400 w-16 text-center"></TableHead>
<TableHead className="text-gray-400 w-16 text-center"></TableHead>
<TableHead className="text-gray-400 w-16 text-center"></TableHead>
<TableHead className="text-gray-400 w-20"></TableHead>
<TableHead className="text-gray-400 w-36"></TableHead>
<TableHead className="text-gray-400 w-36 text-right"></TableHead>
</TableRow>
</TableHeader>
@@ -1337,9 +1654,9 @@ export function UsersPage() {
<TableCell className="text-gray-300">{index + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
{m.avatar ? (
{resolveUserAvatarByMbti(m.avatar, m.mbti) ? (
<img
src={m.avatar}
src={resolveUserAvatarByMbti(m.avatar, m.mbti)}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
alt=""
onError={(e) => {
@@ -1376,6 +1693,15 @@ export function UsersPage() {
<TableCell className="text-gray-300">
{m.vipSort ?? index + 1}
</TableCell>
<TableCell className="text-xs">
{m.webhookUrl ? (
<span className="text-[#38bdac] truncate block max-w-[180px]" title={m.webhookUrl}>
</span>
) : (
<span className="text-gray-500"></span>
)}
</TableCell>
<TableCell className="text-right text-xs text-gray-300">
<div className="inline-flex items-center gap-1.5">
<Button
@@ -1391,11 +1717,8 @@ export function UsersPage() {
variant="ghost"
size="sm"
className="h-7 w-7 px-0 text-[#38bdac] hover:text-[#5fe0cd]"
onClick={() => {
setSelectedUserIdForDetail(m.id)
setShowDetailModal(true)
}}
title="编辑资料"
onClick={() => openVipWebhookModal(m)}
title="编辑飞书群Webhook"
>
<Edit3 className="w-3.5 h-3.5" />
</Button>
@@ -1418,8 +1741,19 @@ export function UsersPage() {
</Card>
)}
</TabsContent>
</Tabs>
<Dialog open={showMbtiAvatarDialog} onOpenChange={setShowMbtiAvatarDialog}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-6xl">
<DialogHeader>
<DialogTitle className="text-white">MBTI </DialogTitle>
</DialogHeader>
<MbtiAvatarsManager />
</DialogContent>
</Dialog>
{/* ===== 弹框组件 ===== */}
{/* 添加/编辑用户 */}
@@ -1498,6 +1832,38 @@ export function UsersPage() {
</DialogContent>
</Dialog>
{/* 设置超级个体飞书群 Webhook */}
<Dialog open={showVipWebhookModal} onOpenChange={(open) => { setShowVipWebhookModal(open); if (!open) setVipWebhookModalMember(null) }}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-xl">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
Webhook {vipWebhookModalMember?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Label className="text-gray-300 text-sm">VOX Webhook </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
value={vipWebhookInput}
onChange={(e) => setVipWebhookInput(e.target.value)}
/>
<p className="text-xs text-gray-500">
线
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowVipWebhookModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSetVipWebhook} disabled={vipWebhookSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" />{vipWebhookSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2">{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}{editingUser ? '编辑用户' : '添加用户'}</DialogTitle></DialogHeader>
@@ -1517,12 +1883,71 @@ export function UsersPage() {
{/* 添加/编辑规则 */}
<Dialog open={showRuleModal} onOpenChange={setShowRuleModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><PenLine className="w-5 h-5 text-[#38bdac]" />{editingRule ? '编辑规则' : '添加规则'}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2"><Label className="text-gray-300"> *</Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例匹配后填写头像、付款1980需填写信息" value={ruleForm.title} onChange={(e) => setRuleForm({ ...ruleForm, title: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Textarea className="bg-[#0a1628] border-gray-700 text-white min-h-[80px] resize-none" placeholder="详细说明规则内容..." value={ruleForm.description} onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例:完成匹配、付款后、注册时" value={ruleForm.trigger} onChange={(e) => setRuleForm({ ...ruleForm, trigger: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Textarea className="bg-[#0a1628] border-gray-700 text-white min-h-[60px] resize-none" placeholder="弹窗内容/推送文案..." value={ruleForm.description} onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })} /></div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="space-y-2">
{['用户状态', '阅读行为', '付费行为', '社交行为', '分销行为'].map((group) => {
const items = TRIGGER_OPTIONS.filter((o) => o.group === group)
if (items.length === 0) return null
return (
<div key={group}>
<p className="text-[10px] text-gray-500 mb-1">{group}</p>
<div className="flex flex-wrap gap-1.5">
{items.map((opt) => {
const selected = (ruleForm.triggerConditions || []).includes(opt.value)
return (
<button
key={opt.value}
type="button"
className={`px-2.5 py-1 rounded-md text-xs border transition-colors ${selected ? 'bg-[#38bdac]/20 border-[#38bdac]/50 text-[#38bdac]' : 'bg-[#0a1628] border-gray-700 text-gray-400 hover:border-gray-500'}`}
onClick={() => {
const current = ruleForm.triggerConditions || []
const next = selected ? current.filter((v) => v !== opt.value) : [...current, opt.value]
setRuleForm({ ...ruleForm, triggerConditions: next })
}}
>
{opt.label}
</button>
)
})}
</div>
</div>
)
})}
</div>
{(ruleForm.triggerConditions || []).length > 0 && (
<p className="text-[10px] text-[#38bdac]"> {(ruleForm.triggerConditions || []).length} </p>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white text-xs h-8" placeholder="与小程序一致注册、完成付款、update_avatar、update_nickname 等" value={ruleForm.trigger} onChange={(e) => setRuleForm({ ...ruleForm, trigger: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="grid grid-cols-2 gap-2">
{ACTION_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`p-2 rounded-lg border text-left transition-colors ${ruleForm.actionType === opt.value ? 'bg-[#38bdac]/15 border-[#38bdac]/50' : 'bg-[#0a1628] border-gray-700 hover:border-gray-500'}`}
onClick={() => setRuleForm({ ...ruleForm, actionType: opt.value })}
>
<span className={`text-xs font-medium ${ruleForm.actionType === opt.value ? 'text-[#38bdac]' : 'text-gray-300'}`}>{opt.label}</span>
<p className="text-[10px] text-gray-500 mt-0.5">{opt.desc}</p>
</button>
))}
</div>
</div>
<div className="flex items-center justify-between"><div><Label className="text-gray-300"></Label></div><Switch checked={ruleForm.enabled} onCheckedChange={(c) => setRuleForm({ ...ruleForm, enabled: c })} /></div>
</div>
<DialogFooter>

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}