This commit is contained in:
ryan
2026-06-08 08:19:22 +08:00
parent 4b72419a96
commit 9d0f9f0576
42 changed files with 1412 additions and 809 deletions
@@ -0,0 +1,160 @@
"use client"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import {useAdminUsers} from "@/contexts/admin-users-context"
import type {CreateUserRequest} from "@/lib/services/admin"
const emptyForm: CreateUserRequest = {
username: "",
password: "",
nickname: "",
is_active: true,
is_admin: false,
}
export function CreateUserModal({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const { createUser } = useAdminUsers()
const [form, setForm] = useState<CreateUserRequest>(emptyForm)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
useEffect(() => {
if (isOpen) {
setForm(emptyForm)
setErrors({})
} else {
setSaving(false)
}
}, [isOpen])
const validate = () => {
const newErrors: Record<string, string> = {}
if (!form.username.trim()) {
newErrors.username = "用户名不能为空"
} else if (form.username.trim().length < 3) {
newErrors.username = "用户名长度不能少于 3 位"
}
if (!form.password) {
newErrors.password = "密码不能为空"
} else if (form.password.length < 8) {
newErrors.password = "密码长度不能少于 8 位"
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
setSaving(true)
try {
await createUser({
...form,
username: form.username.trim(),
nickname: form.nickname?.trim() || undefined,
})
onClose()
} catch {
// Errors are already handled by context toast notifications
} finally {
setSaving(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>新增用户</DialogTitle>
<DialogDescription>
直接创建一个本地密码登录的新账户。
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<Input
id="username"
value={form.username}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="请输入用户名 (至少 3 位)"
/>
{errors.username && (
<p className="text-xs text-destructive">{errors.username}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="nickname">昵称 (选填)</Label>
<Input
id="nickname"
value={form.nickname}
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
placeholder="请输入昵称"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="请输入登录密码 (至少 8 位)"
/>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<div>
<div className="font-medium text-sm">启用账户</div>
<div className="text-xs text-muted-foreground">禁用后此账号将无法登录系统。</div>
</div>
<Switch
checked={form.is_active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<div>
<div className="font-medium text-sm">管理员权限</div>
<div className="text-xs text-muted-foreground">开启后此账号将拥有后台管理权限。</div>
</div>
<Switch
checked={form.is_admin}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_admin: checked }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t mt-2">
<Button variant="outline" type="button" onClick={onClose}>
取消
</Button>
<Button type="submit" disabled={saving} variant="secondary">
{saving ? "创建中..." : "创建"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -147,7 +147,7 @@ export function FilesMain() {
文件管理
</h1>
<p className="text-sm text-muted-foreground">
管理您上传的所有文件,支持下载和批量操作
管理您上传的所有文件,支持下载 and 批量操作
</p>
</div>
</div>
+39 -22
View File
@@ -1,25 +1,38 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Sheet, SheetTitle, SheetContent } from "@/components/ui/sheet"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Layers, Search, UserX, UserCheck, Eye, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Switch} from "@/components/ui/switch"
import {Separator} from "@/components/ui/separator"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Eye,
Filter,
Layers,
Loader2,
Plus,
Search,
ShieldCheck,
UserCheck,
UserX,
X
} from "lucide-react"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import { AdminUser, AdminService } from "@/lib/services"
import { toast } from "sonner"
import { formatDateTime } from "@/lib/utils"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { ErrorInline } from "@/components/layout/error"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { useAdminUsers } from "@/contexts/admin-users-context"
import {AdminUser} from "@/lib/services"
import {cn, formatDateTime} from "@/lib/utils"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {ErrorInline} from "@/components/layout/error"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {CreateUserModal} from "./create-user-modal"
export function UsersManager() {
const {
@@ -43,6 +56,7 @@ export function UsersManager() {
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
useEffect(() => {
fetchUsers()
@@ -277,6 +291,10 @@ export function UsersManager() {
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold">用户管理</div>
</div>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={() => setCreateModalOpen(true)}>
<Plus className="size-3.5 mr-1" />
新增用户
</Button>
</div>
{renderFilterBar()}
@@ -409,9 +427,6 @@ export function UsersManager() {
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
UID: {selectedUser.id}
</Badge>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
Lv.{selectedUser.trust_level}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
Admin
@@ -482,6 +497,8 @@ export function UsersManager() {
)}
</SheetContent>
</Sheet>
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
</div>
)
}
@@ -24,11 +24,7 @@ export const termsSections: PolicySection[] = [
title: "2. 服务定义与性质界定",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>2.1 纯技术研发脚手架:</strong>本平台是一个开源/闭源授权的技术二次开发底座。我们仅提供用户注册、API 调用、安全配置维护等<strong>纯软件技术服务</strong>。</p>
<p><strong>2.2 非金融机构与无承兑申明:</strong></p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>非持牌金融或支付机构:</strong>本系统不是银行、商户收单或清算结算机构。</li>
<li><strong>不提供资金管理:</strong>系统无真实法币、加密货币或商业代金券充值、存储与兑现功能。若在二开中加入了积分等属性,其最终性质也应限制于虚拟软件积分。</li>
<li><strong>责任自担:</strong>关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。</li>
</ul>
</div>
@@ -9,7 +9,7 @@ import {ArrowRight, ExternalLink, FileText, HelpCircle, Layers, Shield, ShieldCh
import Link from "next/link"
export function HomeMain() {
const { user, getTrustLevelLabel } = useUser()
const { user } = useUser()
const quickLinks = [
{
@@ -90,56 +90,56 @@ export function AuthSourceModal({
<div className="grid gap-4 md:grid-cols-2 pt-2">
<div className="space-y-2">
<Label>标识符 (英文名称)</Label>
<Input
value={form.name}
<Input
value={form.name}
disabled={!!source}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如: github"
/>
</div>
<div className="space-y-2">
<Label>展示名称</Label>
<Input
value={form.display_name}
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
<Input
value={form.display_name}
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
placeholder="例如: GitHub 登录"
/>
</div>
<div className="space-y-2">
<Label>Client ID</Label>
<Input
value={form.client_id}
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
<Input
value={form.client_id}
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Client Secret</Label>
<Input
value={form.client_secret}
onChange={(e) => setForm((prev) => ({ ...prev, client_secret: e.target.value }))}
placeholder={source ? "留空则保留原值" : ""}
<Input
value={form.client_secret}
onChange={(e) => setForm((prev) => ({ ...prev, client_secret: e.target.value }))}
placeholder={source ? "留空则保留原值" : ""}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Discovery URL (OIDC 发行方 URL)</Label>
<Input
value={form.openid_discovery_url}
onChange={(e) => setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))}
<Input
value={form.openid_discovery_url}
onChange={(e) => setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>Scopes</Label>
<Input
value={form.scopes}
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
<Input
value={form.scopes}
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>图标 URL (可选)</Label>
<Input
value={form.icon_url}
onChange={(e) => setForm((prev) => ({ ...prev, icon_url: e.target.value }))}
<Input
value={form.icon_url}
onChange={(e) => setForm((prev) => ({ ...prev, icon_url: e.target.value }))}
placeholder="https://... 或留空"
/>
</div>
@@ -154,7 +154,7 @@ export function AuthSourceModal({
<Button variant="outline" type="button" onClick={onClose}>
取消
</Button>
<Button type="button" onClick={saveSource} disabled={saving} className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10">
<Button type="button" onClick={saveSource} disabled={saving} variant="secondary">
{saving ? "保存中..." : "保存"}
</Button>
</div>
+104 -6
View File
@@ -14,18 +14,53 @@ import {
BreadcrumbSeparator
} from "@/components/ui/breadcrumb"
import {useUser} from "@/contexts/user-context"
import {ArrowRight, Link2, Loader2, Shield, Unlink} from "lucide-react"
import {ArrowRight, Info, Link2, Loader2, Lock, Shield, Unlink} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Separator} from "@/components/ui/separator"
import {AuthService} from "@/lib/services"
import {toast} from "sonner"
export function ProfileMain() {
const { user, loading, getTrustLevelLabel } = useUser()
const { user, loading, refetch } = useUser()
const controls = useAnimation()
const isAnimatingRef = React.useRef(false)
const queryClient = useQueryClient()
const [oldPassword, setOldPassword] = React.useState("")
const [newPassword, setNewPassword] = React.useState("")
const [confirmPassword, setConfirmPassword] = React.useState("")
const changePasswordMutation = useMutation({
mutationFn: (req: any) => AuthService.changePassword(req),
onSuccess: () => {
toast.success("密码修改成功")
setOldPassword("")
setNewPassword("")
setConfirmPassword("")
void refetch()
},
onError: (error: Error) => {
toast.error(error.message || "修改密码失败,请重试")
},
})
const handlePasswordChange = (e: React.FormEvent) => {
e.preventDefault()
if (newPassword !== confirmPassword) {
toast.error("两次输入的新密码不一致")
return
}
if (newPassword.length < 8) {
toast.error("新密码长度不能少于 8 位")
return
}
changePasswordMutation.mutate({
old_password: oldPassword,
new_password: newPassword,
})
}
const externalAccountBindingsQuery = useQuery({
queryKey: ["auth", "external-accounts"],
queryFn: () => AuthService.getExternalAccountBindings(),
@@ -135,10 +170,7 @@ export function ProfileMain() {
<div className="text-sm font-mono font-semibold">{user.id}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">信任等级</div>
<div className="text-sm font-semibold">{getTrustLevelLabel(user.trust_level)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">管理员身份</div>
@@ -158,6 +190,72 @@ export function ProfileMain() {
</div>
</div>
{/* 修改密码面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4 flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-500">
<Lock className="size-4" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">修改密码</h2>
<p className="text-xs text-muted-foreground">更改您的账号密码以确保安全。密码长度不能少于 8 位。</p>
</div>
</div>
{user.need_change_password && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-3 text-xs text-amber-500 flex items-start gap-2.5">
<Info className="size-4 shrink-0 mt-0.5" />
<div>
<p className="font-semibold">密码风险提示</p>
<p className="mt-0.5 text-amber-500/80 leading-relaxed font-normal">
为了账号安全,您必须修改初始密码。
</p>
</div>
</div>
)}
<form onSubmit={handlePasswordChange} className="space-y-4 pt-2 max-w-md">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">当前密码</label>
<Input
type="password"
placeholder="请输入当前密码"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">新密码</label>
<Input
type="password"
placeholder="新密码(至少 8 位)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">确认新密码</label>
<Input
type="password"
placeholder="确认新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<Button
type="submit"
className="w-full sm:w-auto"
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? "提交中..." : "确认修改"}
</Button>
</form>
</div>
{/* 账号绑定面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4 flex items-center gap-2">