feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本
- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整 - soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新 - soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL - .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle Made-with: Cursor
This commit is contained in:
1
soul-admin/dist/assets/index-CDCvtX8z.css
vendored
Normal file
1
soul-admin/dist/assets/index-CDCvtX8z.css
vendored
Normal file
File diff suppressed because one or more lines are too long
965
soul-admin/dist/assets/index-CW7Mmh6Q.js
vendored
Normal file
965
soul-admin/dist/assets/index-CW7Mmh6Q.js
vendored
Normal file
File diff suppressed because one or more lines are too long
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
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
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-CW7Mmh6Q.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CDCvtX8z.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,9 @@ import {
|
||||
Link2,
|
||||
FileText,
|
||||
Cloud,
|
||||
Smile,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
|
||||
@@ -72,6 +75,8 @@ interface MpConfig {
|
||||
mchId?: string
|
||||
minWithdraw?: number
|
||||
auditMode?: boolean
|
||||
/** 小程序界面文案与跳转,与 soul-api defaultMpUi 结构一致,服务端会与默认值深合并 */
|
||||
mpUi?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface OssConfig {
|
||||
@@ -113,6 +118,39 @@ const defaultFeatures: FeatureConfig = {
|
||||
aboutEnabled: true,
|
||||
}
|
||||
|
||||
/** 与管理端保存后、后端 deepMergeMpUi 的默认结构对齐,供「填入模板」与文档说明 */
|
||||
const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
|
||||
tabBar: { home: '首页', chapters: '目录', match: '找伙伴', my: '我的' },
|
||||
chaptersPage: {
|
||||
bookTitle: '一场SOUL的创业实验场',
|
||||
bookSubtitle: '来自Soul派对房的真实商业故事',
|
||||
},
|
||||
homePage: {
|
||||
logoTitle: '卡若创业派对',
|
||||
logoSubtitle: '来自派对房的真实故事',
|
||||
linkKaruoText: '点击链接卡若',
|
||||
searchPlaceholder: '搜索章节标题或内容...',
|
||||
bannerTag: '推荐',
|
||||
bannerReadMoreText: '点击阅读',
|
||||
superSectionTitle: '超级个体',
|
||||
superSectionLinkText: '获客入口',
|
||||
superSectionLinkPath: '/pages/match/match',
|
||||
pickSectionTitle: '精选推荐',
|
||||
latestSectionTitle: '最新新增',
|
||||
},
|
||||
myPage: {
|
||||
cardLabel: '名片',
|
||||
vipLabelVip: '会员中心',
|
||||
vipLabelGuest: '成为会员',
|
||||
cardPath: '',
|
||||
vipPath: '/pages/vip/vip',
|
||||
readStatLabel: '已读章节',
|
||||
recentReadTitle: '最近阅读',
|
||||
readStatPath: '/pages/reading-records/reading-records?focus=all',
|
||||
recentReadPath: '/pages/reading-records/reading-records?focus=recent',
|
||||
},
|
||||
}
|
||||
|
||||
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
|
||||
type TabKey = (typeof TAB_KEYS)[number]
|
||||
|
||||
@@ -124,6 +162,7 @@ export function SettingsPage() {
|
||||
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
|
||||
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
|
||||
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
|
||||
const [mpUiJson, setMpUiJson] = useState('{}')
|
||||
const [ossConfig, setOssConfig] = useState<OssConfig>({})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -133,6 +172,39 @@ export function SettingsPage() {
|
||||
const [dialogIsError, setDialogIsError] = useState(false)
|
||||
const [featureSwitchSaving, setFeatureSwitchSaving] = useState(false)
|
||||
|
||||
const MBTI_TYPES = [
|
||||
'INTJ','INTP','ENTJ','ENTP',
|
||||
'INFJ','INFP','ENFJ','ENFP',
|
||||
'ISTJ','ISFJ','ESTJ','ESFJ',
|
||||
'ISTP','ISFP','ESTP','ESFP',
|
||||
]
|
||||
const [mbtiAvatars, setMbtiAvatars] = useState<Record<string, string>>({})
|
||||
const [mbtiLoading, setMbtiLoading] = useState(false)
|
||||
const [mbtiSaving, setMbtiSaving] = useState(false)
|
||||
|
||||
const loadMbtiAvatars = async () => {
|
||||
setMbtiLoading(true)
|
||||
try {
|
||||
const res = await get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
|
||||
if (res?.avatars) setMbtiAvatars(res.avatars)
|
||||
} catch { /* ignore */ }
|
||||
finally { setMbtiLoading(false) }
|
||||
}
|
||||
|
||||
const saveMbtiAvatars = async () => {
|
||||
setMbtiSaving(true)
|
||||
try {
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/mbti-avatars', { avatars: mbtiAvatars })
|
||||
if (!res || res.success === false) {
|
||||
showResult('保存失败', res?.error ?? '未知错误', true)
|
||||
return
|
||||
}
|
||||
showResult('已保存', 'MBTI 头像映射已保存,无头像的超级个体将自动使用对应性格头像。')
|
||||
} catch (error) {
|
||||
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
|
||||
} finally { setMbtiSaving(false) }
|
||||
}
|
||||
|
||||
const showResult = (title: string, message: string, isError = false) => {
|
||||
setDialogTitle(title)
|
||||
setDialogMessage(message)
|
||||
@@ -153,8 +225,18 @@ export function SettingsPage() {
|
||||
if (!res || (res as { success?: boolean }).success === false) return
|
||||
if (res.featureConfig && Object.keys(res.featureConfig).length)
|
||||
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
|
||||
if (res.mpConfig && typeof res.mpConfig === 'object')
|
||||
setMpConfig((prev) => ({ ...prev, ...res.mpConfig }))
|
||||
if (res.mpConfig && typeof res.mpConfig === 'object') {
|
||||
const merged = { ...res.mpConfig } as MpConfig
|
||||
setMpConfig((prev) => ({ ...prev, ...merged }))
|
||||
const raw = merged.mpUi
|
||||
setMpUiJson(
|
||||
JSON.stringify(
|
||||
raw != null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (res.ossConfig && typeof res.ossConfig === 'object')
|
||||
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
|
||||
if (res.siteSettings && typeof res.siteSettings === 'object') {
|
||||
@@ -175,6 +257,7 @@ export function SettingsPage() {
|
||||
}
|
||||
}
|
||||
load()
|
||||
loadMbtiAvatars()
|
||||
}, [])
|
||||
|
||||
const saveFeatureConfigOnly = async (
|
||||
@@ -235,6 +318,25 @@ export function SettingsPage() {
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
let mpUi: Record<string, unknown> = {}
|
||||
try {
|
||||
const t = mpUiJson.trim()
|
||||
if (t) {
|
||||
const parsed: unknown = JSON.parse(t)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
mpUi = parsed as Record<string, unknown>
|
||||
} else {
|
||||
showResult('保存失败', '小程序文案 mpUi 须为 JSON 对象(非数组)', true)
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
showResult('保存失败', '小程序文案 mpUi 不是合法 JSON', true)
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
|
||||
featureConfig,
|
||||
siteSettings: {
|
||||
@@ -251,6 +353,7 @@ export function SettingsPage() {
|
||||
mchId: mpConfig.mchId || '',
|
||||
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
|
||||
auditMode: mpConfig.auditMode ?? false,
|
||||
mpUi,
|
||||
},
|
||||
ossConfig: Object.keys(ossConfig).length
|
||||
? {
|
||||
@@ -599,6 +702,31 @@ export function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-gray-700/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label className="text-gray-300">小程序界面文案 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>
|
||||
|
||||
@@ -804,6 +932,86 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Smile className="w-5 h-5 text-[#38bdac]" />
|
||||
MBTI 头像组
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
为 16 种 MBTI 性格类型配置默认头像 URL。无头像的超级个体将自动使用对应性格的头像。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{mbtiLoading ? (
|
||||
<p className="text-gray-500 text-sm">加载中...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{MBTI_TYPES.map((t) => (
|
||||
<div key={t} className="flex items-center gap-2">
|
||||
<span className="text-xs text-[#38bdac] font-mono w-10 flex-shrink-0">{t}</span>
|
||||
{mbtiAvatars[t] && (
|
||||
<img src={mbtiAvatars[t]} alt={t} className="w-8 h-8 rounded-full flex-shrink-0 object-cover border border-gray-600" />
|
||||
)}
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white text-xs h-8 flex-1"
|
||||
placeholder="头像 URL"
|
||||
value={mbtiAvatars[t] ?? ''}
|
||||
onChange={(e) =>
|
||||
setMbtiAvatars((prev) => ({ ...prev, [t]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={saveMbtiAvatars}
|
||||
disabled={mbtiSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="w-3 h-3 mr-1" />
|
||||
{mbtiSaving ? '保存中...' : '保存头像映射'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Eye className="w-5 h-5 text-[#38bdac]" />
|
||||
小程序模块显隐说明
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
以下模块受上方开关和审核模式共同控制
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{[
|
||||
{ mod: '找伙伴', ctrl: '找伙伴功能开关', icon: <Users className="w-3 h-3" /> },
|
||||
{ mod: '推广中心 / 推荐好友', ctrl: '推广功能开关', icon: <Gift className="w-3 h-3" /> },
|
||||
{ mod: '搜索', ctrl: '搜索功能开关', icon: <BookOpen className="w-3 h-3" /> },
|
||||
{ mod: '关于页面', ctrl: '关于页面开关', icon: <UserCircle className="w-3 h-3" /> },
|
||||
{ mod: '支付 / VIP / 充值 / 收益', ctrl: '审核模式', icon: <ShieldCheck className="w-3 h-3" /> },
|
||||
{ mod: '超级个体名片', ctrl: '审核模式', icon: <Smile className="w-3 h-3" /> },
|
||||
{ mod: '首页获客入口', ctrl: '已移除', icon: <EyeOff className="w-3 h-3" /> },
|
||||
].map((r) => (
|
||||
<div key={r.mod} className="flex items-center gap-2 p-2 rounded bg-[#0a1628] border border-gray-700/30">
|
||||
{r.icon}
|
||||
<div>
|
||||
<span className="text-white">{r.mod}</span>
|
||||
<span className="text-gray-500 ml-1">← {r.ctrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -177,6 +177,13 @@ export function UsersPage() {
|
||||
// ===== 用户旅程总览 =====
|
||||
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
|
||||
const [journeyLoading, setJourneyLoading] = useState(false)
|
||||
const [journeyStage, setJourneyStage] = useState<string | null>(null)
|
||||
const [journeyUsers, setJourneyUsers] = useState<{ id: string; nickname: string; phone: string; createdAt: string }[]>([])
|
||||
const [journeyUsersLoading, setJourneyUsersLoading] = useState(false)
|
||||
const [trackUserId, setTrackUserId] = useState<string | null>(null)
|
||||
const [trackUserNick, setTrackUserNick] = useState('')
|
||||
const [userTracks, setUserTracks] = useState<{ id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[]>([])
|
||||
const [userTracksLoading, setUserTracksLoading] = useState(false)
|
||||
|
||||
// ===== 获客列表(存客宝) =====
|
||||
const [leadsRecords, setLeadsRecords] = useState<{
|
||||
@@ -599,6 +606,23 @@ export function UsersPage() {
|
||||
if (data?.success && data.stats) setJourneyStats(data.stats)
|
||||
} catch { } finally { setJourneyLoading(false) }
|
||||
}, [])
|
||||
const loadJourneyUsers = useCallback(async (stage: string) => {
|
||||
setJourneyStage(stage)
|
||||
setJourneyUsersLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; users?: { id: string; nickname: string; phone: string; createdAt: string }[] }>(`/api/db/users/journey-users?stage=${stage}&limit=50`)
|
||||
if (data?.success && data.users) setJourneyUsers(data.users)
|
||||
} catch { } finally { setJourneyUsersLoading(false) }
|
||||
}, [])
|
||||
const loadUserTracks = useCallback(async (userId: string, nick: string) => {
|
||||
setTrackUserId(userId)
|
||||
setTrackUserNick(nick)
|
||||
setUserTracksLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; tracks?: { id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[] }>(`/api/db/users/tracks?userId=${userId}&limit=50`)
|
||||
if (data?.success && data.tracks) setUserTracks(data.tracks)
|
||||
} catch { } finally { setUserTracksLoading(false) }
|
||||
}, [])
|
||||
|
||||
// ===== 批量用户补全 =====
|
||||
const [batchEnrichLoading, setBatchEnrichLoading] = useState(false)
|
||||
@@ -1124,13 +1148,8 @@ export function UsersPage() {
|
||||
<div key={stage.id} className="relative flex flex-col items-center">
|
||||
{/* 阶段卡片 */}
|
||||
<div
|
||||
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer hover:opacity-80 transition-opacity`}
|
||||
onClick={() => {
|
||||
const sp = new URLSearchParams(searchParams)
|
||||
sp.delete('tab')
|
||||
sp.set('search', stage.label)
|
||||
setSearchParams(sp)
|
||||
}}
|
||||
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer hover:opacity-80 transition-opacity ${journeyStage === stage.id ? 'ring-2 ring-[#38bdac]' : ''}`}
|
||||
onClick={() => loadJourneyUsers(stage.id)}
|
||||
title={`点击查看「${stage.label}」阶段的用户`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{stage.icon}</div>
|
||||
@@ -1167,7 +1186,7 @@ export function UsersPage() {
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
{[
|
||||
{ step: '① 注册', action: '微信 OAuth 或手机号注册', next: '引导填写头像' },
|
||||
{ step: '① 注册', action: '微信 OAuth 或手机号注册', next: '提示设置头像与昵称' },
|
||||
{ step: '② 浏览', action: '点击章节/阅读免费内容', next: '触发绑定手机' },
|
||||
{ step: '③ 首付', action: '购买单章或全书', next: '推送分销功能' },
|
||||
{ step: '④ VIP', action: '¥1980 购买全书', next: '进入 VIP 私域群' },
|
||||
@@ -1217,12 +1236,95 @@ export function UsersPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 阶段用户列表(点击阶段卡片后展开) */}
|
||||
{journeyStage && (
|
||||
<div className="mt-6 bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">
|
||||
{JOURNEY_STAGES.find(s => s.id === journeyStage)?.icon}{' '}
|
||||
{JOURNEY_STAGES.find(s => s.id === journeyStage)?.label} 阶段用户
|
||||
</span>
|
||||
<Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-xs">{journeyUsers.length} 人</Badge>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setJourneyStage(null)} className="text-gray-400 hover:text-white"><X className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
{journeyUsersLoading ? (
|
||||
<div className="flex items-center justify-center py-8"><RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" /></div>
|
||||
) : journeyUsers.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-6">该阶段暂无用户</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-gray-700">
|
||||
<TableHead className="text-gray-400">昵称</TableHead>
|
||||
<TableHead className="text-gray-400">手机号</TableHead>
|
||||
<TableHead className="text-gray-400">注册时间</TableHead>
|
||||
<TableHead className="text-gray-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{journeyUsers.map(u => (
|
||||
<TableRow key={u.id} className="border-gray-700/50 hover:bg-[#0a1628]">
|
||||
<TableCell className="text-white">{u.nickname || '微信用户'}</TableCell>
|
||||
<TableCell className="text-gray-300">{u.phone || '-'}</TableCell>
|
||||
<TableCell className="text-gray-400 text-xs">{u.createdAt ? new Date(u.createdAt).toLocaleString('zh-CN') : '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" className="text-[#38bdac] hover:bg-[#38bdac]/10" onClick={() => loadUserTracks(u.id, u.nickname || '微信用户')}>
|
||||
<Eye className="w-4 h-4 mr-1" /> 行为轨迹
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户行为轨迹弹窗 */}
|
||||
<Dialog open={!!trackUserId} onOpenChange={(open) => { if (!open) setTrackUserId(null) }}>
|
||||
<DialogContent className="sm:max-w-[600px] bg-[#0f2137] border-gray-700 text-white max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Navigation className="w-5 h-5 text-[#38bdac]" />
|
||||
{trackUserNick} 的行为轨迹
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{userTracksLoading ? (
|
||||
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /></div>
|
||||
) : userTracks.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">该用户暂无行为记录</p>
|
||||
) : (
|
||||
<div className="relative pl-6 space-y-0">
|
||||
<div className="absolute left-[11px] top-2 bottom-2 w-0.5 bg-gray-700" />
|
||||
{userTracks.map((t, idx) => (
|
||||
<div key={t.id || idx} className="relative flex items-start gap-3 py-2">
|
||||
<div className="absolute left-[-13px] top-3 w-2.5 h-2.5 rounded-full bg-[#38bdac] border-2 border-[#0f2137] z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-white text-sm font-medium">{t.actionLabel}</span>
|
||||
{t.module && <Badge className="bg-purple-500/10 text-purple-400 border border-purple-500/30 text-[10px]">{t.module}</Badge>}
|
||||
</div>
|
||||
{(t.chapterTitle || t.target) && (
|
||||
<p className="text-gray-400 text-xs mt-0.5 truncate">{t.chapterTitle || t.target}</p>
|
||||
)}
|
||||
<p className="text-gray-600 text-[10px] mt-0.5">{t.timeAgo} · {t.createdAt ? new Date(t.createdAt).toLocaleString('zh-CN') : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 规则配置 ===== */}
|
||||
<TabsContent value="rules">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-gray-400 text-sm">用户旅程引导规则,定义各行为节点的触发条件与引导内容</p>
|
||||
<p className="text-gray-400 text-sm">用户旅程触达规则:各行为节点的触发条件与展示文案(偏利他说明,少用命令式)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadRules} disabled={rulesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${rulesLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||
@@ -1244,23 +1346,26 @@ export function UsersPage() {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className={`p-4 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<PenLine className="w-4 h-4 text-[#38bdac] shrink-0" />
|
||||
<span className="text-white font-medium">{rule.title}</span>
|
||||
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-xs">触发:{rule.trigger}</Badge>}
|
||||
<Badge className={`text-xs border-0 ${rule.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}`}>{rule.enabled ? '启用' : '禁用'}</Badge>
|
||||
</div>
|
||||
{rule.description && <p className="text-gray-400 text-sm ml-6">{rule.description}</p>}
|
||||
<div key={rule.id} className={`p-3 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-gray-600 text-xs font-mono w-5 shrink-0 text-right">#{rule.sort}</span>
|
||||
<PenLine className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
|
||||
<span className="text-white font-medium text-sm truncate">{rule.title}</span>
|
||||
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-[10px] shrink-0">{rule.trigger}</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4 shrink-0">
|
||||
<div className="flex items-center gap-1.5 ml-3 shrink-0">
|
||||
<Switch checked={rule.enabled} onCheckedChange={() => handleToggleRule(rule)} />
|
||||
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"><Edit3 className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10"><Trash2 className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"><Edit3 className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 w-7 p-0"><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
{rule.description && (
|
||||
<details className="ml-[52px] mt-1">
|
||||
<summary className="text-gray-500 text-xs cursor-pointer hover:text-gray-400 select-none">查看描述</summary>
|
||||
<p className="text-gray-400 text-sm mt-1 pl-1 border-l-2 border-gray-700">{rule.description}</p>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user