page.tsx 21 KB

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