sync: Gitea 同步配置、miniprogram 页面逻辑、miniprogram 页面样式、脚本与配置、soul-admin 前端、soul-admin 页面、soul-api 接口逻辑、soul-api 路由等 | 原因: 多模块开发更新

This commit is contained in:
卡若
2026-03-08 08:00:39 +08:00
parent b7c35a89b0
commit 66cd90e511
43 changed files with 2559 additions and 809 deletions

View File

@@ -8,7 +8,6 @@ import { DistributionPage } from './pages/distribution/DistributionPage'
import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage'
import { ContentPage } from './pages/content/ContentPage'
import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage'
import { AuthorSettingsPage } from './pages/author-settings/AuthorSettingsPage'
import { SettingsPage } from './pages/settings/SettingsPage'
import { PaymentPage } from './pages/payment/PaymentPage'
import { SitePage } from './pages/site/SitePage'
@@ -18,7 +17,7 @@ import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
import { MentorsPage } from './pages/mentors/MentorsPage'
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
import { AdminUsersPage } from './pages/admin-users/AdminUsersPage'
import { FindPartnerPage } from './pages/find-partner/FindPartnerPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
@@ -35,15 +34,16 @@ function App() {
<Route path="withdrawals" element={<WithdrawalsPage />} />
<Route path="content" element={<ContentPage />} />
<Route path="referral-settings" element={<ReferralSettingsPage />} />
<Route path="author-settings" element={<AuthorSettingsPage />} />
<Route path="author-settings" element={<Navigate to="/settings?tab=author" replace />} />
<Route path="admin-users" element={<Navigate to="/settings?tab=admin" replace />} />
<Route path="vip-roles" element={<VipRolesPage />} />
<Route path="mentors" element={<MentorsPage />} />
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
<Route path="admin-users" element={<AdminUsersPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="payment" element={<PaymentPage />} />
<Route path="site" element={<SitePage />} />
<Route path="qrcodes" element={<QRCodesPage />} />
<Route path="find-partner" element={<FindPartnerPage />} />
<Route path="match" element={<MatchPage />} />
<Route path="match-records" element={<MatchRecordsPage />} />
<Route path="api-doc" element={<ApiDocPage />} />

View File

@@ -11,9 +11,6 @@ import {
GitMerge,
Crown,
GraduationCap,
Calendar,
User,
ShieldCheck,
ChevronDown,
ChevronUp,
} from 'lucide-react'
@@ -28,13 +25,10 @@ const primaryMenuItems = [
]
// 折叠区「更多」(字典类 + 业务)
const moreMenuItems = [
{ icon: GitMerge, label: '找伙伴', href: '/find-partner' },
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
{ icon: User, label: '作者详情', href: '/author-settings' },
{ icon: ShieldCheck, label: '管理员', href: '/admin-users' },
{ icon: GraduationCap, label: '导师管理', href: '/mentors' },
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
{ icon: Wallet, label: '推广中心', href: '/distribution' },
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
]

View File

@@ -16,6 +16,8 @@ export interface SectionItem {
isNew?: boolean
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
export interface ChapterItem {
@@ -55,6 +57,8 @@ interface ChapterTreeProps {
/** 批量移动:勾选章节 */
selectedSectionIds?: string[]
onToggleSectionSelect?: (sectionId: string) => void
/** 查看某节的付款记录 */
onShowSectionOrders?: (s: SectionItem) => void
}
export function ChapterTree({
@@ -72,6 +76,7 @@ export function ChapterTree({
onEditChapter,
selectedSectionIds = [],
onToggleSectionSelect,
onShowSectionOrders,
}: ChapterTreeProps) {
const [draggingItem, setDraggingItem] = useState<{ type: DragType; id: string } | null>(null)
const [dragOverTarget, setDragOverTarget] = useState<{ type: DragType; id: string } | null>(null)
@@ -318,6 +323,13 @@ export function ChapterTree({
) : (
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
@@ -382,6 +394,17 @@ export function ChapterTree({
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onEditPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onEditPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑篇名">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onDeletePart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeletePart(part)} className="text-gray-500 hover:text-red-400 h-7 px-2" title="删除本篇">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
@@ -393,7 +416,26 @@ export function ChapterTree({
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
{part.chapters.map((chapter) => (
<div key={chapter.id} className="space-y-2">
<p className="text-xs text-gray-500 pb-1">{chapter.title}</p>
<div className="flex items-center gap-2 w-full">
<p className="text-xs text-gray-500 pb-1 flex-1">{chapter.title}</p>
<div className="flex gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{onEditChapter && (
<Button variant="ghost" size="sm" onClick={() => onEditChapter(part, chapter)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑章节名称">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onAddChapterInPart && (
<Button variant="ghost" size="sm" onClick={() => onAddChapterInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="新增第X章">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onDeleteChapter && (
<Button variant="ghost" size="sm" onClick={() => onDeleteChapter(part, chapter)} className="text-gray-500 hover:text-red-400 h-7 px-1.5" title="删除本章">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
<div className="space-y-1 pl-2">
{chapter.sections.map((section) => {
const secDragOver = isDragOver('section', section.id)
@@ -432,6 +474,12 @@ export function ChapterTree({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Eye className="w-3.5 h-3.5" />
</Button>
@@ -499,6 +547,12 @@ export function ChapterTree({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Eye className="w-3.5 h-3.5" />
@@ -576,6 +630,12 @@ export function ChapterTree({
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
@@ -636,6 +696,12 @@ export function ChapterTree({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
@@ -722,7 +788,7 @@ export function ChapterTree({
{isExpanded && (
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
{part.chapters.map((chapter, chIndex) => {
{part.chapters.map((chapter) => {
const chDragOver = isDragOver('chapter', chapter.id)
return (
<div key={chapter.id} className="space-y-2">
@@ -835,6 +901,12 @@ export function ChapterTree({
<span className="text-xs text-gray-500">¥{section.price}</span>
)}
<span className="text-[10px] text-gray-500" title="点击次数 · 付款笔数"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5 shrink-0">
</Button>
)}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Eye className="w-3.5 h-3.5" />

View File

@@ -37,7 +37,7 @@ import {
Image as ImageIcon,
Search,
} from 'lucide-react'
import { get, put, del } from '@/api/client'
import { get, put, post, del } from '@/api/client'
import { ChapterTree } from './ChapterTree'
import { apiUrl } from '@/api/client'
@@ -54,6 +54,8 @@ interface SectionListItem {
filePath?: string
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
interface Section {
@@ -65,6 +67,8 @@ interface Section {
isNew?: boolean
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
interface Chapter {
@@ -126,12 +130,15 @@ function buildTree(sections: SectionListItem[]): Part[] {
isNew: s.isNew,
clickCount: s.clickCount ?? 0,
payCount: s.payCount ?? 0,
hotScore: s.hotScore ?? 0,
hotRank: s.hotRank ?? 0,
})
}
// 确保「2026每日派对干货」篇章存在不在第六篇编号体系内
const DAILY_PART_ID = 'part-2026-daily'
const DAILY_PART_TITLE = '2026每日派对干货'
if (!partMap.has(DAILY_PART_ID)) {
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,
@@ -195,6 +202,10 @@ export function ContentPage() {
const [isSavingPart, setIsSavingPart] = useState(false)
const [sectionOrdersModal, setSectionOrdersModal] = useState<{ section: Section; orders: SectionOrder[] } | null>(null)
const [sectionOrdersLoading, setSectionOrdersLoading] = useState(false)
const [showRankingAlgorithmModal, setShowRankingAlgorithmModal] = useState(false)
const [rankingWeights, setRankingWeights] = useState({ readWeight: 0.5, recencyWeight: 0.3, payWeight: 0.2 })
const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false)
const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false)
const tree = buildTree(sectionsList)
const totalSections = sectionsList.length
@@ -202,8 +213,10 @@ export function ContentPage() {
const loadList = async () => {
setLoading(true)
try {
// 每次请求均从服务端拉取最新数据,确保点击量/付款数与 reading_progress、orders 表直接捆绑
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
'/api/db/book?action=list',
{ cache: 'no-store' as RequestCache },
)
setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
} catch (e) {
@@ -269,6 +282,60 @@ export function ContentPage() {
}
}
const loadRankingWeights = useCallback(async () => {
setRankingWeightsLoading(true)
try {
const data = await get<{ success?: boolean; data?: { readWeight?: number; recencyWeight?: number; payWeight?: number } }>(
'/api/db/config/full?key=article_ranking_weights',
{ cache: 'no-store' as RequestCache },
)
const d = data && (data as { success?: boolean; data?: { readWeight?: number; recencyWeight?: number; payWeight?: number } }).data
if (d && typeof d.readWeight === 'number' && typeof d.recencyWeight === 'number' && typeof d.payWeight === 'number') {
setRankingWeights({
readWeight: Math.max(0, Math.min(1, d.readWeight)),
recencyWeight: Math.max(0, Math.min(1, d.recencyWeight)),
payWeight: Math.max(0, Math.min(1, d.payWeight)),
})
}
} catch {
// 使用默认值
} finally {
setRankingWeightsLoading(false)
}
}, [])
useEffect(() => {
if (showRankingAlgorithmModal) loadRankingWeights()
}, [showRankingAlgorithmModal, loadRankingWeights])
const handleSaveRankingWeights = async () => {
const { readWeight, recencyWeight, payWeight } = rankingWeights
const sum = readWeight + recencyWeight + payWeight
if (Math.abs(sum - 1) > 0.001) {
alert('三个权重之和必须等于 1')
return
}
setRankingWeightsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'article_ranking_weights',
value: { readWeight, recencyWeight, payWeight },
description: '文章排名算法权重',
})
if (res && (res as { success?: boolean }).success !== false) {
alert('已保存')
loadList()
} else {
alert('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
}
} catch (e) {
console.error(e)
alert('保存失败')
} finally {
setRankingWeightsSaving(false)
}
}
const handleShowSectionOrders = async (section: Section & { filePath?: string }) => {
setSectionOrdersModal({ section, orders: [] })
setSectionOrdersLoading(true)
@@ -558,6 +625,56 @@ export function ContentPage() {
}
setIsMoving(true)
try {
const buildFallbackReorderItems = () => {
const selectedSet = new Set(selectedSectionIds)
const baseItems = sectionsList.map((s) => ({
id: s.id,
partId: s.partId || '',
partTitle: s.partTitle || '',
chapterId: s.chapterId || '',
chapterTitle: s.chapterTitle || '',
}))
const movingItems = baseItems
.filter((item) => selectedSet.has(item.id))
.map((item) => ({
...item,
partId: batchMoveTargetPartId,
partTitle: targetPart.title || batchMoveTargetPartId,
chapterId: batchMoveTargetChapterId,
chapterTitle: targetChapter.title || batchMoveTargetChapterId,
}))
const remainingItems = baseItems.filter((item) => !selectedSet.has(item.id))
let insertIndex = remainingItems.length
for (let i = remainingItems.length - 1; i >= 0; i -= 1) {
const item = remainingItems[i]
if (item.partId === batchMoveTargetPartId && item.chapterId === batchMoveTargetChapterId) {
insertIndex = i + 1
break
}
}
return [
...remainingItems.slice(0, insertIndex),
...movingItems,
...remainingItems.slice(insertIndex),
]
}
const tryFallbackReorder = async () => {
const reorderItems = buildFallbackReorderItems()
const reorderRes = await put<{ success?: boolean; error?: string }>(
'/api/db/book',
{ action: 'reorder', items: reorderItems },
)
if (reorderRes && (reorderRes as { success?: boolean }).success !== false) {
alert(`已移动 ${selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
setShowBatchMoveModal(false)
setSelectedSectionIds([])
await loadList()
return true
}
return false
}
const payload = {
action: 'move-sections',
sectionIds: selectedSectionIds,
@@ -571,13 +688,18 @@ export function ContentPage() {
alert(`已移动 ${(res as { count?: number }).count ?? selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
setShowBatchMoveModal(false)
setSelectedSectionIds([])
loadList()
await loadList()
} else {
alert('移动失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
const errorMessage = res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error || '' : '未知错误'
if (errorMessage.includes('缺少 id') || errorMessage.includes('无效的 action')) {
const fallbackOk = await tryFallbackReorder()
if (fallbackOk) return
}
alert('移动失败: ' + errorMessage)
}
} catch (e) {
console.error(e)
alert('移动失败')
alert('移动失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
} finally {
setIsMoving(false)
}
@@ -591,7 +713,10 @@ export function ContentPage() {
const handleDeletePart = async (part: Part) => {
const sectionIds = sectionsList.filter((s) => s.partId === part.id).map((s) => s.id)
if (sectionIds.length === 0) return
if (sectionIds.length === 0) {
alert('该篇下暂无小节可删除')
return
}
if (!confirm(`确定要删除「${part.title}」整篇吗?将删除共 ${sectionIds.length} 节内容,此操作不可恢复。`)) return
try {
for (const id of sectionIds) {
@@ -670,6 +795,14 @@ export function ContentPage() {
<p className="text-gray-400 mt-1"> {tree.length} · {totalSections} </p>
</div>
<div className="flex gap-2">
<Button
onClick={() => setShowRankingAlgorithmModal(true)}
variant="outline"
className="border-amber-500/50 text-amber-400 hover:bg-amber-500/10 bg-transparent"
>
<Settings2 className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={() => {
const url = import.meta.env.VITE_API_DOC_URL || (typeof window !== 'undefined' ? `${window.location.origin}/api-doc` : '')
@@ -986,6 +1119,122 @@ export function ContentPage() {
</DialogContent>
</Dialog>
{/* 付款记录弹窗 */}
<Dialog open={!!sectionOrdersModal} onOpenChange={(open) => !open && setSectionOrdersModal(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-3xl max-h-[85vh] overflow-hidden flex flex-col" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white">
{sectionOrdersModal?.section.title ?? ''}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-2">
{sectionOrdersLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : sectionOrdersModal && sectionOrdersModal.orders.length === 0 ? (
<p className="text-gray-500 text-center py-6"></p>
) : sectionOrdersModal ? (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-gray-700 text-left text-gray-400">
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2">ID</th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
</tr>
</thead>
<tbody>
{sectionOrdersModal.orders.map((o) => (
<tr key={o.id ?? o.orderSn ?? ''} className="border-b border-gray-700/50">
<td className="py-2 pr-2 text-gray-300">{o.orderSn ?? '-'}</td>
<td className="py-2 pr-2 text-gray-300">{o.userId ?? o.openId ?? '-'}</td>
<td className="py-2 pr-2 text-gray-300">¥{o.amount ?? 0}</td>
<td className="py-2 pr-2 text-gray-300">{o.status ?? '-'}</td>
<td className="py-2 pr-2 text-gray-500">{o.payTime ?? o.createdAt ?? '-'}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
</DialogContent>
</Dialog>
{/* 排名算法:权重可编辑 */}
<Dialog open={showRankingAlgorithmModal} onOpenChange={setShowRankingAlgorithmModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Settings2 className="w-5 h-5 text-amber-400" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-gray-400"> = × + × + × 1</p>
{rankingWeightsLoading ? (
<p className="text-gray-500">...</p>
) : (
<>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.readWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, readWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.recencyWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, recencyWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.payWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, payWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
/>
</div>
</div>
<p className="text-xs text-gray-500">: {(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight).toFixed(1)}</p>
<ul className="list-disc list-inside space-y-1 text-xs text-gray-400">
<li> 20 201</li>
<li> 30 301</li>
<li> 20 201</li>
</ul>
<Button
onClick={handleSaveRankingWeights}
disabled={rankingWeightsSaving || Math.abs(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight - 1) > 0.001}
className="w-full bg-amber-500 hover:bg-amber-600 text-white"
>
{rankingWeightsSaving ? '保存中...' : '保存权重'}
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
{/* 新建篇弹窗 */}
<Dialog open={showNewPartModal} onOpenChange={setShowNewPartModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
@@ -1269,6 +1518,7 @@ export function ContentPage() {
onEditChapter={handleEditChapter}
selectedSectionIds={selectedSectionIds}
onToggleSectionSelect={toggleSectionSelect}
onShowSectionOrders={handleShowSectionOrders}
/>
)}
</TabsContent>

View File

@@ -39,6 +39,18 @@ interface DashboardOverviewRes {
newUsers?: UserRow[]
}
interface UsersRes {
success?: boolean
users?: UserRow[]
total?: number
}
interface OrdersRes {
success?: boolean
orders?: OrderRow[]
total?: number
}
export function DashboardPage() {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(true)
@@ -48,9 +60,11 @@ export function DashboardPage() {
const [paidOrderCount, setPaidOrderCount] = useState(0)
const [totalRevenue, setTotalRevenue] = useState(0)
const [conversionRate, setConversionRate] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
async function loadData() {
setIsLoading(true)
setLoadError(null)
try {
const data = await get<DashboardOverviewRes>('/api/admin/dashboard/overview')
if (data?.success) {
@@ -60,9 +74,38 @@ export function DashboardPage() {
setConversionRate(data.conversionRate ?? 0)
setPurchases(data.recentOrders ?? [])
setUsers(data.newUsers ?? [])
return
}
} catch (e) {
console.error('加载数据失败', e)
console.error('数据概览接口失败,尝试降级拉取', e)
}
// 降级:新接口未部署或失败时,用原有接口拉取用户与订单
try {
const [usersData, ordersData] = await Promise.all([
get<UsersRes>('/api/db/users?page=1&pageSize=10'),
get<OrdersRes>('/api/orders?page=1&pageSize=20&status=paid'),
])
const totalUsers = typeof usersData?.total === 'number' ? usersData.total : (usersData?.users?.length ?? 0)
const orders = ordersData?.orders ?? []
const total = typeof ordersData?.total === 'number' ? ordersData.total : orders.length
const paidOrders = orders.filter((p) => p.status === 'paid' || p.status === 'completed' || p.status === 'success')
const revenue = paidOrders.reduce((sum, p) => sum + Number(p.amount || 0), 0)
const paidUserIds = new Set(paidOrders.map((p) => p.userId).filter(Boolean))
const rate = totalUsers > 0 && paidUserIds.size > 0 ? (paidUserIds.size / totalUsers) * 100 : 0
setTotalUsersCount(totalUsers)
setPaidOrderCount(total)
setTotalRevenue(revenue)
setConversionRate(rate)
setPurchases(orders.slice(0, 5))
setUsers(usersData?.users ?? [])
} catch (fallbackErr) {
console.error('降级拉取失败', fallbackErr)
const err = fallbackErr as Error & { status?: number }
if (err?.status === 401) {
setLoadError('登录已过期,请重新登录')
} else {
setLoadError('加载失败,请检查网络或联系管理员')
}
} finally {
setIsLoading(false)
}
@@ -70,6 +113,8 @@ export function DashboardPage() {
useEffect(() => {
loadData()
const timer = setInterval(loadData, 30000)
return () => clearInterval(timer)
}, [])
if (isLoading) {
@@ -157,7 +202,18 @@ export function DashboardPage() {
return (
<div className="p-8 w-full">
<h1 className="text-2xl font-bold mb-8 text-white"></h1>
{loadError && (
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-500/20 border border-amber-500/50 text-amber-200 text-sm flex items-center justify-between">
<span>{loadError}</span>
<button
type="button"
onClick={() => loadData()}
className="text-amber-400 hover:text-amber-300 underline"
>
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat, index) => (
<Card
@@ -183,14 +239,22 @@ export function DashboardPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white"></CardTitle>
<button
type="button"
onClick={() => loadData()}
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1"
title="刷新"
>
<RefreshCw className="w-3.5 h-3.5" />
30
</button>
</CardHeader>
<CardContent>
<div className="space-y-3">
{purchases
.slice(-5)
.reverse()
.slice(0, 5)
.map((p) => {
const referrer: UserRow | undefined = p.referrerId
? users.find((u) => u.id === p.referrerId)
@@ -285,8 +349,7 @@ export function DashboardPage() {
<CardContent>
<div className="space-y-3">
{users
.slice(-5)
.reverse()
.slice(0, 5)
.map((u) => (
<div
key={u.id}

View File

@@ -0,0 +1,65 @@
import { useState } from 'react'
import { Users, List, Handshake, GraduationCap, UserPlus, BarChart3 } from 'lucide-react'
import { MatchPoolTab } from './tabs/MatchPoolTab'
import { MatchRecordsTab } from './tabs/MatchRecordsTab'
import { ResourceDockingTab } from './tabs/ResourceDockingTab'
import { MentorBookingTab } from './tabs/MentorBookingTab'
import { TeamRecruitTab } from './tabs/TeamRecruitTab'
import { CKBStatsTab } from './tabs/CKBStatsTab'
const TABS = [
{ id: 'pool', label: '匹配池', icon: Users },
{ id: 'records', label: '匹配记录', icon: List },
{ id: 'resource', label: '资源对接', icon: Handshake },
{ id: 'mentor', label: '导师预约', icon: GraduationCap },
{ id: 'team', label: '团队招募', icon: UserPlus },
{ id: 'stats', label: '存客宝统计', icon: BarChart3 },
] as const
type TabId = (typeof TABS)[number]['id']
export function FindPartnerPage() {
const [activeTab, setActiveTab] = useState<TabId>('pool')
return (
<div className="p-8 w-full">
<div className="mb-6">
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Users className="w-6 h-6 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
</p>
</div>
<div className="flex gap-1 mb-6 bg-[#0f2137] rounded-lg p-1 border border-gray-700/50">
{TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-md text-sm font-medium transition-all ${
isActive
? 'bg-[#38bdac] text-white shadow-lg'
: 'text-gray-400 hover:text-white hover:bg-gray-700/50'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
)
})}
</div>
{activeTab === 'pool' && <MatchPoolTab />}
{activeTab === 'records' && <MatchRecordsTab />}
{activeTab === 'resource' && <ResourceDockingTab />}
{activeTab === 'mentor' && <MentorBookingTab />}
{activeTab === 'team' && <TeamRecruitTab />}
{activeTab === 'stats' && <CKBStatsTab />}
</div>
)
}

View File

@@ -0,0 +1,242 @@
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { RefreshCw, Users, UserCheck, TrendingUp, Zap, Link2, CheckCircle2, XCircle } from 'lucide-react'
import { get, post } from '@/api/client'
interface MatchStats {
totalMatches: number
todayMatches: number
byType: { matchType: string; count: number }[]
uniqueUsers: number
}
interface CKBTestResult {
endpoint: string
label: string
status: 'idle' | 'testing' | 'success' | 'error'
message?: string
responseTime?: number
}
export function CKBStatsTab() {
const [stats, setStats] = useState<MatchStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [ckbTests, setCkbTests] = useState<CKBTestResult[]>([
{ endpoint: '/api/ckb/join', label: 'CKB 加入ckb/join', status: 'idle' },
{ endpoint: '/api/ckb/match', label: 'CKB 匹配上报ckb/match', status: 'idle' },
{ endpoint: '/api/miniprogram/ckb/lead', label: 'CKB 链接卡若ckb/lead', status: 'idle' },
{ endpoint: '/api/match/config', label: '匹配配置match/config', status: 'idle' },
])
const typeLabels: Record<string, string> = {
partner: '超级个体', investor: '资源对接', mentor: '导师顾问', team: '团队招募',
}
const loadStats = useCallback(async () => {
setIsLoading(true)
try {
const data = await get<{ success?: boolean; data?: MatchStats }>('/api/db/match-records?stats=true')
if (data?.success && data.data) {
setStats(data.data)
} else {
const fallback = await get<{ success?: boolean; records?: unknown[]; total?: number }>('/api/db/match-records?page=1&pageSize=1')
if (fallback?.success) {
setStats({
totalMatches: fallback.total ?? 0,
todayMatches: 0,
byType: [],
uniqueUsers: 0,
})
}
}
} catch (e) { console.error('加载统计失败:', e) }
finally { setIsLoading(false) }
}, [])
useEffect(() => { loadStats() }, [loadStats])
const testEndpoint = async (index: number) => {
const test = ckbTests[index]
const updated = [...ckbTests]
updated[index] = { ...test, status: 'testing', message: undefined, responseTime: undefined }
setCkbTests(updated)
const start = performance.now()
try {
let res: { success?: boolean; message?: string; code?: number }
if (test.endpoint.includes('match/config')) {
res = await get<{ success?: boolean }>(test.endpoint)
} else {
res = await post<{ success?: boolean; message?: string }>(test.endpoint, {
type: 'partner',
phone: '00000000000',
wechat: 'test_ping',
userId: 'test_admin_ping',
matchType: 'partner',
name: '接口测试',
})
}
const elapsed = Math.round(performance.now() - start)
const next = [...ckbTests]
const ok = res?.success !== undefined || res?.code === 200 || res?.code === 400
next[index] = {
...test,
status: ok ? 'success' : 'error',
message: res?.message || (ok ? '接口可用' : '响应异常'),
responseTime: elapsed,
}
setCkbTests(next)
} catch (e: unknown) {
const elapsed = Math.round(performance.now() - start)
const next = [...ckbTests]
next[index] = {
...test,
status: 'error',
message: e instanceof Error ? e.message : '请求失败',
responseTime: elapsed,
}
setCkbTests(next)
}
}
const testAll = async () => {
for (let i = 0; i < ckbTests.length; i++) {
await testEndpoint(i)
}
}
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-white mt-1">{isLoading ? '-' : (stats?.totalMatches ?? 0)}</p>
</div>
<Users className="w-10 h-10 text-[#38bdac]/50" />
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-white mt-1">{isLoading ? '-' : (stats?.todayMatches ?? 0)}</p>
</div>
<Zap className="w-10 h-10 text-yellow-400/50" />
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-white mt-1">{isLoading ? '-' : (stats?.uniqueUsers ?? 0)}</p>
</div>
<UserCheck className="w-10 h-10 text-blue-400/50" />
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-white mt-1">
{isLoading ? '-' : (stats?.uniqueUsers ? (stats.totalMatches / stats.uniqueUsers).toFixed(1) : '0')}
</p>
</div>
<TrendingUp className="w-10 h-10 text-green-400/50" />
</div>
</CardContent>
</Card>
</div>
{/* 按类型分布 */}
{stats?.byType && stats.byType.length > 0 && (
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.byType.map(item => (
<div key={item.matchType} className="bg-[#0a1628] rounded-lg p-4 text-center">
<p className="text-gray-400 text-sm">{typeLabels[item.matchType] || item.matchType}</p>
<p className="text-2xl font-bold text-white mt-2">{item.count}</p>
<p className="text-gray-500 text-xs mt-1">
{stats.totalMatches > 0 ? ((item.count / stats.totalMatches) * 100).toFixed(1) : 0}%
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* CKB 接口测试 */}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-white flex items-center gap-2">
<Link2 className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<p className="text-gray-400 text-sm mt-1">
CKB
</p>
</div>
<div className="flex gap-2">
<Button onClick={loadStats} disabled={isLoading} variant="outline" className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={testAll} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Zap className="w-4 h-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{ckbTests.map((test, idx) => (
<div key={test.endpoint} className="flex items-center justify-between bg-[#0a1628] rounded-lg px-4 py-3">
<div className="flex items-center gap-3">
{test.status === 'idle' && <div className="w-3 h-3 rounded-full bg-gray-500" />}
{test.status === 'testing' && <RefreshCw className="w-4 h-4 text-yellow-400 animate-spin" />}
{test.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-400" />}
{test.status === 'error' && <XCircle className="w-4 h-4 text-red-400" />}
<div>
<p className="text-white text-sm font-medium">{test.label}</p>
<p className="text-gray-500 text-xs font-mono">{test.endpoint}</p>
</div>
</div>
<div className="flex items-center gap-3">
{test.message && (
<span className={`text-xs ${test.status === 'success' ? 'text-green-400' : test.status === 'error' ? 'text-red-400' : 'text-gray-400'}`}>
{test.message}
</span>
)}
{test.responseTime !== undefined && (
<Badge className="bg-gray-700 text-gray-300 border-0">{test.responseTime}ms</Badge>
)}
<Button size="sm" variant="outline"
onClick={() => testEndpoint(idx)}
disabled={test.status === 'testing'}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent text-xs">
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,242 @@
import { useState, useEffect } from 'react'
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import { Save, RefreshCw, Edit3, Plus, Trash2, Users, Zap } from 'lucide-react'
import { get, post } from '@/api/client'
interface MatchType {
id: string; label: string; matchLabel: string; icon: string
matchFromDB: boolean; showJoinAfterMatch: boolean; price: number; enabled: boolean
}
interface MatchConfig {
matchTypes: MatchType[]; freeMatchLimit: number; matchPrice: number
settings: { enableFreeMatches: boolean; enablePaidMatches: boolean; maxMatchesPerDay: number }
}
const DEFAULT_CONFIG: MatchConfig = {
matchTypes: [
{ id: 'partner', label: '超级个体', matchLabel: '超级个体', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, price: 1, enabled: true },
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
{ id: 'mentor', label: '导师顾问', matchLabel: '导师顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
{ id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
],
freeMatchLimit: 3, matchPrice: 1,
settings: { enableFreeMatches: true, enablePaidMatches: true, maxMatchesPerDay: 10 },
}
const ICONS = ['⭐', '👥', '❤️', '🎮', '💼', '🚀', '💡', '🎯', '🔥', '✨']
export function MatchPoolTab() {
const [config, setConfig] = useState<MatchConfig>(DEFAULT_CONFIG)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [showTypeModal, setShowTypeModal] = useState(false)
const [editingType, setEditingType] = useState<MatchType | null>(null)
const [formData, setFormData] = useState({ id: '', label: '', matchLabel: '', icon: '⭐', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true })
const loadConfig = async () => {
setIsLoading(true)
try {
const data = await get<{ success?: boolean; data?: MatchConfig; config?: MatchConfig }>('/api/db/config/full?key=match_config')
const c = (data as { data?: MatchConfig })?.data ?? (data as { config?: MatchConfig })?.config
if (c) setConfig({ ...DEFAULT_CONFIG, ...c })
} catch (e) { console.error('加载匹配配置失败:', e) }
finally { setIsLoading(false) }
}
useEffect(() => { loadConfig() }, [])
const handleSave = async () => {
setIsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', { key: 'match_config', value: config, description: '匹配功能配置' })
alert(res?.success !== false ? '配置保存成功!' : '保存失败: ' + (res?.error || '未知错误'))
} catch (e) { console.error(e); alert('保存失败') }
finally { setIsSaving(false) }
}
const handleEditType = (type: MatchType) => {
setEditingType(type)
setFormData({ ...type })
setShowTypeModal(true)
}
const handleAddType = () => {
setEditingType(null)
setFormData({ id: '', label: '', matchLabel: '', icon: '⭐', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true })
setShowTypeModal(true)
}
const handleSaveType = () => {
if (!formData.id || !formData.label) { alert('请填写类型ID和名称'); return }
const newTypes = [...config.matchTypes]
if (editingType) {
const idx = newTypes.findIndex(t => t.id === editingType.id)
if (idx !== -1) newTypes[idx] = { ...formData }
} else {
if (newTypes.some(t => t.id === formData.id)) { alert('类型ID已存在'); return }
newTypes.push({ ...formData })
}
setConfig({ ...config, matchTypes: newTypes })
setShowTypeModal(false)
}
const handleDeleteType = (typeId: string) => {
if (!confirm('确定要删除这个匹配类型吗?')) return
setConfig({ ...config, matchTypes: config.matchTypes.filter(t => t.id !== typeId) })
}
const handleToggleType = (typeId: string) => {
setConfig({ ...config, matchTypes: config.matchTypes.map(t => t.id === typeId ? { ...t, enabled: !t.enabled } : t) })
}
return (
<div className="space-y-6">
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={loadConfig} disabled={isLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={handleSave} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" /> {isSaving ? '保存中...' : '保存配置'}
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2"><Zap className="w-5 h-5 text-yellow-400" /> </CardTitle>
<CardDescription className="text-gray-400"></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input type="number" min={0} max={100} className="bg-[#0a1628] border-gray-700 text-white" value={config.freeMatchLimit} onChange={e => setConfig({ ...config, freeMatchLimit: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input type="number" min={0.01} step={0.01} className="bg-[#0a1628] border-gray-700 text-white" value={config.matchPrice} onChange={e => setConfig({ ...config, matchPrice: parseFloat(e.target.value) || 1 })} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input type="number" min={1} max={100} className="bg-[#0a1628] border-gray-700 text-white" value={config.settings.maxMatchesPerDay} onChange={e => setConfig({ ...config, settings: { ...config.settings, maxMatchesPerDay: parseInt(e.target.value, 10) || 10 } })} />
</div>
</div>
<div className="flex gap-8 pt-4 border-t border-gray-700/50">
<div className="flex items-center gap-3">
<Switch checked={config.settings.enableFreeMatches} onCheckedChange={checked => setConfig({ ...config, settings: { ...config.settings, enableFreeMatches: checked } })} />
<Label className="text-gray-300"></Label>
</div>
<div className="flex items-center gap-3">
<Switch checked={config.settings.enablePaidMatches} onCheckedChange={checked => setConfig({ ...config, settings: { ...config.settings, enablePaidMatches: checked } })} />
<Label className="text-gray-300"></Label>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-white flex items-center gap-2"><Users className="w-5 h-5 text-[#38bdac]" /> </CardTitle>
<CardDescription className="text-gray-400"></CardDescription>
</div>
<Button onClick={handleAddType} size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-1" />
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{config.matchTypes.map(type => (
<TableRow key={type.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell><span className="text-2xl">{type.icon}</span></TableCell>
<TableCell className="font-mono text-gray-300">{type.id}</TableCell>
<TableCell className="text-white font-medium">{type.label}</TableCell>
<TableCell className="text-gray-300">{type.matchLabel}</TableCell>
<TableCell><Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">¥{type.price}</Badge></TableCell>
<TableCell>{type.matchFromDB ? <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"></Badge> : <Badge variant="outline" className="text-gray-500 border-gray-600"></Badge>}</TableCell>
<TableCell><Switch checked={type.enabled} onCheckedChange={() => handleToggleType(type.id)} /></TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEditType(type)} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteType(type.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10"><Trash2 className="w-4 h-4" /></Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={showTypeModal} onOpenChange={setShowTypeModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
{editingType ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <Plus className="w-5 h-5 text-[#38bdac]" />}
{editingType ? '编辑匹配类型' : '添加匹配类型'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">ID</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="如: partner" value={formData.id} onChange={e => setFormData({ ...formData, id: e.target.value })} disabled={!!editingType} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex gap-1 flex-wrap">
{ICONS.map(icon => (
<button key={icon} type="button" className={`w-8 h-8 text-lg rounded ${formData.icon === icon ? 'bg-[#38bdac]/30 ring-1 ring-[#38bdac]' : 'bg-[#0a1628]'}`} onClick={() => setFormData({ ...formData, icon })}>{icon}</button>
))}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="如: 超级个体" value={formData.label} onChange={e => setFormData({ ...formData, label: 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={formData.matchLabel} onChange={e => setFormData({ ...formData, matchLabel: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input type="number" min={0.01} step={0.01} className="bg-[#0a1628] border-gray-700 text-white" value={formData.price} onChange={e => setFormData({ ...formData, price: parseFloat(e.target.value) || 1 })} />
</div>
<div className="flex gap-6 pt-2">
<div className="flex items-center gap-3"><Switch checked={formData.matchFromDB} onCheckedChange={checked => setFormData({ ...formData, matchFromDB: checked })} /><Label className="text-gray-300 text-sm"></Label></div>
<div className="flex items-center gap-3"><Switch checked={formData.showJoinAfterMatch} onCheckedChange={checked => setFormData({ ...formData, showJoinAfterMatch: checked })} /><Label className="text-gray-300 text-sm"></Label></div>
<div className="flex items-center gap-3"><Switch checked={formData.enabled} onCheckedChange={checked => setFormData({ ...formData, enabled: checked })} /><Label className="text-gray-300 text-sm"></Label></div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowTypeModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"></Button>
<Button onClick={handleSaveType} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Save className="w-4 h-4 mr-2" /> </Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { RefreshCw } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get } from '@/api/client'
interface MatchRecord {
id: string; userId: string; matchedUserId: string; matchType: string
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
userAvatar?: string; matchedUserAvatar?: string; matchScore?: number; createdAt: string
}
const matchTypeLabels: Record<string, string> = {
partner: '超级个体', investor: '资源对接', mentor: '导师顾问', team: '团队招募',
}
export function MatchRecordsTab() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [matchTypeFilter, setMatchTypeFilter] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadRecords() {
setIsLoading(true); setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (matchTypeFilter) params.set('matchType', matchTypeFilter)
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
else setError('加载匹配记录失败')
} catch { setError('加载失败,请检查网络后重试') }
finally { setIsLoading(false) }
}
useEffect(() => { loadRecords() }, [page, matchTypeFilter])
const totalPages = Math.ceil(total / pageSize) || 1
return (
<div>
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">×</button>
</div>
)}
<div className="flex justify-between items-center mb-4">
<p className="text-gray-400"> {total} </p>
<div className="flex items-center gap-4">
<select value={matchTypeFilter} onChange={e => { setMatchTypeFilter(e.target.value); setPage(1) }}
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm">
<option value=""></option>
{Object.entries(matchTypeLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button type="button" onClick={loadRecords} disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">...</span></div>
) : (
<>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] 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"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map(r => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
<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 overflow-hidden">
{r.userAvatar ? <img src={r.userAvatar} alt="" className="w-full h-full object-cover" onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }} /> : null}
<span className={r.userAvatar ? 'hidden' : ''}>{(r.userNickname || r.userId || '?').charAt(0)}</span>
</div>
<div>
<div className="text-white">{r.userNickname || r.userId}</div>
<div className="text-xs text-gray-500 font-mono">{r.userId.slice(0, 16)}...</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<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 overflow-hidden">
{r.matchedUserAvatar ? <img src={r.matchedUserAvatar} alt="" className="w-full h-full object-cover" onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }} /> : null}
<span className={r.matchedUserAvatar ? 'hidden' : ''}>{(r.matchedNickname || r.matchedUserId || '?').charAt(0)}</span>
</div>
<div>
<div className="text-white">{r.matchedNickname || r.matchedUserId}</div>
<div className="text-xs text-gray-500 font-mono">{r.matchedUserId.slice(0, 16)}...</div>
</div>
</div>
</TableCell>
<TableCell><Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">{matchTypeLabels[r.matchType] || r.matchType}</Badge></TableCell>
<TableCell className="text-gray-400 text-sm">
{r.phone && <div>📱 {r.phone}</div>}
{r.wechatId && <div>💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && '-'}
</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{records.length === 0 && <TableRow><TableCell colSpan={5} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize}
onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
import { get } from '@/api/client'
interface Consultation {
id: number; userId: number; mentorId: number; consultationType: string
amount: number; status: string; createdAt: string
}
const statusMap: Record<string, string> = { created: '已创建', pending_pay: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消' }
const typeMap: Record<string, string> = { single: '单次', half_year: '半年', year: '年度' }
export function MentorBookingTab() {
const [list, setList] = useState<Consultation[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('')
async function load() {
setLoading(true)
try {
const url = statusFilter ? `/api/db/mentor-consultations?status=${statusFilter}` : '/api/db/mentor-consultations'
const data = await get<{ success?: boolean; data?: Consultation[] }>(url)
if (data?.success && data.data) setList(data.data)
} catch (e) { console.error(e) }
finally { setLoading(false) }
}
useEffect(() => { load() }, [statusFilter])
return (
<div>
<div className="flex justify-between items-center mb-4">
<p className="text-gray-400"></p>
<div className="flex items-center gap-2">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="bg-[#0f2137] border border-gray-700 rounded-lg px-3 py-2 text-gray-300 text-sm">
<option value=""></option>
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<Button onClick={load} disabled={loading} variant="outline" className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map(r => (
<TableRow key={r.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{r.id}</TableCell>
<TableCell className="text-gray-400">{r.userId}</TableCell>
<TableCell className="text-gray-400">{r.mentorId}</TableCell>
<TableCell className="text-gray-400">{typeMap[r.consultationType] || r.consultationType}</TableCell>
<TableCell className="text-white">¥{r.amount}</TableCell>
<TableCell className="text-gray-400">{statusMap[r.status] || r.status}</TableCell>
<TableCell className="text-gray-500 text-sm">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { RefreshCw } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get } from '@/api/client'
interface MatchRecord {
id: string; userId: string; matchedUserId: string; matchType: string
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
createdAt: string
}
export function ResourceDockingTab() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [isLoading, setIsLoading] = useState(true)
async function load() {
setIsLoading(true)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: 'investor' })
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
} catch (e) { console.error(e) }
finally { setIsLoading(false) }
}
useEffect(() => { load() }, [page])
const totalPages = Math.ceil(total / pageSize) || 1
return (
<div>
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-gray-400"> {total} </p>
<p className="text-gray-500 text-xs mt-1"></p>
</div>
<button type="button" onClick={load} disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">...</span></div>
) : (
<>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] 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"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map(r => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell className="text-white">{r.userNickname || r.userId}</TableCell>
<TableCell className="text-white">{r.matchedNickname || r.matchedUserId}</TableCell>
<TableCell className="text-gray-400 text-sm">
{r.phone && <div>📱 {r.phone}</div>}
{r.wechatId && <div>💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && '-'}
</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{records.length === 0 && <TableRow><TableCell colSpan={4} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { RefreshCw } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get } from '@/api/client'
interface MatchRecord {
id: string; userId: string; matchedUserId: string; matchType: string
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
createdAt: string
}
export function TeamRecruitTab() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [isLoading, setIsLoading] = useState(true)
async function load() {
setIsLoading(true)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: 'team' })
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
} catch (e) { console.error(e) }
finally { setIsLoading(false) }
}
useEffect(() => { load() }, [page])
const totalPages = Math.ceil(total / pageSize) || 1
return (
<div>
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-gray-400"> {total} </p>
<p className="text-gray-500 text-xs mt-1"></p>
</div>
<button type="button" onClick={load} disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">...</span></div>
) : (
<>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] 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"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map(r => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell className="text-white">{r.userNickname || r.userId}</TableCell>
<TableCell className="text-white">{r.matchedNickname || r.matchedUserId}</TableCell>
<TableCell className="text-gray-400 text-sm">
{r.phone && <div>📱 {r.phone}</div>}
{r.wechatId && <div>💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && '-'}
</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{records.length === 0 && <TableRow><TableCell colSpan={4} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Card,
CardContent,
@@ -30,8 +32,11 @@ import {
BookOpen,
Gift,
Smartphone,
ShieldCheck,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
interface AuthorInfo {
name?: string
@@ -93,7 +98,14 @@ const defaultFeatures: FeatureConfig = {
aboutEnabled: true,
}
const TAB_KEYS = ['system', 'author', 'admin'] as const
type TabKey = (typeof TAB_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 [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
@@ -208,26 +220,58 @@ export function SettingsPage() {
}
}
const handleTabChange = (v: string) => {
setSearchParams(v === 'system' ? {} : { tab: v })
}
if (loading) return <div className="p-8 text-gray-500">...</div>
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1"></p>
</div>
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? '保存中...' : '保存设置'}
</Button>
{activeTab === 'system' && (
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? '保存中...' : '保存设置'}
</Button>
)}
</div>
<div className="space-y-6">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="mb-6 bg-[#0f2137] border border-gray-700/50 p-1">
<TabsTrigger
value="system"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<Settings className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="author"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<UserCircle className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="admin"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<ShieldCheck className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-0">
<div className="space-y-6">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -561,7 +605,17 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="author" className="mt-0">
<AuthorSettingsPage />
</TabsContent>
<TabsContent value="admin" className="mt-0">
<AdminUsersPage />
</TabsContent>
</Tabs>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent