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:
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
配置阿里云对象存储,用于图片和视频的云端存储(配置后将替代本地存储)
|
||||
endpoint、bucket、accessKey 等,用于图片/文件上传
|
||||
</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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user