page.tsx 21 KB

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