Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	miniprogram/app.js
#	miniprogram/app.json
#	miniprogram/pages/chapters/chapters.js
#	miniprogram/pages/chapters/chapters.wxml
#	miniprogram/pages/chapters/chapters.wxss
#	miniprogram/pages/index/index.js
#	miniprogram/pages/index/index.wxml
#	miniprogram/pages/match/match.js
#	miniprogram/pages/my/my.js
#	miniprogram/pages/my/my.wxml
#	miniprogram/pages/read/read.js
#	miniprogram/pages/read/read.wxml
#	miniprogram/pages/read/read.wxss
#	miniprogram/pages/referral/referral.js
#	miniprogram/pages/search/search.js
#	miniprogram/pages/vip/vip.js
#	miniprogram/pages/wallet/wallet.wxml
#	miniprogram/project.private.config.json
#	soul-admin/dist/index.html
#	soul-admin/src/pages/dashboard/DashboardPage.tsx
#	soul-admin/src/pages/settings/SettingsPage.tsx
#	soul-api/go.mod
#	soul-api/internal/handler/admin_dashboard.go
#	soul-api/internal/handler/db.go
#	soul-api/wechat/info.log
#	开发文档/10、项目管理/运营与变更.md
#	开发文档/README.md
This commit is contained in:
Alex-larget
2026-03-18 17:55:34 +08:00
125 changed files with 46439 additions and 2916 deletions

View File

