sync: Gitea 同步配置、miniprogram 页面逻辑、miniprogram 页面样式、脚本与配置、soul-admin 前端、soul-admin 页面、soul-api 接口逻辑、soul-api 路由等 | 原因: 多模块开发更新
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 名:得分 20~1</li>
|
||||
<li>最近更新前 30 篇:得分 30~1</li>
|
||||
<li>付款数前 20 名:得分 20~1</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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
65
soul-admin/src/pages/find-partner/FindPartnerPage.tsx
Normal file
65
soul-admin/src/pages/find-partner/FindPartnerPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx
Normal file
242
soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx
Normal file
242
soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx
Normal file
132
soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx
Normal file
86
soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
86
soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx
Normal file
86
soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user