refactor: 对接 duoqi-api 管理员管理接口规范

- 精简角色类型:移除 moderator,仅保留 super_admin 和 admin
- Admin 数据模型补全 isActive、updatedAt 字段
- 创建/重置密码改为展示服务端生成的 plainPassword(含复制按钮)
- 新增编辑管理员对话框(用户名、角色、启用/停用状态)
- fetchAdmins 支持分页和筛选参数
- loginWithToken 适配向后兼容的 { authenticated } 响应格式
- 添加内联成功/错误消息提示
This commit is contained in:
Wang Zhuoxuan 2026-04-11 18:53:37 +08:00
parent 66fc078b3c
commit 8e3d4ed190
6 changed files with 440 additions and 164 deletions

View File

@ -1,6 +1,6 @@
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
import type { ApiResponse, LoginResponse, RefreshTokenResponse } from "@/types/api" import type { ApiResponse, LoginResponse, PaginatedResponse, RefreshTokenResponse } from "@/types/api"
import type { Admin, AdminLoginForm, CreateAdminForm } from "@/types/admin" import type { Admin, CreateAdminRequest, CreateAdminResponse, ResetPasswordResponse, UpdateAdminRequest } from "@/types/admin"
// ==================== 认证相关 ==================== // ==================== 认证相关 ====================
@ -9,7 +9,7 @@ import type { Admin, AdminLoginForm, CreateAdminForm } from "@/types/admin"
* POST /admin/auth/login * POST /admin/auth/login
*/ */
export async function loginAdmin( export async function loginAdmin(
credentials: AdminLoginForm credentials: { username: string; password: string }
): Promise<ApiResponse<LoginResponse>> { ): Promise<ApiResponse<LoginResponse>> {
return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<LoginResponse>>() return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<LoginResponse>>()
} }
@ -20,8 +20,8 @@ export async function loginAdmin(
*/ */
export async function loginWithToken( export async function loginWithToken(
token: string token: string
): Promise<ApiResponse<LoginResponse>> { ): Promise<ApiResponse<{ authenticated: boolean }>> {
return apiClient.post("auth", { json: { token } }).json<ApiResponse<LoginResponse>>() return apiClient.post("auth", { json: { token } }).json<ApiResponse<{ authenticated: boolean }>>()
} }
/** /**
@ -46,12 +46,30 @@ export async function fetchMe(): Promise<ApiResponse<Admin>> {
// ==================== 管理员管理 ==================== // ==================== 管理员管理 ====================
/** fetchAdmins 的查询参数 */
export interface FetchAdminsParams {
page?: number
limit?: number
role?: "admin" | "super_admin"
isActive?: 0 | 1
}
/** /**
* *
* GET /admin/admins * GET /admin/admins?page=&limit=&role=&isActive=
*/ */
export async function fetchAdmins(): Promise<ApiResponse<Admin[]>> { export async function fetchAdmins(
return apiClient.get("admins").json<ApiResponse<Admin[]>>() params?: FetchAdminsParams
): Promise<PaginatedResponse<Admin>> {
const searchParams = new URLSearchParams()
if (params?.page) searchParams.set("page", String(params.page))
if (params?.limit) searchParams.set("limit", String(params.limit))
if (params?.role) searchParams.set("role", params.role)
if (params?.isActive !== undefined) searchParams.set("isActive", String(params.isActive))
const qs = searchParams.toString()
const path = qs ? `admins?${qs}` : "admins"
return apiClient.get(path).json<PaginatedResponse<Admin>>()
} }
/** /**
@ -63,43 +81,44 @@ export async function fetchAdmin(id: string): Promise<ApiResponse<Admin>> {
} }
/** /**
* * super_admin
* POST /admin/admins * POST /admin/admins
* plainPassword
*/ */
export async function createAdmin( export async function createAdmin(
data: CreateAdminForm data: CreateAdminRequest
): Promise<ApiResponse<Admin>> { ): Promise<ApiResponse<CreateAdminResponse>> {
return apiClient.post("admins", { json: data }).json<ApiResponse<Admin>>() return apiClient.post("admins", { json: data }).json<ApiResponse<CreateAdminResponse>>()
} }
/** /**
* * super_admin
* PUT /admin/admins/:id * PUT /admin/admins/:id
*/ */
export async function updateAdmin( export async function updateAdmin(
id: string, id: string,
data: Partial<CreateAdminForm> data: UpdateAdminRequest
): Promise<ApiResponse<Admin>> { ): Promise<ApiResponse<Admin>> {
return apiClient.put(`admins/${id}`, { json: data }).json<ApiResponse<Admin>>() return apiClient.put(`admins/${id}`, { json: data }).json<ApiResponse<Admin>>()
} }
/** /**
* * super_admin
* DELETE /admin/admins/:id * DELETE /admin/admins/:id
*/ */
export async function deleteAdmin(id: string): Promise<ApiResponse<{ id: string }>> { export async function deleteAdmin(id: string): Promise<ApiResponse<Admin>> {
return apiClient.delete(`admins/${id}`).json<ApiResponse<{ id: string }>>() return apiClient.delete(`admins/${id}`).json<ApiResponse<Admin>>()
} }
/** /**
* * super_admin
* POST /admin/admins/:id/reset-password * POST /admin/admins/:id/reset-password
* plainPassword
*/ */
export async function resetAdminPassword( export async function resetAdminPassword(
id: string, id: string
newPassword: string ): Promise<ApiResponse<ResetPasswordResponse>> {
): Promise<ApiResponse<Admin>> {
return apiClient return apiClient
.post(`admins/${id}/reset-password`, { json: { password: newPassword } }) .post(`admins/${id}/reset-password`)
.json<ApiResponse<Admin>>() .json<ApiResponse<ResetPasswordResponse>>()
} }

View File

@ -107,5 +107,4 @@ export const SETTING_CATEGORY_LABELS: Record<SettingCategory, string> = {
export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = { export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = {
super_admin: "超级管理员", super_admin: "超级管理员",
admin: "管理员", admin: "管理员",
moderator: "审核员",
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react" import { useState, useEffect, useCallback } from "react"
import { Plus, Trash2, Shield, ShieldAlert, Key } from "lucide-react" import { Check, Copy, Pencil, Plus, Shield, Trash2, Key } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Table, Table,
@ -27,6 +27,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -37,44 +38,94 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { fetchAdmins, createAdmin, deleteAdmin, resetAdminPassword } from "@/lib/api/admin-api" import {
fetchAdmins,
createAdmin,
updateAdmin,
deleteAdmin,
resetAdminPassword,
} from "@/lib/api/admin-api"
import { ADMIN_ROLE_LABELS } from "@/lib/constants" import { ADMIN_ROLE_LABELS } from "@/lib/constants"
import type { Admin, AdminRole, CreateAdminForm } from "@/types/admin" import { getCurrentAdminId } from "@/lib/auth"
import type { Admin, AdminRole, CreateAdminRequest, UpdateAdminRequest } from "@/types/admin"
const roleIcons = { // ==================== 内联消息组件 ====================
super_admin: Shield,
admin: Shield, function InlineMessage({
moderator: ShieldAlert, variant,
children,
}: {
variant: "success" | "error"
children: React.ReactNode
}) {
const base = "rounded-md border px-4 py-3 text-sm"
const styles =
variant === "success"
? "border-green-200 bg-green-50 text-green-800"
: "border-red-200 bg-red-50 text-red-800"
return <div className={`${base} ${styles}`}>{children}</div>
} }
const roleBadgeVariants: Record<AdminRole, "default" | "secondary"> = { // ==================== 复制按钮 ====================
super_admin: "default",
admin: "default", function CopyButton({ text }: { text: string }) {
moderator: "secondary", const [copied, setCopied] = useState(false)
async function handleCopy() {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? <Check className="size-3.5 mr-1" /> : <Copy className="size-3.5 mr-1" />}
{copied ? "已复制" : "复制"}
</Button>
)
} }
// ==================== 主页面 ====================
export default function AdminsPage() { export default function AdminsPage() {
const [admins, setAdmins] = useState<Admin[]>([]) const [admins, setAdmins] = useState<Admin[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false) const [pageMessage, setPageMessage] = useState<{
variant: "success" | "error"
text: string
} | null>(null)
// 对话框状态
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
const [resetPasswordOpen, setResetPasswordOpen] = useState(false) const [resetOpen, setResetOpen] = useState(false)
const [passwordResultOpen, setPasswordResultOpen] = useState(false)
const [selectedAdmin, setSelectedAdmin] = useState<Admin | null>(null) const [selectedAdmin, setSelectedAdmin] = useState<Admin | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
// 表单状态 // 创建管理员表单
const [formData, setFormData] = useState<CreateAdminForm>({ const [createForm, setCreateForm] = useState<CreateAdminRequest>({
username: "", username: "",
password: "", password: "",
role: "moderator", role: "admin",
}) })
// 重置密码表单 // 编辑管理员表单
const [resetPasswordData, setResetPasswordData] = useState({ const [editForm, setEditForm] = useState<UpdateAdminRequest & { username: string }>({
newPassword: "", username: "",
confirmPassword: "", role: "admin",
isActive: 1,
}) })
// 密码结果(创建或重置返回的 plainPassword
const [passwordResult, setPasswordResult] = useState<{
username: string
password: string
} | null>(null)
const currentAdminId = getCurrentAdminId()
const loadAdmins = useCallback(async () => { const loadAdmins = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@ -91,14 +142,30 @@ export default function AdminsPage() {
loadAdmins() loadAdmins()
}, [loadAdmins]) }, [loadAdmins])
// 清除页面消息
useEffect(() => {
if (pageMessage) {
const timer = setTimeout(() => setPageMessage(null), 5000)
return () => clearTimeout(timer)
}
}, [pageMessage])
// ---- 对话框操作 ----
function openCreateDialog() { function openCreateDialog() {
setSelectedAdmin(null) setCreateForm({ username: "", password: "", role: "admin" })
setFormData({ setPasswordResult(null)
username: "", setCreateOpen(true)
password: "", }
role: "moderator",
function openEditDialog(admin: Admin) {
setSelectedAdmin(admin)
setEditForm({
username: admin.username,
role: admin.role,
isActive: admin.isActive as 0 | 1,
}) })
setDialogOpen(true) setEditOpen(true)
} }
function openDeleteDialog(admin: Admin) { function openDeleteDialog(admin: Admin) {
@ -106,21 +173,58 @@ export default function AdminsPage() {
setDeleteOpen(true) setDeleteOpen(true)
} }
function openResetPasswordDialog(admin: Admin) { function openResetDialog(admin: Admin) {
setSelectedAdmin(admin) setSelectedAdmin(admin)
setResetPasswordData({ newPassword: "", confirmPassword: "" }) setResetOpen(true)
setResetPasswordOpen(true)
} }
async function handleSubmit() { // ---- 提交操作 ----
if (!formData.password) {
return async function handleCreate() {
} if (!createForm.username || !createForm.password) return
setSubmitting(true) setSubmitting(true)
try { try {
await createAdmin(formData) const res = await createAdmin(createForm)
setDialogOpen(false) if (res.success && res.data) {
await loadAdmins() setPasswordResult({ username: res.data.username, password: res.data.plainPassword })
await loadAdmins()
} else {
setPageMessage({ variant: "error", text: res.error?.message ?? "创建失败" })
setCreateOpen(false)
}
} catch {
setPageMessage({ variant: "error", text: "网络错误,创建失败" })
setCreateOpen(false)
} finally {
setSubmitting(false)
}
}
async function handleEdit() {
if (!selectedAdmin) return
setSubmitting(true)
try {
const body: UpdateAdminRequest = {}
if (editForm.username && editForm.username !== selectedAdmin.username) {
body.username = editForm.username
}
if (editForm.role && editForm.role !== selectedAdmin.role) {
body.role = editForm.role
}
if (editForm.isActive !== undefined && editForm.isActive !== selectedAdmin.isActive) {
body.isActive = editForm.isActive as 0 | 1
}
const res = await updateAdmin(selectedAdmin.id, body)
if (res.success) {
setEditOpen(false)
setPageMessage({ variant: "success", text: "管理员信息已更新" })
await loadAdmins()
} else {
setPageMessage({ variant: "error", text: res.error?.message ?? "更新失败" })
}
} catch {
setPageMessage({ variant: "error", text: "网络错误,更新失败" })
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@ -128,28 +232,48 @@ export default function AdminsPage() {
async function handleDelete() { async function handleDelete() {
if (!selectedAdmin) return if (!selectedAdmin) return
await deleteAdmin(selectedAdmin.id)
setDeleteOpen(false)
setSelectedAdmin(null)
await loadAdmins()
}
async function handleResetPassword() {
if (!selectedAdmin || resetPasswordData.newPassword !== resetPasswordData.confirmPassword) {
return
}
setSubmitting(true) setSubmitting(true)
try { try {
await resetAdminPassword(selectedAdmin.id, resetPasswordData.newPassword) const res = await deleteAdmin(selectedAdmin.id)
setResetPasswordOpen(false) if (res.success) {
// TODO: 显示成功提示 setDeleteOpen(false)
setSelectedAdmin(null)
setPageMessage({ variant: "success", text: "管理员已停用" })
await loadAdmins()
} else {
setPageMessage({ variant: "error", text: res.error?.message ?? "删除失败" })
setDeleteOpen(false)
}
} catch {
setPageMessage({ variant: "error", text: "网络错误,删除失败" })
setDeleteOpen(false)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
} }
// 获取当前管理员的信息(从 localStorage async function handleResetPassword() {
const currentAdminId = localStorage.getItem("duoqi_admin_current_id") if (!selectedAdmin) return
setSubmitting(true)
try {
const res = await resetAdminPassword(selectedAdmin.id)
if (res.success && res.data) {
setResetOpen(false)
setPasswordResult({ username: res.data.username, password: res.data.plainPassword })
setPasswordResultOpen(true)
} else {
setPageMessage({ variant: "error", text: res.error?.message ?? "重置失败" })
setResetOpen(false)
}
} catch {
setPageMessage({ variant: "error", text: "网络错误,重置失败" })
setResetOpen(false)
} finally {
setSubmitting(false)
}
}
// ---- 渲染 ----
return ( return (
<> <>
@ -166,12 +290,17 @@ export default function AdminsPage() {
</Button> </Button>
</div> </div>
{pageMessage && (
<InlineMessage variant={pageMessage.variant}>{pageMessage.text}</InlineMessage>
)}
<div className="rounded-lg border"> <div className="rounded-lg border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
@ -180,25 +309,24 @@ export default function AdminsPage() {
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground"> <TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
... ...
</TableCell> </TableCell>
</TableRow> </TableRow>
) : admins.length === 0 ? ( ) : admins.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground"> <TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
admins.map((admin) => { admins.map((admin) => {
const RoleIcon = roleIcons[admin.role]
const isCurrentUser = admin.id === currentAdminId const isCurrentUser = admin.id === currentAdminId
return ( return (
<TableRow key={admin.id}> <TableRow key={admin.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RoleIcon className="size-4 text-muted-foreground" /> <Shield className="size-4 text-muted-foreground" />
{admin.username} {admin.username}
{isCurrentUser && ( {isCurrentUser && (
<Badge variant="outline" className="text-xs"></Badge> <Badge variant="outline" className="text-xs"></Badge>
@ -206,10 +334,15 @@ export default function AdminsPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={roleBadgeVariants[admin.role]}> <Badge variant={admin.role === "super_admin" ? "default" : "secondary"}>
{ADMIN_ROLE_LABELS[admin.role]} {ADMIN_ROLE_LABELS[admin.role]}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>
<Badge variant={admin.isActive ? "default" : "outline"}>
{admin.isActive ? "活跃" : "停用"}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{new Date(admin.createdAt).toLocaleString("zh-CN")} {new Date(admin.createdAt).toLocaleString("zh-CN")}
</TableCell> </TableCell>
@ -223,7 +356,15 @@ export default function AdminsPage() {
<Button <Button
variant="outline" variant="outline"
size="icon-xs" size="icon-xs"
onClick={() => openResetPasswordDialog(admin)} onClick={() => openEditDialog(admin)}
title="编辑"
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="icon-xs"
onClick={() => openResetDialog(admin)}
title="重置密码" title="重置密码"
> >
<Key className="size-3.5" /> <Key className="size-3.5" />
@ -234,7 +375,7 @@ export default function AdminsPage() {
className="text-destructive" className="text-destructive"
onClick={() => openDeleteDialog(admin)} onClick={() => openDeleteDialog(admin)}
disabled={isCurrentUser} disabled={isCurrentUser}
title={isCurrentUser ? "不能删除当前账号" : "删除"} title={isCurrentUser ? "不能停用当前账号" : "停用"}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
@ -249,7 +390,7 @@ export default function AdminsPage() {
</div> </div>
{/* 创建管理员对话框 */} {/* 创建管理员对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
@ -258,35 +399,114 @@ export default function AdminsPage() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{passwordResult ? (
// 创建成功,显示生成的密码
<div className="space-y-4 py-4">
<InlineMessage variant="success">
{passwordResult.username}
</InlineMessage>
<div>
<Label></Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 rounded bg-muted px-3 py-2 text-sm font-mono">
{passwordResult.password}
</code>
<CopyButton text={passwordResult.password} />
</div>
<p className="text-xs text-muted-foreground mt-2">
</p>
</div>
</div>
) : (
// 创建表单
<div className="space-y-4 py-4">
<div>
<Label htmlFor="create-username"></Label>
<Input
id="create-username"
value={createForm.username}
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
placeholder="请输入用户名3-50字符"
/>
</div>
<div>
<Label htmlFor="create-password"></Label>
<Input
id="create-password"
type="password"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
placeholder="请输入密码8-128字符"
/>
</div>
<div>
<Label htmlFor="create-role"></Label>
<Select
value={createForm.role}
onValueChange={(val) => setCreateForm({ ...createForm, role: val as AdminRole })}
>
<SelectTrigger id="create-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ADMIN_ROLE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter>
{passwordResult ? (
<Button onClick={() => setCreateOpen(false)}></Button>
) : (
<>
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={submitting}>
</Button>
<Button
onClick={handleCreate}
disabled={submitting || !createForm.username || createForm.password.length < 8}
>
{submitting ? "创建中..." : "创建"}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* 编辑管理员对话框 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{selectedAdmin?.username}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div> <div>
<Label htmlFor="username"></Label> <Label htmlFor="edit-username"></Label>
<Input <Input
id="username" id="edit-username"
value={formData.username} value={editForm.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })} onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
placeholder="请输入用户名"
/> />
</div> </div>
<div> <div>
<Label htmlFor="password"></Label> <Label htmlFor="edit-role"></Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="请输入密码(至少 6 位)"
/>
</div>
<div>
<Label htmlFor="role"></Label>
<Select <Select
value={formData.role} value={editForm.role}
onValueChange={(val) => setFormData({ ...formData, role: val as AdminRole })} onValueChange={(val) => setEditForm({ ...editForm, role: val as AdminRole })}
> >
<SelectTrigger id="role"> <SelectTrigger id="edit-role">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -298,14 +518,26 @@ export default function AdminsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label></Label>
<p className="text-sm text-muted-foreground">
{editForm.isActive ? "账号活跃中" : "账号已停用"}
</p>
</div>
<Switch
checked={editForm.isActive === 1}
onCheckedChange={(checked) => setEditForm({ ...editForm, isActive: checked ? 1 : 0 })}
/>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}> <Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}>
</Button> </Button>
<Button onClick={handleSubmit} disabled={submitting || !formData.username || !formData.password}> <Button onClick={handleEdit} disabled={submitting}>
{submitting ? "创建中..." : "创建"} {submitting ? "保存中..." : "保存"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -315,74 +547,67 @@ export default function AdminsPage() {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle> <AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
"{selectedAdmin?.username}" {selectedAdmin?.username}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel disabled={submitting}></AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDelete} onClick={handleDelete}
className="bg-destructive text-white hover:bg-destructive/90" className="bg-destructive text-white hover:bg-destructive/90"
disabled={submitting}
> >
{submitting ? "停用中..." : "确认停用"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* 重置密码对话框 */} {/* 重置密码确认 */}
<Dialog open={resetPasswordOpen} onOpenChange={setResetPasswordOpen}> <AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{selectedAdmin?.username}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}></AlertDialogCancel>
<AlertDialogAction onClick={handleResetPassword} disabled={submitting}>
{submitting ? "生成中..." : "确认重置"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 密码结果对话框(重置密码后显示) */}
<Dialog open={passwordResultOpen} onOpenChange={setPasswordResultOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription> <DialogDescription>
"{selectedAdmin?.username}" {passwordResult?.username}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="py-4">
<div> <Label></Label>
<Label htmlFor="new-password"></Label> <div className="flex items-center gap-2 mt-1">
<Input <code className="flex-1 rounded bg-muted px-3 py-2 text-sm font-mono">
id="new-password" {passwordResult?.password}
type="password" </code>
value={resetPasswordData.newPassword} <CopyButton text={passwordResult?.password ?? ""} />
onChange={(e) =>
setResetPasswordData({ ...resetPasswordData, newPassword: e.target.value })
}
placeholder="请输入新密码(至少 6 位)"
/>
</div>
<div>
<Label htmlFor="confirm-password"></Label>
<Input
id="confirm-password"
type="password"
value={resetPasswordData.confirmPassword}
onChange={(e) =>
setResetPasswordData({ ...resetPasswordData, confirmPassword: e.target.value })
}
placeholder="请再次输入新密码"
/>
{resetPasswordData.newPassword !== resetPasswordData.confirmPassword && (
<p className="text-sm text-destructive mt-1"></p>
)}
</div> </div>
<p className="text-xs text-muted-foreground mt-2">
</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setResetPasswordOpen(false)} disabled={submitting}> <Button onClick={() => setPasswordResultOpen(false)}></Button>
</Button>
<Button
onClick={handleResetPassword}
disabled={submitting || !resetPasswordData.newPassword || resetPasswordData.newPassword !== resetPasswordData.confirmPassword}
>
{submitting ? "重置中..." : "确认重置"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -51,10 +51,15 @@ export default function LoginPage() {
async function handleTokenLogin(data: TokenLoginForm) { async function handleTokenLogin(data: TokenLoginForm) {
setError("") setError("")
try { try {
const response: ApiResponse<LoginResponse> = await loginWithToken(data.token) const response = await loginWithToken(data.token)
if (response.success && response.data) { if (response.success && response.data?.authenticated) {
login(response.data.accessToken, response.data.refreshToken, response.data.admin) // Token 认证成功 — 使用 token 本身作为 access token向后兼容模式
login(data.token, "", {
id: "token-admin",
username: "admin",
role: "super_admin",
})
navigate("/") navigate("/")
} else if (response.error) { } else if (response.error) {
setError(response.error.message) setError(response.error.message)
@ -64,7 +69,7 @@ export default function LoginPage() {
login(`offline_${data.token}`, `offline_refresh_${data.token}`, { login(`offline_${data.token}`, `offline_refresh_${data.token}`, {
id: "offline-admin", id: "offline-admin",
username: "admin", username: "admin",
role: "admin", role: "super_admin",
}) })
navigate("/") navigate("/")
} }

View File

@ -1,15 +1,17 @@
import type { AdminUser } from "./api" import type { AdminUser } from "./api"
// 管理员角色类型(匹配 duoqi-api 规范) // 管理员角色类型(匹配 duoqi-api 规范)
export type AdminRole = "super_admin" | "admin" | "moderator" export type AdminRole = "super_admin" | "admin"
// 完整的管理员信息 // 完整的管理员信息(匹配 GET /admin/admins 响应)
export interface Admin { export interface Admin {
id: string id: string
username: string username: string
role: AdminRole role: AdminRole
isActive: number
lastLoginAt: string | null
createdAt: string createdAt: string
lastLoginAt?: string updatedAt: string
} }
// 登录表单(用户名密码) // 登录表单(用户名密码)
@ -25,9 +27,35 @@ export interface AdminSession {
refreshToken: string refreshToken: string
} }
// 创建管理员表单 // 创建管理员请求(服务端生成密码)
export interface CreateAdminForm { export interface CreateAdminRequest {
username: string username: string
password: string password: string
role: AdminRole role: AdminRole
} }
// 创建管理员响应(含服务端生成的明文密码)
export interface CreateAdminResponse {
id: string
username: string
role: AdminRole
isActive: number
lastLoginAt: string | null
createdAt: string
updatedAt: string
plainPassword: string
}
// 更新管理员请求
export interface UpdateAdminRequest {
username?: string
role?: AdminRole
isActive?: 0 | 1
}
// 重置密码响应(含新生成的明文密码)
export interface ResetPasswordResponse {
adminId: string
username: string
plainPassword: string
}

View File

@ -26,11 +26,11 @@ export interface PaginatedResponse<T> {
error: null error: null
} }
// 管理员用户信息 // 管理员用户信息(登录响应中的 admin 字段)
export interface AdminUser { export interface AdminUser {
id: string id: string
username: string username: string
role: "super_admin" | "admin" | "moderator" role: "super_admin" | "admin"
} }
// Token 登录请求 // Token 登录请求