page.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. "use client";
  2. import { useState, useEffect } from "react";
  3. import { useRouter } from "next/navigation";
  4. import { ResponsiveLayout } from "@/components/layout";
  5. import { Button } from "@/components/ui/button";
  6. import { Input } from "@/components/ui/input";
  7. import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
  8. import {
  9. fetchAIConfigs,
  10. createAIConfig,
  11. updateAIConfig,
  12. deleteAIConfig,
  13. type AIConfig,
  14. type CreateAIConfigRequest,
  15. type UpdateAIConfigRequest,
  16. } from "@/features/agent/services/aiConfigApi";
  17. import { useProfile } from "@/features/agent/hooks/useProfile";
  18. import { API_BASE_URL } from "@/lib/config";
  19. import { Checkbox } from "@/components/ui/checkbox";
  20. import { Label } from "@/components/ui/label";
  21. export default function SettingsPage({ embedded = false }: { embedded?: boolean } = {}) {
  22. const router = useRouter();
  23. const [userId, setUserId] = useState<number | null>(null);
  24. const [configs, setConfigs] = useState<AIConfig[]>([]);
  25. const [loading, setLoading] = useState(true);
  26. const [editingId, setEditingId] = useState<number | null>(null);
  27. const [formData, setFormData] = useState<CreateAIConfigRequest>({
  28. provider: "",
  29. api_url: "",
  30. api_key: "",
  31. model: "",
  32. model_type: "text",
  33. is_active: true,
  34. is_public: false,
  35. description: "",
  36. });
  37. const [submitting, setSubmitting] = useState(false);
  38. const [error, setError] = useState("");
  39. // 检查登录状态
  40. useEffect(() => {
  41. const storedUserId = localStorage.getItem("agent_user_id");
  42. if (!storedUserId) {
  43. router.push("/");
  44. return;
  45. }
  46. setUserId(Number.parseInt(storedUserId, 10));
  47. }, [router]);
  48. // 加载个人资料(用于获取和更新 AI 对话接收设置)
  49. const {
  50. profile,
  51. loading: profileLoading,
  52. update: updateProfile,
  53. } = useProfile({
  54. userId: userId ?? null,
  55. enabled: Boolean(userId),
  56. });
  57. // 加载配置列表
  58. const loadConfigs = async () => {
  59. if (!userId) return;
  60. try {
  61. setLoading(true);
  62. const data = await fetchAIConfigs(userId);
  63. setConfigs(data);
  64. } catch (error) {
  65. console.error("加载配置失败:", error);
  66. setError("加载配置失败");
  67. } finally {
  68. setLoading(false);
  69. }
  70. };
  71. useEffect(() => {
  72. if (userId) {
  73. loadConfigs();
  74. }
  75. }, [userId]);
  76. // 重置表单
  77. const resetForm = () => {
  78. setFormData({
  79. provider: "",
  80. api_url: "",
  81. api_key: "",
  82. model: "",
  83. model_type: "text",
  84. is_active: true,
  85. is_public: false,
  86. description: "",
  87. });
  88. setEditingId(null);
  89. setError("");
  90. };
  91. // 开始编辑
  92. const handleEdit = (config: AIConfig) => {
  93. setFormData({
  94. provider: config.provider,
  95. api_url: config.api_url,
  96. api_key: "", // 不显示 API Key(已加密)
  97. model: config.model,
  98. model_type: config.model_type,
  99. is_active: config.is_active,
  100. is_public: config.is_public,
  101. description: config.description,
  102. });
  103. setEditingId(config.id);
  104. };
  105. // 提交表单
  106. const handleSubmit = async (e: React.FormEvent) => {
  107. e.preventDefault();
  108. if (!userId) return;
  109. setSubmitting(true);
  110. setError("");
  111. try {
  112. if (editingId) {
  113. // 更新配置
  114. const updateData: UpdateAIConfigRequest = {
  115. provider: formData.provider,
  116. api_url: formData.api_url,
  117. model: formData.model,
  118. model_type: formData.model_type,
  119. is_active: formData.is_active,
  120. is_public: formData.is_public,
  121. description: formData.description,
  122. };
  123. // 如果提供了新的 API Key,才更新
  124. if (formData.api_key) {
  125. updateData.api_key = formData.api_key;
  126. }
  127. await updateAIConfig(userId, editingId, updateData);
  128. } else {
  129. // 创建配置
  130. await createAIConfig(userId, formData);
  131. }
  132. resetForm();
  133. await loadConfigs();
  134. } catch (error) {
  135. setError((error as Error).message || "操作失败");
  136. } finally {
  137. setSubmitting(false);
  138. }
  139. };
  140. // 删除配置
  141. const handleDelete = async (id: number) => {
  142. if (!userId) return;
  143. if (!confirm("确定要删除这个配置吗?")) return;
  144. try {
  145. await deleteAIConfig(userId, id);
  146. await loadConfigs();
  147. } catch (error) {
  148. setError((error as Error).message || "删除失败");
  149. }
  150. };
  151. // 退出登录
  152. const handleLogout = async () => {
  153. try {
  154. await fetch(`${API_BASE_URL}/logout`, { method: "POST" });
  155. } catch (error) {
  156. console.error("退出登录失败:", error);
  157. } finally {
  158. localStorage.removeItem("agent_user_id");
  159. localStorage.removeItem("agent_username");
  160. localStorage.removeItem("agent_role");
  161. router.push("/");
  162. }
  163. };
  164. if (!userId) {
  165. return null;
  166. }
  167. // 构建头部内容
  168. const headerContent = (
  169. <div className="bg-card border-b p-4 shadow-sm">
  170. <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
  171. <div>
  172. <h1 className="text-xl font-bold text-foreground">AI 配置管理</h1>
  173. <div className="text-sm text-muted-foreground mt-1">管理 AI 服务商配置</div>
  174. </div>
  175. {!embedded && (
  176. <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
  177. <Button
  178. onClick={() => router.push("/agent/dashboard")}
  179. variant="outline"
  180. size="sm"
  181. className="w-full sm:w-auto"
  182. >
  183. 返回工作台
  184. </Button>
  185. <Button
  186. onClick={handleLogout}
  187. variant="outline"
  188. size="sm"
  189. className="w-full sm:w-auto"
  190. >
  191. 退出登录
  192. </Button>
  193. </div>
  194. )}
  195. </div>
  196. </div>
  197. );
  198. // 构建主内容区
  199. const mainContent = (
  200. <div className="flex-1 overflow-auto p-4 md:p-6">
  201. <div className="max-w-6xl mx-auto space-y-6">
  202. {/* 全局设置 */}
  203. <Card>
  204. <CardHeader>
  205. <CardTitle>全局设置</CardTitle>
  206. </CardHeader>
  207. <CardContent>
  208. <div className="flex items-center space-x-2">
  209. <Checkbox
  210. id="receive_ai_conversations"
  211. checked={!(profile?.receive_ai_conversations ?? false)}
  212. onCheckedChange={async (checked) => {
  213. if (userId) {
  214. try {
  215. await updateProfile({
  216. receive_ai_conversations: !checked,
  217. });
  218. } catch (error) {
  219. console.error("更新设置失败:", error);
  220. alert("更新设置失败,请重试");
  221. }
  222. }
  223. }}
  224. disabled={profileLoading}
  225. />
  226. <Label
  227. htmlFor="receive_ai_conversations"
  228. className="text-sm font-medium cursor-pointer"
  229. >
  230. 客服不接收 AI 对话
  231. </Label>
  232. </div>
  233. <p className="text-xs text-muted-foreground mt-2">
  234. 开启后,AI 对话将不会显示在对话列表中,也不会收到 AI 消息通知。
  235. 但您仍可以在会话页面手动开启&quot;显示 AI 消息&quot;来查看 AI 对话历史。
  236. </p>
  237. </CardContent>
  238. </Card>
  239. {/* 配置表单 */}
  240. <Card>
  241. <CardHeader>
  242. <CardTitle>
  243. {editingId ? "编辑 AI 配置" : "添加 AI 配置"}
  244. </CardTitle>
  245. </CardHeader>
  246. <CardContent>
  247. <form onSubmit={handleSubmit} className="space-y-4">
  248. {error && (
  249. <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
  250. {error}
  251. </div>
  252. )}
  253. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  254. <div>
  255. <label className="block text-sm font-medium mb-1">
  256. 服务商名称 <span className="text-red-500">*</span>
  257. </label>
  258. <Input
  259. value={formData.provider}
  260. onChange={(e) =>
  261. setFormData({ ...formData, provider: e.target.value })
  262. }
  263. placeholder="例如:OpenAI、Claude、自定义"
  264. required
  265. />
  266. </div>
  267. <div>
  268. <label className="block text-sm font-medium mb-1">
  269. API 地址 <span className="text-red-500">*</span>
  270. </label>
  271. <Input
  272. value={formData.api_url}
  273. onChange={(e) =>
  274. setFormData({ ...formData, api_url: e.target.value })
  275. }
  276. placeholder="https://api.openai.com/v1/chat/completions"
  277. required
  278. />
  279. </div>
  280. <div>
  281. <label className="block text-sm font-medium mb-1">
  282. API Key <span className="text-red-500">*</span>
  283. </label>
  284. <Input
  285. type="password"
  286. value={formData.api_key}
  287. onChange={(e) =>
  288. setFormData({ ...formData, api_key: e.target.value })
  289. }
  290. placeholder={editingId ? "留空则不更新" : "输入 API Key"}
  291. required={!editingId}
  292. />
  293. </div>
  294. <div>
  295. <label className="block text-sm font-medium mb-1">
  296. 模型名称 <span className="text-red-500">*</span>
  297. </label>
  298. <Input
  299. value={formData.model}
  300. onChange={(e) =>
  301. setFormData({ ...formData, model: e.target.value })
  302. }
  303. placeholder="例如:gpt-3.5-turbo、gpt-4"
  304. required
  305. />
  306. </div>
  307. <div>
  308. <label className="block text-sm font-medium mb-1">
  309. 模型类型
  310. </label>
  311. <select
  312. value={formData.model_type}
  313. onChange={(e) =>
  314. setFormData({ ...formData, model_type: e.target.value })
  315. }
  316. className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
  317. >
  318. <option value="text">文本</option>
  319. <option value="image">图片</option>
  320. <option value="audio">语音</option>
  321. <option value="video">视频</option>
  322. </select>
  323. </div>
  324. </div>
  325. <div>
  326. <label className="block text-sm font-medium mb-1">
  327. 配置描述
  328. </label>
  329. <Input
  330. value={formData.description}
  331. onChange={(e) =>
  332. setFormData({ ...formData, description: e.target.value })
  333. }
  334. placeholder="例如:OpenAI GPT-3.5 Turbo 模型"
  335. />
  336. </div>
  337. <div className="flex items-center gap-4">
  338. <label className="flex items-center gap-2">
  339. <input
  340. type="checkbox"
  341. checked={formData.is_active}
  342. onChange={(e) =>
  343. setFormData({ ...formData, is_active: e.target.checked })
  344. }
  345. className="w-4 h-4"
  346. />
  347. <span className="text-sm">启用配置</span>
  348. </label>
  349. <label className="flex items-center gap-2">
  350. <input
  351. type="checkbox"
  352. checked={formData.is_public}
  353. onChange={(e) =>
  354. setFormData({ ...formData, is_public: e.target.checked })
  355. }
  356. className="w-4 h-4"
  357. />
  358. <span className="text-sm">开放给访客使用</span>
  359. </label>
  360. </div>
  361. <div className="flex gap-2">
  362. <Button type="submit" disabled={submitting}>
  363. {submitting
  364. ? "提交中..."
  365. : editingId
  366. ? "更新配置"
  367. : "创建配置"}
  368. </Button>
  369. {editingId && (
  370. <Button
  371. type="button"
  372. variant="outline"
  373. onClick={resetForm}
  374. >
  375. 取消
  376. </Button>
  377. )}
  378. </div>
  379. </form>
  380. </CardContent>
  381. </Card>
  382. {/* 配置列表 */}
  383. <Card>
  384. <CardHeader>
  385. <CardTitle>已配置的 AI 服务</CardTitle>
  386. </CardHeader>
  387. <CardContent>
  388. {loading ? (
  389. <div className="text-center py-8 text-gray-500">
  390. 加载中...
  391. </div>
  392. ) : configs.length === 0 ? (
  393. <div className="text-center py-8 text-gray-500">
  394. 暂无配置,请添加
  395. </div>
  396. ) : (
  397. <div className="space-y-4">
  398. {configs.map((config) => (
  399. <div
  400. key={config.id}
  401. className="p-4 border rounded-lg hover:shadow-md transition-shadow"
  402. >
  403. <div className="flex justify-between items-start">
  404. <div className="flex-1">
  405. <div className="flex items-center gap-2 mb-2">
  406. <h3 className="font-semibold">
  407. {config.provider} - {config.model}
  408. </h3>
  409. {config.is_active && (
  410. <span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded">
  411. 启用
  412. </span>
  413. )}
  414. {config.is_public && (
  415. <span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
  416. 开放
  417. </span>
  418. )}
  419. </div>
  420. <div className="text-sm text-gray-600 space-y-1">
  421. <p>
  422. <span className="font-medium">API 地址:</span>
  423. {config.api_url}
  424. </p>
  425. <p>
  426. <span className="font-medium">模型类型:</span>
  427. {config.model_type}
  428. </p>
  429. {config.description && (
  430. <p>
  431. <span className="font-medium">描述:</span>
  432. {config.description}
  433. </p>
  434. )}
  435. </div>
  436. </div>
  437. <div className="flex gap-2">
  438. <Button
  439. size="sm"
  440. variant="outline"
  441. onClick={() => handleEdit(config)}
  442. >
  443. 编辑
  444. </Button>
  445. <Button
  446. size="sm"
  447. variant="destructive"
  448. onClick={() => handleDelete(config.id)}
  449. >
  450. 删除
  451. </Button>
  452. </div>
  453. </div>
  454. </div>
  455. ))}
  456. </div>
  457. )}
  458. </CardContent>
  459. </Card>
  460. </div>
  461. </div>
  462. );
  463. // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
  464. if (embedded) {
  465. return (
  466. <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
  467. {headerContent}
  468. {mainContent}
  469. </div>
  470. );
  471. }
  472. return (
  473. <ResponsiveLayout
  474. main={mainContent}
  475. header={headerContent}
  476. />
  477. );
  478. }