更新个人资料页实现评估会议记录,明确展示与编辑页字段一致性要求,补充技能字段的展示与编辑需求。优化小程序页面,增加联系方式完善弹窗,确保用户在使用找伙伴功能前填写手机号或微信号。调整相关文档以反映最新进展,提升用户体验与功能一致性。

This commit is contained in:
Alex-larget
2026-02-28 15:16:23 +08:00
parent 244fe98591
commit 41e5b1258b
57 changed files with 3451 additions and 1740 deletions

View File

@@ -16,6 +16,8 @@ import { QRCodesPage } from './pages/qrcodes/QRCodesPage'
import { MatchPage } from './pages/match/MatchPage'
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 { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
@@ -34,6 +36,8 @@ function App() {
<Route path="chapters" element={<ChaptersPage />} />
<Route path="referral-settings" element={<ReferralSettingsPage />} />
<Route path="vip-roles" element={<VipRolesPage />} />
<Route path="mentors" element={<MentorsPage />} />
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="payment" element={<PaymentPage />} />
<Route path="site" element={<SitePage />} />

View File

@@ -10,6 +10,8 @@ import {
BookOpen,
GitMerge,
Crown,
GraduationCap,
Calendar,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken } from '@/api/auth'
@@ -19,6 +21,8 @@ const menuItems = [
{ icon: BookOpen, label: '内容管理', href: '/content' },
{ icon: Users, label: '用户管理', href: '/users' },
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
{ 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

@@ -49,6 +49,7 @@ interface SectionListItem {
title: string
price: number
isFree?: boolean
isNew?: boolean
partId?: string
partTitle?: string
chapterId?: string
@@ -62,6 +63,7 @@ interface Section {
price: number
filePath?: string
isFree?: boolean
isNew?: boolean
}
interface Chapter {
@@ -83,6 +85,7 @@ interface EditingSection {
content?: string
filePath?: string
isFree?: boolean
isNew?: boolean
}
function buildTree(sections: SectionListItem[]): Part[] {
@@ -108,6 +111,7 @@ function buildTree(sections: SectionListItem[]): Part[] {
price: s.price ?? 1,
filePath: s.filePath,
isFree: s.isFree,
isNew: s.isNew,
})
}
return Array.from(partMap.values()).map((p) => ({
@@ -201,6 +205,7 @@ export function ContentPage() {
`/api/db/book?action=read&id=${encodeURIComponent(section.id)}`,
)
if (data?.success && data.section) {
const sec = data.section as { isNew?: boolean }
setEditingSection({
id: section.id,
title: data.section.title ?? section.title,
@@ -208,6 +213,7 @@ export function ContentPage() {
content: data.section.content ?? '',
filePath: section.filePath,
isFree: section.isFree || section.price === 0,
isNew: sec.isNew ?? section.isNew,
})
} else {
setEditingSection({
@@ -217,6 +223,7 @@ export function ContentPage() {
content: '',
filePath: section.filePath,
isFree: section.isFree,
isNew: section.isNew,
})
if (data && !(data as { success?: boolean }).success) {
alert('无法读取文件内容: ' + ((data as { error?: string }).error || '未知错误'))
@@ -256,6 +263,7 @@ export function ContentPage() {
price: editingSection.isFree ? 0 : editingSection.price,
content,
isFree: editingSection.isFree || editingSection.price === 0,
isNew: editingSection.isNew,
saveToFile: true,
})
if (res && (res as { success?: boolean }).success !== false) {
@@ -557,6 +565,25 @@ export function ContentPage() {
</label>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex items-center h-10">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={editingSection.isNew ?? false}
onChange={(e) =>
setEditingSection({
...editingSection,
isNew: e.target.checked,
})
}
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
<span className="ml-2 text-gray-400 text-sm"> NEW</span>
</label>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>

View File

@@ -0,0 +1,133 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Calendar, RefreshCw } from 'lucide-react'
import { get } from '@/api/client'
import { Button } from '@/components/ui/button'
interface Consultation {
id: number
userId: number
mentorId: number
consultationType: string
amount: number
status: string
createdAt: string
}
export function MentorConsultationsPage() {
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('Load consultations error:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [statusFilter])
const statusMap: Record<string, string> = {
created: '已创建',
pending_pay: '待支付',
paid: '已支付',
completed: '已完成',
cancelled: '已取消',
}
const typeMap: Record<string, string> = {
single: '单次',
half_year: '半年',
year: '年度',
}
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
stitch_soul
</p>
</div>
<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">
<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}</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,429 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Users, Plus, Edit3, Trash2, X, Save } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
interface Mentor {
id: number
name: string
avatar?: string
intro?: string
tags?: string
priceSingle?: number
priceHalfYear?: number
priceYear?: number
quote?: string
whyFind?: string
offering?: string
judgmentStyle?: string
sort: number
enabled?: boolean
}
export function MentorsPage() {
const [mentors, setMentors] = useState<Mentor[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<Mentor | null>(null)
const [form, setForm] = useState({
name: '',
avatar: '',
intro: '',
tags: '',
priceSingle: '',
priceHalfYear: '',
priceYear: '',
quote: '',
whyFind: '',
offering: '',
judgmentStyle: '',
sort: 0,
enabled: true,
})
const [saving, setSaving] = useState(false)
async function load() {
setLoading(true)
try {
const data = await get<{ success?: boolean; data?: Mentor[] }>('/api/db/mentors')
if (data?.success && data.data) setMentors(data.data)
} catch (e) {
console.error('Load mentors error:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const resetForm = () => {
setForm({
name: '',
avatar: '',
intro: '',
tags: '',
priceSingle: '',
priceHalfYear: '',
priceYear: '',
quote: '',
whyFind: '',
offering: '',
judgmentStyle: '',
sort: mentors.length > 0 ? Math.max(...mentors.map((m) => m.sort)) + 1 : 0,
enabled: true,
})
}
const handleAdd = () => {
setEditing(null)
resetForm()
setShowModal(true)
}
const handleEdit = (m: Mentor) => {
setEditing(m)
setForm({
name: m.name,
avatar: m.avatar || '',
intro: m.intro || '',
tags: m.tags || '',
priceSingle: m.priceSingle != null ? String(m.priceSingle) : '',
priceHalfYear: m.priceHalfYear != null ? String(m.priceHalfYear) : '',
priceYear: m.priceYear != null ? String(m.priceYear) : '',
quote: m.quote || '',
whyFind: m.whyFind || '',
offering: m.offering || '',
judgmentStyle: m.judgmentStyle || '',
sort: m.sort,
enabled: m.enabled ?? true,
})
setShowModal(true)
}
const handleSave = async () => {
if (!form.name.trim()) {
alert('导师姓名不能为空')
return
}
setSaving(true)
try {
const num = (s: string) => (s === '' ? undefined : parseFloat(s))
const payload = {
name: form.name.trim(),
avatar: form.avatar.trim() || undefined,
intro: form.intro.trim() || undefined,
tags: form.tags.trim() || undefined,
priceSingle: num(form.priceSingle),
priceHalfYear: num(form.priceHalfYear),
priceYear: num(form.priceYear),
quote: form.quote.trim() || undefined,
whyFind: form.whyFind.trim() || undefined,
offering: form.offering.trim() || undefined,
judgmentStyle: form.judgmentStyle.trim() || undefined,
sort: form.sort,
enabled: form.enabled,
}
if (editing) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/mentors', {
id: editing.id,
...payload,
})
if (data?.success) {
setShowModal(false)
load()
} else {
alert('更新失败: ' + (data as { error?: string })?.error)
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/mentors', payload)
if (data?.success) {
setShowModal(false)
load()
} else {
alert('新增失败: ' + (data as { error?: string })?.error)
}
}
} catch (e) {
console.error('Save error:', e)
alert('保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该导师?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/mentors?id=${id}`)
if (data?.success) load()
else alert('删除失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Delete error:', e)
alert('删除失败')
}
}
const fmt = (v?: number) => (v != null ? `¥${v}` : '-')
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
stitch_soul //
</p>
</div>
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</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"></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>
{mentors.map((m) => (
<TableRow key={m.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{m.id}</TableCell>
<TableCell className="text-white">{m.name}</TableCell>
<TableCell className="text-gray-400 max-w-[200px] truncate">{m.intro || '-'}</TableCell>
<TableCell className="text-gray-400">{fmt(m.priceSingle)}</TableCell>
<TableCell className="text-gray-400">{fmt(m.priceHalfYear)}</TableCell>
<TableCell className="text-gray-400">{fmt(m.priceYear)}</TableCell>
<TableCell className="text-gray-400">{m.sort}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(m)}
className="text-gray-400 hover:text-[#38bdac]"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(m.id)}
className="text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{mentors.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">
{editing ? '编辑导师' : '新增导师'}
</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"> *</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:卡若"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={form.sort}
onChange={(e) => setForm((f) => ({ ...f, sort: parseInt(e.target.value, 10) || 0 }))}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> URL</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="https://..."
value={form.avatar}
onChange={(e) => setForm((f) => ({ ...f, avatar: 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="如:结构判断型咨询 · Decision > Execution"
value={form.intro}
onChange={(e) => setForm((f) => ({ ...f, intro: 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={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
/>
</div>
<div className="border-t border-gray-700 pt-4">
<Label className="text-gray-300 block mb-2"></Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-gray-500 text-xs"> ¥</Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="980"
value={form.priceSingle}
onChange={(e) => setForm((f) => ({ ...f, priceSingle: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-500 text-xs"> ¥</Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="19800"
value={form.priceHalfYear}
onChange={(e) => setForm((f) => ({ ...f, priceHalfYear: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-500 text-xs"> ¥</Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="29800"
value={form.priceYear}
onChange={(e) => setForm((f) => ({ ...f, priceYear: e.target.value }))}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:大多数人失败,不是因为不努力..."
value={form.quote}
onChange={(e) => setForm((f) => ({ ...f, quote: 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={form.whyFind}
onChange={(e) => setForm((f) => ({ ...f, whyFind: 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={form.offering}
onChange={(e) => setForm((f) => ({ ...f, offering: 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={form.judgmentStyle}
onChange={(e) => setForm((f) => ({ ...f, judgmentStyle: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
className="rounded border-gray-600 bg-[#0a1628]"
/>
<Label htmlFor="enabled" className="text-gray-300 cursor-pointer"></Label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="border-gray-600 text-gray-300"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}