Files
soul/app/admin/users/page.tsx
卡若 b60edb3d47 feat: 完整重构小程序匹配功能 + 修复UI对齐 + 文章数据API
主要更新:
1. 按H5网页端完全重构匹配功能(match页面)
   - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募
   - 资源对接等类型弹出手机号/微信号输入框
   - 去掉重新匹配按钮,改为返回按钮

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

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

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

5. 新增文章数据API
   - /api/db/chapters - 获取完整书籍结构
   - 支持按ID获取单篇文章内容
2026-01-21 15:49:12 +08:00

496 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
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 { 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 [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(() => {
loadUsers()
}, [])
const filteredUsers = users.filter((u) =>
u.nickname?.includes(searchTerm) || u.phone?.includes(searchTerm)
)
// 删除用户
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)
}
}
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<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
type="text"
placeholder="搜索用户..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500 w-64"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<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">
{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>
</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>
)
}
export default function UsersPage() {
return (
<Suspense fallback={null}>
<UsersContent />
</Suspense>
)
}