@@ -347,8 +347,8 @@ export function ChapterTree({
)
}
// 2026每日派对干货独立篇章带六点拖拽、可拖可放
const is2026Daily = part.title === '2026每日派对干货' || part.title.includes('2026每日派对干货')
// 2026每日派对干货独立篇章带六点拖拽、可拖可放(以 part_id 识别,标题来自 DB
const is2026Daily = part.id === 'part-2026-daily'
if (is2026Daily) {
const partDragOver = isDragOver('part', part.id)
return (

View File

@@ -158,32 +158,21 @@ function buildTree(sections: SectionListItem[]): Part[] {
hotRank: s.hotRank ?? 0,
})
}
// 确保「2026每日派对干货」篇章存在不在第六篇编号体系内
const DAILY_PART_ID = 'part-2026-daily'
const DAILY_PART_TITLE = '2026每日派对干货'
const hasDailyPart = Array.from(partMap.values()).some((p) => p.title === DAILY_PART_TITLE || p.title.includes(DAILY_PART_TITLE))
if (!hasDailyPart) {
partMap.set(DAILY_PART_ID, {
id: DAILY_PART_ID,
title: DAILY_PART_TITLE,
chapters: new Map([['chapter-2026-daily', { id: 'chapter-2026-daily', title: DAILY_PART_TITLE, sections: [] }]]),
})
}
const parts = Array.from(partMap.values()).map((p) => ({
...p,
chapters: Array.from(p.chapters.values()),
}))
// 固定顺序:序言首位,2026每日派对干货(附录前),附录/尾声末位
const orderKey = (t: string) => {
if (t.includes('序言')) return 0
if (t.includes(DAILY_PART_TITLE)) return 1.5
if (t.includes('附录')) return 2
if (t.includes('尾声')) return 3
// 固定顺序:序言首位,part-2026-daily(附录前),附录/尾声末位;标题均来自 DB
const orderKey = (p: { id: string; title: string }) => {
if (p.title.includes('序言')) return 0
if (p.id === 'part-2026-daily') return 1.5
if (p.title.includes('附录')) return 2
if (p.title.includes('尾声')) return 3
return 1
}
return parts.sort((a, b) => {
const ka = orderKey(a.title)
const kb = orderKey(b.title)
const ka = orderKey(a)
const kb = orderKey(b)
if (ka !== kb) return ka - kb
return 0
})

View File

@@ -1,8 +1,7 @@
import { useState, useEffect } from 'react'
import { normalizeImageUrl } from '@/lib/utils'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
import { get } from '@/api/client'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
@@ -62,13 +61,6 @@ interface OrdersRes {
total?: number
}
function maskPhone(phone?: string) {
if (!phone) return ''
const digits = phone.replace(/\s+/g, '')
if (digits.length < 7) return digits
return `${digits.slice(0, 3)}****${digits.slice(-4)}`
}
export function DashboardPage() {
const navigate = useNavigate()
const [statsLoading, setStatsLoading] = useState(true)
@@ -80,18 +72,17 @@ export function DashboardPage() {
const [paidOrderCount, setPaidOrderCount] = useState(0)
const [totalRevenue, setTotalRevenue] = useState(0)
const [conversionRate, setConversionRate] = useState(0)
const [giftedTotal, setGiftedTotal] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
const [detailUserId, setDetailUserId] = useState<string | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [giftedTotal, setGiftedTotal] = useState(0)
const [ordersExpanded, setOrdersExpanded] = useState(false)
const [trackPeriod, setTrackPeriod] = useState<string>('week')
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 [ordersExpanded, setOrdersExpanded] = useState(false)
const showError = (err: unknown) => {
const e = err as Error & { status?: number; name?: string }
@@ -149,7 +140,7 @@ export function DashboardPage() {
const loadOrders = async () => {
try {
const res = await get<{ success?: boolean; recentOrders?: OrderRow[] }>(
'/api/admin/dashboard/recent-orders',
'/api/admin/dashboard/recent-orders?limit=10',
init
)
if (res?.success && res.recentOrders) setPurchases(res.recentOrders)
@@ -160,7 +151,7 @@ export function DashboardPage() {
const ordersData = await get<OrdersRes>('/api/admin/orders?page=1&pageSize=20&status=paid', init)
const orders = ordersData?.orders ?? []
const paid = orders.filter((p) => ['paid', 'completed', 'success'].includes(p.status || ''))
setPurchases(paid.slice(0, 10))
setPurchases(paid.slice(0, 5))
} catch {
setPurchases([])
}
@@ -230,6 +221,17 @@ export function DashboardPage() {
const amount = typeof p.amount === 'number' ? p.amount.toFixed(2) : parseFloat(String(p.amount || '0')).toFixed(2)
return { title: `余额充值 ¥${amount}`, subtitle: '余额充值' }
}
if (type === 'gift_pay') {
const amount = typeof p.amount === 'number' ? p.amount.toFixed(2) : parseFloat(String(p.amount || '0')).toFixed(2)
return { title: `代付 ¥${amount}`, subtitle: '好友代付' }
}
if (type === 'gift_pay_batch') {
const amount = typeof p.amount === 'number' ? p.amount.toFixed(2) : parseFloat(String(p.amount || '0')).toFixed(2)
return { title: desc || `代付分享 ¥${amount}`, subtitle: '代付分享' }
}
if (type === 'section' && desc.includes('代付领取')) {
return { title: desc.replace('代付领取 - ', ''), subtitle: '代付领取' }
}
if (desc) {
if (type === 'section' && desc.includes('章节')) {
if (desc.includes('-')) {
@@ -294,12 +296,12 @@ export function DashboardPage() {
},
{
title: '转化率',
value: statsLoading ? null : `${conversionRate.toFixed(1)}%`,
value: statsLoading ? null : `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
sub: null as string | null,
icon: TrendingUp,
color: 'text-amber-400',
bg: 'bg-amber-500/20',
link: '/users',
icon: BookOpen,
color: 'text-orange-400',
bg: 'bg-orange-500/20',
link: '/distribution',
},
]
@@ -318,7 +320,7 @@ export function DashboardPage() {
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat, index) => (
<Card
key={index}
@@ -371,7 +373,7 @@ export function DashboardPage() {
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
30
</button>
</CardHeader>
<CardContent>
@@ -398,7 +400,6 @@ export function DashboardPage() {
const buyer =
p.userNickname ||
users.find((u) => u.id === p.userId)?.nickname ||
maskPhone(users.find((u) => u.id === p.userId)?.phone) ||
'匿名用户'
return (
@@ -409,9 +410,9 @@ export function DashboardPage() {
<div className="flex items-start gap-3 flex-1">
{p.userAvatar ? (
<img
src={normalizeImageUrl(p.userAvatar)}
src={p.userAvatar}
alt={buyer}
className="w-9 h-9 rounded-full object-cover shrink-0 mt-0.5"
className="w-9 h-9 rounded-full object-cover flex-shrink-0 mt-0.5"
onError={(e) => {
e.currentTarget.style.display = 'none'
const next = e.currentTarget.nextElementSibling as HTMLElement
@@ -420,7 +421,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] 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] flex-shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
>
{buyer.charAt(0)}
</div>
@@ -435,7 +436,7 @@ export function DashboardPage() {
{buyer}
</button>
<span className="text-gray-600">·</span>
<span className="text-sm font-medium text-white truncate" title={product.title}>
<span className="text-sm font-medium text-white truncate">
{product.title}
</span>
</div>
@@ -460,7 +461,7 @@ export function DashboardPage() {
</div>
</div>
<div className="text-right ml-4 shrink-0">
<div className="text-right ml-4 flex-shrink-0">
<p className="text-sm font-bold text-[#38bdac]">
+¥{Number(p.amount).toFixed(2)}
</p>
@@ -471,22 +472,21 @@ export function DashboardPage() {
</div>
)
})}
{purchases.length > 4 && !ordersExpanded && (
<button
type="button"
onClick={() => setOrdersExpanded(true)}
className="w-full py-2 text-sm text-[#38bdac] hover:text-[#2da396] border border-dashed border-gray-600 rounded-lg hover:border-[#38bdac]/50 transition-colors"
>
</button>
)}
{purchases.length === 0 && !ordersLoading && (
<div className="text-center py-12">
<ShoppingBag className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
</div>
)}
{purchases.length > 4 && (
<button
type="button"
onClick={() => setOrdersExpanded(!ordersExpanded)}
className="w-full py-2 text-xs text-gray-400 hover:text-[#38bdac] transition-colors flex items-center justify-center gap-1"
>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${ordersExpanded ? 'rotate-90' : 'rotate-270'}`} />
{ordersExpanded ? '收起' : `展开更多(共 ${purchases.length} 条)`}
</button>
)}
</>
)}
</div>
@@ -509,13 +509,13 @@ export function DashboardPage() {
{users
.slice(0, 5)
.map((u) => (
<div
<div
key={u.id}
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
>
<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]">
{(u.nickname || maskPhone(u.phone) || '?').charAt(0)}
{u.nickname?.charAt(0) || '?'}
</div>
<div>
<button
@@ -523,9 +523,9 @@ export function DashboardPage() {
onClick={() => { setDetailUserId(u.id); setShowDetailModal(true) }}
className="text-sm font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
>
{u.nickname || maskPhone(u.phone) || '匿名用户'}
{u.nickname || '匿名用户'}
</button>
<p className="text-xs text-gray-500">{maskPhone(u.phone) || '未填写手机号'}</p>
<p className="text-xs text-gray-500">{u.phone || '-'}</p>
</div>
</div>
<p className="text-xs text-gray-400">
@@ -545,8 +545,7 @@ export function DashboardPage() {
</Card>
</div>
{/* 分类标签点击统计 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mt-8">
<Card className="mt-8 bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[#38bdac]" />

View File

@@ -154,6 +154,8 @@ export function DistributionPage() {
productType: string
productId: string
amount: number
quantity?: number
redeemedCount?: number
description: string
status: string
payerUserId?: string
@@ -1133,7 +1135,8 @@ export function DistributionPage() {
onChange={(e) => { setGiftPayStatusFilter(e.target.value); setGiftPayPage(1) }}
>
<option value=""></option>
<option value="pending"></option>
<option value="pending"></option>
<option value="pending_pay"></option>
<option value="paid"></option>
<option value="cancelled"></option>
<option value="expired"></option>
@@ -1153,7 +1156,8 @@ export function DistributionPage() {
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400">/</th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400">/</th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
</tr>
@@ -1169,18 +1173,21 @@ export function DistributionPage() {
<p className="text-white">{r.productType} · ¥{r.amount.toFixed(2)}</p>
{r.description && <p className="text-gray-500 text-xs">{r.description}</p>}
</td>
<td className="p-4 text-gray-400">
{(r.quantity ?? 1) > 1 ? `${r.quantity}份 / 已领${r.redeemedCount ?? 0}` : '-'}
</td>
<td className="p-4 text-gray-400">{r.payerNick || (r.payerUserId ? r.payerUserId : '-')}</td>
<td className="p-4">
<Badge
className={
r.status === 'paid'
? 'bg-green-500/20 text-green-400 border-0'
: r.status === 'pending'
: r.status === 'pending' || r.status === 'pending_pay'
? 'bg-amber-500/20 text-amber-400 border-0'
: 'bg-gray-500/20 text-gray-400 border-0'
}
>
{r.status === 'paid' ? '已支付' : r.status === 'pending' ? '待支付' : r.status === 'cancelled' ? '已取消' : '已过期'}
{r.status === 'paid' ? '已支付' : r.status === 'pending' || r.status === 'pending_pay' ? '待支付' : r.status === 'cancelled' ? '已取消' : '已过期'}
</Badge>
</td>
<td className="p-4 text-gray-400 text-sm">

View File

@@ -122,7 +122,10 @@ export function OrdersPage() {
return { name: `余额充值 ¥${amount}`, type: '余额充值' }
}
if (desc) {
if (type === 'section' && desc.includes('章节')) {
if (type === 'section' && (desc.includes('章节') || desc.includes('代付领取'))) {
if (desc.includes('代付领取')) {
return { name: desc.replace('代付领取 - ', ''), type: '代付领取' }
}
if (desc.includes('-')) {
const parts = desc.split('-')
if (parts.length >= 3) {
@@ -314,7 +317,12 @@ export function OrdersPage() {
<div>
<p className="text-white text-sm flex items-center gap-2">
{getUserNickname(purchase)}
{purchase.payerUserId && (
{purchase.paymentMethod === 'gift_pay' && (
<Badge className="bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/20 border-0 text-xs">
</Badge>
)}
{purchase.payerUserId && purchase.paymentMethod !== 'gift_pay' && (
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0 text-xs">
</Badge>
@@ -322,7 +330,9 @@ export function OrdersPage() {
</p>
<p className="text-gray-500 text-xs">{getUserPhone(purchase.userId)}</p>
{purchase.payerUserId && purchase.payerNickname && (
<p className="text-amber-400/80 text-xs mt-0.5">{purchase.payerNickname}</p>
<p className="text-amber-400/80 text-xs mt-0.5">
{purchase.paymentMethod === 'gift_pay' ? '赠送人:' : '代付人:'}{purchase.payerNickname}
</p>
)}
</div>
</TableCell>

View File

@@ -34,6 +34,7 @@ import {
Smartphone,
ShieldCheck,
Link2,
FileText,
Cloud,
} from 'lucide-react'
import { get, post } from '@/api/client'
@@ -75,10 +76,10 @@ interface MpConfig {
interface OssConfig {
endpoint?: string
accessKeyId?: string
accessKeySecret?: string
bucket?: string
region?: string
accessKeyId?: string
accessKeySecret?: string
}
const defaultMpConfig: MpConfig = {
@@ -105,14 +106,6 @@ const defaultSettings: LocalSettings = {
ckbLeadApiKey: '',
}
const defaultOssConfig: OssConfig = {
endpoint: '',
accessKeyId: '',
accessKeySecret: '',
bucket: '',
region: '',
}
const defaultFeatures: FeatureConfig = {
matchEnabled: true,
referralEnabled: true,
@@ -131,7 +124,7 @@ export function SettingsPage() {
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [ossConfig, setOssConfig] = useState<OssConfig>(defaultOssConfig)
const [ossConfig, setOssConfig] = useState<OssConfig>({})
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
@@ -223,7 +216,7 @@ export function SettingsPage() {
setAuditModeSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
mp_config: next,
mpConfig: next,
})
if (!res || (res as { success?: boolean }).success === false) {
setMpConfig(prev)
@@ -257,14 +250,17 @@ export function SettingsPage() {
withdrawSubscribeTmplId: mpConfig.withdrawSubscribeTmplId || '',
mchId: mpConfig.mchId || '',
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
auditMode: mpConfig.auditMode ?? false,
},
ossConfig: {
endpoint: ossConfig.endpoint || '',
accessKeyId: ossConfig.accessKeyId || '',
accessKeySecret: ossConfig.accessKeySecret || '',
bucket: ossConfig.bucket || '',
region: ossConfig.region || '',
},
ossConfig: Object.keys(ossConfig).length
? {
endpoint: ossConfig.endpoint ?? '',
bucket: ossConfig.bucket ?? '',
region: ossConfig.region ?? '',
accessKeyId: ossConfig.accessKeyId ?? '',
accessKeySecret: ossConfig.accessKeySecret ?? '',
}
: undefined,
})
if (!res || (res as { success?: boolean }).success === false) {
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
@@ -331,7 +327,7 @@ export function SettingsPage() {
value="api-docs"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<BookOpen className="w-4 h-4 mr-2" />
<FileText className="w-4 h-4 mr-2" />
API
</TabsTrigger>
</TabsList>
@@ -606,10 +602,10 @@ export function SettingsPage() {
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Cloud className="w-5 h-5 text-[#38bdac]" />
OSS
OSS
</CardTitle>
<CardDescription className="text-gray-400">
endpointbucketaccessKey /
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -625,6 +621,17 @@ export function SettingsPage() {
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Bucket</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="bucket 名称"
value={ossConfig.bucket ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Region</Label>
<Input
@@ -640,7 +647,7 @@ export function SettingsPage() {
<Label className="text-gray-300">AccessKey ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="LTAI5t..."
placeholder="AccessKey ID"
value={ossConfig.accessKeyId ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeyId: e.target.value }))
@@ -652,31 +659,13 @@ export function SettingsPage() {
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="********"
placeholder="AccessKey Secret"
value={ossConfig.accessKeySecret ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeySecret: e.target.value }))
}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300">Bucket </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="my-soul-bucket"
value={ossConfig.bucket ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
}
/>
</div>
</div>
<div className={`p-3 rounded-lg ${ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId ? 'bg-green-500/10 border border-green-500/30' : 'bg-amber-500/10 border border-amber-500/30'}`}>
<p className={`text-xs ${ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId ? 'text-green-300' : 'text-amber-300'}`}>
{ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId
? `✅ OSS 已配置(${ossConfig.bucket}.${ossConfig.endpoint}),上传将自动使用云端存储`
: '⚠ 未配置 OSS当前上传存储在本地服务器。填写以上信息并保存后自动启用云端存储'}
</p>
</div>
</CardContent>
</Card>
@@ -770,7 +759,7 @@ export function SettingsPage() {
</Label>
</div>
<p className="text-xs text-gray-400 ml-6"></p>
<p className="text-xs text-gray-400 ml-6"></p>
</div>
<Switch
id="search-enabled"

View File

@@ -245,9 +245,17 @@ export function UsersPage() {
if (!confirm('确定要删除这个用户吗?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/users?id=${encodeURIComponent(userId)}`)
if (data?.success) loadUsers()
else toast.error('删除失败: ' + (data?.error || ''))
} catch { toast.error('删除失败') }
if (data?.success) {
toast.success('删除')
loadUsers()
} else {
toast.error('删除失败: ' + (data?.error || '未知错误'))
}
} catch (e) {
const err = e as Error & { data?: { error?: string } }
const msg = err?.data?.error || err?.message || '网络错误'
toast.error('删除失败: ' + msg)
}
}
const handleEditUser = (user: User) => {