page.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. "use client";
  2. import { useCallback, useEffect, useState } from "react";
  3. import { useRouter } from "next/navigation";
  4. import { useAuth } from "@/features/agent/hooks/useAuth";
  5. import { ResponsiveLayout } from "@/components/layout";
  6. import {
  7. fetchUsers,
  8. createUser,
  9. updateUser,
  10. deleteUser,
  11. updateUserPassword,
  12. type UserSummary,
  13. type CreateUserRequest,
  14. type UpdateUserRequest,
  15. type UpdatePasswordRequest,
  16. } from "@/features/agent/services/userApi";
  17. import { Button } from "@/components/ui/button";
  18. import { Input } from "@/components/ui/input";
  19. import {
  20. Dialog,
  21. DialogContent,
  22. DialogHeader,
  23. DialogTitle,
  24. } from "@/components/ui/dialog";
  25. import { Badge } from "@/components/ui/badge";
  26. import { Card } from "@/components/ui/card";
  27. import { Label } from "@/components/ui/label";
  28. import {
  29. Plus,
  30. Edit,
  31. Trash2,
  32. Lock,
  33. Search,
  34. UserPlus,
  35. Save,
  36. X,
  37. } from "lucide-react";
  38. interface UsersPageProps {
  39. embedded?: boolean; // 是否嵌入模式(不使用 ResponsiveLayout)
  40. }
  41. export default function UsersPage({ embedded = false }: UsersPageProps) {
  42. const router = useRouter();
  43. const { agent } = useAuth();
  44. const [users, setUsers] = useState<UserSummary[]>([]);
  45. const [loading, setLoading] = useState(true);
  46. const [searchQuery, setSearchQuery] = useState("");
  47. const [createDialogOpen, setCreateDialogOpen] = useState(false);
  48. const [editDialogOpen, setEditDialogOpen] = useState(false);
  49. const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
  50. const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  51. const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null);
  52. const [submitting, setSubmitting] = useState(false);
  53. // 创建用户表单
  54. const [createForm, setCreateForm] = useState<CreateUserRequest>({
  55. username: "",
  56. password: "",
  57. role: "agent",
  58. nickname: "",
  59. email: "",
  60. });
  61. // 编辑用户表单
  62. const [editForm, setEditForm] = useState<UpdateUserRequest>({
  63. role: "agent",
  64. nickname: "",
  65. email: "",
  66. receive_ai_conversations: true,
  67. });
  68. // 修改密码表单
  69. const [passwordForm, setPasswordForm] = useState<UpdatePasswordRequest>({
  70. old_password: "",
  71. new_password: "",
  72. });
  73. // 检查权限
  74. useEffect(() => {
  75. if (agent && agent.role !== "admin") {
  76. router.push("/agent/dashboard");
  77. }
  78. }, [agent, router]);
  79. // 加载用户列表
  80. const loadUsers = useCallback(async () => {
  81. if (!agent?.id) {
  82. return;
  83. }
  84. setLoading(true);
  85. try {
  86. const data = await fetchUsers(agent.id);
  87. setUsers(data);
  88. } catch (error) {
  89. console.error("加载用户列表失败:", error);
  90. alert((error as Error).message || "加载用户列表失败");
  91. } finally {
  92. setLoading(false);
  93. }
  94. }, [agent?.id]);
  95. // 初始加载
  96. useEffect(() => {
  97. loadUsers();
  98. }, [loadUsers]);
  99. // 过滤用户列表
  100. const filteredUsers = users.filter((user) => {
  101. if (!searchQuery.trim()) {
  102. return true;
  103. }
  104. const query = searchQuery.toLowerCase();
  105. return (
  106. user.username.toLowerCase().includes(query) ||
  107. (user.nickname && user.nickname.toLowerCase().includes(query)) ||
  108. (user.email && user.email.toLowerCase().includes(query))
  109. );
  110. });
  111. // 打开创建对话框
  112. const handleOpenCreate = () => {
  113. setCreateForm({
  114. username: "",
  115. password: "",
  116. role: "agent",
  117. nickname: "",
  118. email: "",
  119. });
  120. setCreateDialogOpen(true);
  121. };
  122. // 创建用户
  123. const handleCreate = async () => {
  124. if (!agent?.id) {
  125. return;
  126. }
  127. if (!createForm.username.trim() || !createForm.password.trim()) {
  128. alert("用户名和密码不能为空");
  129. return;
  130. }
  131. setSubmitting(true);
  132. try {
  133. await createUser(createForm, agent.id);
  134. setCreateDialogOpen(false);
  135. await loadUsers();
  136. alert("创建成功");
  137. } catch (error) {
  138. alert((error as Error).message || "创建用户失败");
  139. } finally {
  140. setSubmitting(false);
  141. }
  142. };
  143. // 打开编辑对话框
  144. const handleOpenEdit = (user: UserSummary) => {
  145. setSelectedUser(user);
  146. setEditForm({
  147. role: user.role as "admin" | "agent",
  148. nickname: user.nickname || "",
  149. email: user.email || "",
  150. receive_ai_conversations: user.receive_ai_conversations,
  151. });
  152. setEditDialogOpen(true);
  153. };
  154. // 更新用户
  155. const handleUpdate = async () => {
  156. if (!agent?.id || !selectedUser) {
  157. return;
  158. }
  159. setSubmitting(true);
  160. try {
  161. await updateUser(selectedUser.id, editForm, agent.id);
  162. setEditDialogOpen(false);
  163. setSelectedUser(null);
  164. await loadUsers();
  165. alert("更新成功");
  166. } catch (error) {
  167. alert((error as Error).message || "更新用户失败");
  168. } finally {
  169. setSubmitting(false);
  170. }
  171. };
  172. // 打开修改密码对话框
  173. const handleOpenPassword = (user: UserSummary) => {
  174. setSelectedUser(user);
  175. setPasswordForm({
  176. old_password: "",
  177. new_password: "",
  178. });
  179. setPasswordDialogOpen(true);
  180. };
  181. // 更新密码
  182. const handleUpdatePassword = async () => {
  183. if (!agent?.id || !selectedUser) {
  184. return;
  185. }
  186. if (!passwordForm.new_password.trim()) {
  187. alert("新密码不能为空");
  188. return;
  189. }
  190. // 如果修改的是当前用户,需要旧密码;如果是其他用户,不需要旧密码
  191. const isCurrentUser = selectedUser.id === agent.id;
  192. if (isCurrentUser && !passwordForm.old_password?.trim()) {
  193. alert("修改自己的密码需要提供旧密码");
  194. return;
  195. }
  196. setSubmitting(true);
  197. try {
  198. await updateUserPassword(
  199. selectedUser.id,
  200. isCurrentUser ? passwordForm : { new_password: passwordForm.new_password },
  201. agent.id
  202. );
  203. setPasswordDialogOpen(false);
  204. setSelectedUser(null);
  205. setPasswordForm({ old_password: "", new_password: "" });
  206. alert("密码更新成功");
  207. } catch (error) {
  208. alert((error as Error).message || "更新密码失败");
  209. } finally {
  210. setSubmitting(false);
  211. }
  212. };
  213. // 打开删除对话框
  214. const handleOpenDelete = (user: UserSummary) => {
  215. setSelectedUser(user);
  216. setDeleteDialogOpen(true);
  217. };
  218. // 删除用户
  219. const handleDelete = async () => {
  220. if (!agent?.id || !selectedUser) {
  221. return;
  222. }
  223. setSubmitting(true);
  224. try {
  225. await deleteUser(selectedUser.id, agent.id);
  226. setDeleteDialogOpen(false);
  227. setSelectedUser(null);
  228. await loadUsers();
  229. alert("删除成功");
  230. } catch (error) {
  231. alert((error as Error).message || "删除用户失败");
  232. } finally {
  233. setSubmitting(false);
  234. }
  235. };
  236. // 格式化时间
  237. const formatTime = (dateStr: string) => {
  238. const date = new Date(dateStr);
  239. return date.toLocaleString("zh-CN", {
  240. year: "numeric",
  241. month: "2-digit",
  242. day: "2-digit",
  243. hour: "2-digit",
  244. minute: "2-digit",
  245. });
  246. };
  247. if (!agent || agent.role !== "admin") {
  248. return null; // 或者显示"权限不足"页面
  249. }
  250. // 构建头部内容
  251. const headerContent = (
  252. <div className="bg-card border-b p-4 shadow-sm">
  253. <div className="flex items-center justify-between mb-4">
  254. <h1 className="text-xl font-bold text-foreground">用户管理</h1>
  255. {!embedded && (
  256. <Button
  257. variant="ghost"
  258. size="sm"
  259. onClick={() => router.push("/agent/dashboard")}
  260. >
  261. 返回
  262. </Button>
  263. )}
  264. </div>
  265. {/* 搜索和操作栏 */}
  266. <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
  267. <div className="flex-1 relative">
  268. <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
  269. <Input
  270. type="text"
  271. placeholder="搜索用户(用户名、昵称、邮箱)..."
  272. value={searchQuery}
  273. onChange={(e) => setSearchQuery(e.target.value)}
  274. className="pl-10"
  275. />
  276. </div>
  277. <Button
  278. onClick={handleOpenCreate}
  279. className="w-full sm:w-auto"
  280. >
  281. <UserPlus className="w-4 h-4 mr-2" />
  282. 创建用户
  283. </Button>
  284. </div>
  285. </div>
  286. );
  287. // 构建主内容区
  288. const mainContent = (
  289. <div className="flex-1 overflow-y-auto p-4 scrollbar-auto">
  290. {loading ? (
  291. <div className="flex items-center justify-center h-full">
  292. <span className="text-muted-foreground">加载中...</span>
  293. </div>
  294. ) : filteredUsers.length === 0 ? (
  295. <div className="flex items-center justify-center h-full">
  296. <span className="text-muted-foreground">
  297. {searchQuery ? "没有找到匹配的用户" : "暂无用户"}
  298. </span>
  299. </div>
  300. ) : (
  301. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  302. {filteredUsers.map((user) => (
  303. <Card key={user.id} className="p-4 flex flex-col">
  304. <div className="mb-3 flex-1">
  305. <div className="flex items-center gap-2 mb-2">
  306. <span className="font-medium text-foreground">
  307. {user.nickname || user.username}
  308. </span>
  309. <Badge
  310. variant={user.role === "admin" ? "default" : "secondary"}
  311. >
  312. {user.role === "admin" ? "管理员" : "客服"}
  313. </Badge>
  314. </div>
  315. <div className="text-sm text-muted-foreground space-y-1 mb-2">
  316. <div>用户名: {user.username}</div>
  317. {user.email && <div>邮箱: {user.email}</div>}
  318. </div>
  319. <div className="text-xs text-muted-foreground">
  320. 创建时间: {formatTime(user.created_at)}
  321. </div>
  322. </div>
  323. <div className="flex items-center gap-2 mt-auto pt-3 border-t">
  324. <Button
  325. variant="outline"
  326. size="sm"
  327. onClick={() => handleOpenEdit(user)}
  328. className="flex-1"
  329. >
  330. <Edit className="w-4 h-4 mr-1" />
  331. 编辑
  332. </Button>
  333. <Button
  334. variant="outline"
  335. size="sm"
  336. onClick={() => handleOpenPassword(user)}
  337. className="flex-1"
  338. >
  339. <Lock className="w-4 h-4 mr-1" />
  340. 密码
  341. </Button>
  342. <Button
  343. variant="destructive"
  344. size="sm"
  345. onClick={() => handleOpenDelete(user)}
  346. disabled={user.id === agent.id}
  347. title={user.id === agent.id ? "不能删除当前登录用户" : ""}
  348. >
  349. <Trash2 className="w-4 h-4" />
  350. </Button>
  351. </div>
  352. </Card>
  353. ))}
  354. </div>
  355. )}
  356. </div>
  357. );
  358. // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
  359. if (embedded) {
  360. return (
  361. <>
  362. <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
  363. {headerContent}
  364. {mainContent}
  365. </div>
  366. {/* 对话框 */}
  367. {/* 创建用户对话框 */}
  368. <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
  369. <DialogContent>
  370. <DialogHeader>
  371. <DialogTitle>创建新用户</DialogTitle>
  372. </DialogHeader>
  373. <div className="space-y-4">
  374. <div>
  375. <Label htmlFor="create-username">用户名 *</Label>
  376. <Input
  377. id="create-username"
  378. value={createForm.username}
  379. onChange={(e) =>
  380. setCreateForm({ ...createForm, username: e.target.value })
  381. }
  382. placeholder="请输入用户名"
  383. />
  384. </div>
  385. <div>
  386. <Label htmlFor="create-password">密码 *</Label>
  387. <Input
  388. id="create-password"
  389. type="password"
  390. value={createForm.password}
  391. onChange={(e) =>
  392. setCreateForm({ ...createForm, password: e.target.value })
  393. }
  394. placeholder="请输入密码"
  395. />
  396. </div>
  397. <div>
  398. <Label htmlFor="create-role">角色 *</Label>
  399. <select
  400. id="create-role"
  401. value={createForm.role}
  402. onChange={(e) =>
  403. setCreateForm({
  404. ...createForm,
  405. role: e.target.value as "admin" | "agent",
  406. })
  407. }
  408. className="w-full px-3 py-2 border border-border rounded-md bg-background"
  409. >
  410. <option value="agent">客服</option>
  411. <option value="admin">管理员</option>
  412. </select>
  413. </div>
  414. <div>
  415. <Label htmlFor="create-nickname">昵称</Label>
  416. <Input
  417. id="create-nickname"
  418. value={createForm.nickname}
  419. onChange={(e) =>
  420. setCreateForm({ ...createForm, nickname: e.target.value })
  421. }
  422. placeholder="请输入昵称(可选)"
  423. />
  424. </div>
  425. <div>
  426. <Label htmlFor="create-email">邮箱</Label>
  427. <Input
  428. id="create-email"
  429. type="email"
  430. value={createForm.email}
  431. onChange={(e) =>
  432. setCreateForm({ ...createForm, email: e.target.value })
  433. }
  434. placeholder="请输入邮箱(可选)"
  435. />
  436. </div>
  437. <div className="flex justify-end gap-2">
  438. <Button
  439. variant="outline"
  440. onClick={() => setCreateDialogOpen(false)}
  441. disabled={submitting}
  442. >
  443. 取消
  444. </Button>
  445. <Button onClick={handleCreate} disabled={submitting}>
  446. {submitting ? "创建中..." : "创建"}
  447. </Button>
  448. </div>
  449. </div>
  450. </DialogContent>
  451. </Dialog>
  452. {/* 编辑用户对话框 */}
  453. <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
  454. <DialogContent>
  455. <DialogHeader>
  456. <DialogTitle>编辑用户</DialogTitle>
  457. </DialogHeader>
  458. {selectedUser && (
  459. <div className="space-y-4">
  460. <div>
  461. <Label>用户名</Label>
  462. <Input value={selectedUser.username} disabled />
  463. <p className="text-xs text-muted-foreground mt-1">
  464. 用户名不能修改
  465. </p>
  466. </div>
  467. <div>
  468. <Label htmlFor="edit-role">角色 *</Label>
  469. <select
  470. id="edit-role"
  471. value={editForm.role}
  472. onChange={(e) =>
  473. setEditForm({
  474. ...editForm,
  475. role: e.target.value as "admin" | "agent",
  476. })
  477. }
  478. className="w-full px-3 py-2 border border-border rounded-md bg-background"
  479. >
  480. <option value="agent">客服</option>
  481. <option value="admin">管理员</option>
  482. </select>
  483. </div>
  484. <div>
  485. <Label htmlFor="edit-nickname">昵称</Label>
  486. <Input
  487. id="edit-nickname"
  488. value={editForm.nickname || ""}
  489. onChange={(e) =>
  490. setEditForm({ ...editForm, nickname: e.target.value })
  491. }
  492. placeholder="请输入昵称"
  493. />
  494. </div>
  495. <div>
  496. <Label htmlFor="edit-email">邮箱</Label>
  497. <Input
  498. id="edit-email"
  499. type="email"
  500. value={editForm.email || ""}
  501. onChange={(e) =>
  502. setEditForm({ ...editForm, email: e.target.value })
  503. }
  504. placeholder="请输入邮箱"
  505. />
  506. </div>
  507. <div className="flex items-center gap-2">
  508. <input
  509. type="checkbox"
  510. id="edit-receive-ai"
  511. checked={editForm.receive_ai_conversations ?? true}
  512. onChange={(e) =>
  513. setEditForm({
  514. ...editForm,
  515. receive_ai_conversations: e.target.checked,
  516. })
  517. }
  518. className="w-4 h-4"
  519. />
  520. <Label htmlFor="edit-receive-ai" className="cursor-pointer">
  521. 接收 AI 对话
  522. </Label>
  523. </div>
  524. <div className="flex justify-end gap-2">
  525. <Button
  526. variant="outline"
  527. onClick={() => setEditDialogOpen(false)}
  528. disabled={submitting}
  529. >
  530. 取消
  531. </Button>
  532. <Button onClick={handleUpdate} disabled={submitting}>
  533. {submitting ? "更新中..." : "更新"}
  534. </Button>
  535. </div>
  536. </div>
  537. )}
  538. </DialogContent>
  539. </Dialog>
  540. {/* 修改密码对话框 */}
  541. <Dialog open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen}>
  542. <DialogContent>
  543. <DialogHeader>
  544. <DialogTitle>修改密码</DialogTitle>
  545. </DialogHeader>
  546. {selectedUser && (
  547. <div className="space-y-4">
  548. <div>
  549. <Label>用户名</Label>
  550. <Input value={selectedUser.username} disabled />
  551. </div>
  552. {selectedUser.id === agent?.id && (
  553. <div>
  554. <Label htmlFor="password-old">旧密码 *</Label>
  555. <Input
  556. id="password-old"
  557. type="password"
  558. value={passwordForm.old_password || ""}
  559. onChange={(e) =>
  560. setPasswordForm({
  561. ...passwordForm,
  562. old_password: e.target.value,
  563. })
  564. }
  565. placeholder="请输入旧密码"
  566. />
  567. </div>
  568. )}
  569. <div>
  570. <Label htmlFor="password-new">新密码 *</Label>
  571. <Input
  572. id="password-new"
  573. type="password"
  574. value={passwordForm.new_password}
  575. onChange={(e) =>
  576. setPasswordForm({
  577. ...passwordForm,
  578. new_password: e.target.value,
  579. })
  580. }
  581. placeholder="请输入新密码"
  582. />
  583. </div>
  584. <div className="flex justify-end gap-2">
  585. <Button
  586. variant="outline"
  587. onClick={() => setPasswordDialogOpen(false)}
  588. disabled={submitting}
  589. >
  590. 取消
  591. </Button>
  592. <Button onClick={handleUpdatePassword} disabled={submitting}>
  593. {submitting ? "更新中..." : "更新"}
  594. </Button>
  595. </div>
  596. </div>
  597. )}
  598. </DialogContent>
  599. </Dialog>
  600. {/* 删除确认对话框 */}
  601. <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
  602. <DialogContent>
  603. <DialogHeader>
  604. <DialogTitle>删除用户</DialogTitle>
  605. </DialogHeader>
  606. {selectedUser && (
  607. <div className="space-y-4">
  608. <p className="text-foreground">
  609. 确定要删除用户 <strong>{selectedUser.username}</strong> 吗?
  610. </p>
  611. <p className="text-sm text-muted-foreground">
  612. 此操作不可恢复,请谨慎操作。
  613. </p>
  614. <div className="flex justify-end gap-2">
  615. <Button
  616. variant="outline"
  617. onClick={() => setDeleteDialogOpen(false)}
  618. disabled={submitting}
  619. >
  620. 取消
  621. </Button>
  622. <Button
  623. variant="destructive"
  624. onClick={handleDelete}
  625. disabled={submitting}
  626. >
  627. {submitting ? "删除中..." : "删除"}
  628. </Button>
  629. </div>
  630. </div>
  631. )}
  632. </DialogContent>
  633. </Dialog>
  634. </>
  635. );
  636. }
  637. return (
  638. <ResponsiveLayout
  639. main={mainContent}
  640. header={headerContent}
  641. />
  642. );
  643. }