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:
1
soul-admin/dist/assets/index-DYq6N0y0.css
vendored
1
soul-admin/dist/assets/index-DYq6N0y0.css
vendored
File diff suppressed because one or more lines are too long
1006
soul-admin/dist/assets/index-Dk6CvQRe.js
vendored
Normal file
1006
soul-admin/dist/assets/index-Dk6CvQRe.js
vendored
Normal file
File diff suppressed because one or more lines are too long
955
soul-admin/dist/assets/index-etcBHhA9.js
vendored
955
soul-admin/dist/assets/index-etcBHhA9.js
vendored
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-qjssBjc3.css
vendored
Normal file
1
soul-admin/dist/assets/index-qjssBjc3.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
173
soul-admin/src/components/modules/mbti/MbtiAvatarsManager.tsx
Normal file
173
soul-admin/src/components/modules/mbti/MbtiAvatarsManager.tsx
Normal 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
130
soul-admin/src/lib/mbtiAvatarPrompts.ts
Normal file
130
soul-admin/src/lib/mbtiAvatarPrompts.ts
Normal 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)}`
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 或微信 AppID;AppSecret 仅存服务端(不下发小程序),供后续开放接口与台账使用。
|
||||
</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 已锁定
|
||||
// 留空则后端自动生成;自定义时与库一致:1~50 字符,勿含 #、逗号、换行
|
||||
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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 : '-'}
|
||||
|
||||
@@ -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">小程序界面文案 mpUi(JSON)</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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user