feat: 完整重构小程序匹配功能 + 修复UI对齐 + 文章数据API

主要更新:
1. 按H5网页端完全重构匹配功能(match页面)
   - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募
   - 资源对接等类型弹出手机号/微信号输入框
   - 去掉重新匹配按钮,改为返回按钮

2. 修复所有卡片对齐和宽度问题
   - 目录页附录卡片居中
   - 首页阅读进度卡片满宽度
   - 我的页面菜单卡片对齐
   - 推广中心分享卡片统一宽度

3. 修复目录页图标和文字对齐
   - section-icon固定40rpx宽高
   - section-title与图标垂直居中

4. 更新真实完整文章标题(62篇)
   - 从book目录读取真实markdown文件名
   - 替换之前的简化标题

5. 新增文章数据API
   - /api/db/chapters - 获取完整书籍结构
   - 支持按ID获取单篇文章内容
This commit is contained in:
卡若
2026-01-21 15:49:12 +08:00
parent 1ee25e3dab
commit b60edb3d47
197 changed files with 34430 additions and 7345 deletions

View File

@@ -4,26 +4,220 @@ import { useState, useEffect, Suspense } 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 { Badge } from "@/components/ui/badge"
import { useStore, type User } from "@/lib/store"
import { Search, UserPlus, Eye, Trash2 } from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch"
import { Search, UserPlus, Eye, Trash2, Edit3, Key, Save, X, RefreshCw } from "lucide-react"
interface User {
id: string
phone: string
nickname: string
password?: string
is_admin?: boolean
has_full_book?: boolean
referral_code: string
referred_by?: string
earnings: number
pending_earnings: number
withdrawn_earnings: number
referral_count: number
match_count_today?: number
last_match_date?: string
created_at: string
}
function UsersContent() {
const { getAllUsers, deleteUser } = useStore()
const [users, setUsers] = useState<User[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [showUserModal, setShowUserModal] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isSaving, setIsSaving] = useState(false)
// 初始表单状态
const [formData, setFormData] = useState({
phone: "",
nickname: "",
password: "",
is_admin: false,
has_full_book: false,
})
// 加载用户列表
const loadUsers = async () => {
setIsLoading(true)
try {
const res = await fetch('/api/db/users')
const data = await res.json()
if (data.success) {
setUsers(data.users || [])
}
} catch (error) {
console.error('Load users error:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setUsers(getAllUsers())
}, [getAllUsers])
loadUsers()
}, [])
const filteredUsers = users.filter((u) => u.nickname.includes(searchTerm) || u.phone.includes(searchTerm))
const filteredUsers = users.filter((u) =>
u.nickname?.includes(searchTerm) || u.phone?.includes(searchTerm)
)
const handleDelete = (userId: string) => {
if (confirm("确定要删除这个用户吗?")) {
deleteUser(userId)
setUsers(getAllUsers())
// 删除用户
const handleDelete = async (userId: string) => {
if (!confirm("确定要删除这个用户吗?")) return
try {
const res = await fetch(`/api/db/users?id=${userId}`, { method: 'DELETE' })
const data = await res.json()
if (data.success) {
loadUsers()
} else {
alert("删除失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error('Delete user error:', error)
alert("删除失败")
}
}
// 打开编辑用户弹窗
const handleEditUser = (user: User) => {
setEditingUser(user)
setFormData({
phone: user.phone,
nickname: user.nickname,
password: "",
is_admin: user.is_admin || false,
has_full_book: user.has_full_book || false,
})
setShowUserModal(true)
}
// 打开新建用户弹窗
const handleAddUser = () => {
setEditingUser(null)
setFormData({
phone: "",
nickname: "",
password: "",
is_admin: false,
has_full_book: false,
})
setShowUserModal(true)
}
// 保存用户
const handleSaveUser = async () => {
if (!formData.phone || !formData.nickname) {
alert("请填写手机号和昵称")
return
}
setIsSaving(true)
try {
if (editingUser) {
// 更新用户
const res = await fetch('/api/db/users', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingUser.id,
nickname: formData.nickname,
is_admin: formData.is_admin,
has_full_book: formData.has_full_book,
...(formData.password && { password: formData.password }),
})
})
const data = await res.json()
if (!data.success) {
alert("更新失败: " + (data.error || "未知错误"))
return
}
} else {
// 创建用户
const res = await fetch('/api/db/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: formData.phone,
nickname: formData.nickname,
password: formData.password,
is_admin: formData.is_admin,
})
})
const data = await res.json()
if (!data.success) {
alert("创建失败: " + (data.error || "未知错误"))
return
}
}
setShowUserModal(false)
loadUsers()
} catch (error) {
console.error('Save user error:', error)
alert("保存失败")
} finally {
setIsSaving(false)
}
}
// 打开修改密码弹窗
const handleChangePassword = (user: User) => {
setEditingUser(user)
setNewPassword("")
setConfirmPassword("")
setShowPasswordModal(true)
}
// 保存密码
const handleSavePassword = async () => {
if (!newPassword) {
alert("请输入新密码")
return
}
if (newPassword !== confirmPassword) {
alert("两次输入的密码不一致")
return
}
if (newPassword.length < 6) {
alert("密码长度不能少于6位")
return
}
setIsSaving(true)
try {
const res = await fetch('/api/db/users', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingUser?.id,
password: newPassword,
})
})
const data = await res.json()
if (data.success) {
alert("密码修改成功")
setShowPasswordModal(false)
} else {
alert("密码修改失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error('Change password error:', error)
alert("密码修改失败")
} finally {
setIsSaving(false)
}
}
@@ -35,6 +229,15 @@ function UsersContent() {
<p className="text-gray-400 mt-1"> {users.length} </p>
</div>
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={loadUsers}
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>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
@@ -45,82 +248,238 @@ function UsersContent() {
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Button onClick={handleAddUser} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<UserPlus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 用户编辑弹窗 */}
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}
{editingUser ? "编辑用户" : "添加用户"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="请输入手机号"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
disabled={!!editingUser}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="请输入昵称"
value={formData.nickname}
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">{editingUser ? "新密码 (留空则不修改)" : "密码"}</Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder={editingUser ? "留空则不修改" : "请输入密码"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-gray-300"></Label>
<Switch
checked={formData.is_admin}
onCheckedChange={(checked) => setFormData({ ...formData, is_admin: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-gray-300"></Label>
<Switch
checked={formData.has_full_book}
onCheckedChange={(checked) => setFormData({ ...formData, has_full_book: checked })}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowUserModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSaveUser}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 修改密码弹窗 */}
<Dialog open={showPasswordModal} onOpenChange={setShowPasswordModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Key className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-[#0a1628] rounded-lg p-3">
<p className="text-gray-400 text-sm">{editingUser?.nickname}</p>
<p className="text-gray-400 text-sm">{editingUser?.phone}</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="请输入新密码 (至少6位)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="请再次输入新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowPasswordModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleSavePassword}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSaving ? "保存中..." : "确认修改"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
<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>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
{user.nickname.charAt(0)}
</div>
<div>
<p className="font-medium text-white">{user.nickname}</p>
<p className="text-xs text-gray-500">ID: {user.id.slice(0, 8)}</p>
</div>
</div>
</TableCell>
<TableCell className="text-gray-300">{user.phone}</TableCell>
<TableCell>
{user.hasFullBook ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"></Badge>
) : user.purchasedSections.length > 0 ? (
<Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">
{user.purchasedSections.length}
</Badge>
) : (
<Badge variant="outline" className="text-gray-500 border-gray-600">
</Badge>
)}
</TableCell>
<TableCell className="text-white font-medium">¥{user.earnings?.toFixed(2) || "0.00"}</TableCell>
<TableCell className="text-gray-400">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-white hover:bg-gray-700/50">
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleDelete(user.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
{isLoading ? (
<div className="flex items-center 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>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
))}
{filteredUsers.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
{user.nickname?.charAt(0) || "?"}
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-white">{user.nickname}</p>
{user.is_admin && (
<Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs">
</Badge>
)}
</div>
<p className="text-xs text-gray-500">ID: {user.id?.slice(0, 8)}</p>
</div>
</div>
</TableCell>
<TableCell className="text-gray-300">{user.phone}</TableCell>
<TableCell>
{user.has_full_book ? (
<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 className="text-white font-medium">¥{(user.earnings || 0).toFixed(2)}</TableCell>
<TableCell className="text-gray-300">{user.match_count_today || 0}/3</TableCell>
<TableCell className="text-gray-400">
{user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(user)}
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleChangePassword(user)}
className="text-gray-400 hover:text-yellow-400 hover:bg-yellow-400/10"
>
<Key className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleDelete(user.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{filteredUsers.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>