dsh-plugin-image-workbench 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1371 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/server/static/index.html +15 -0
  25. package/vendor/chatgpt2api/source/.env.example +82 -82
  26. package/vendor/chatgpt2api/source/.python-version +1 -1
  27. package/vendor/chatgpt2api/source/LICENSE +21 -21
  28. package/vendor/chatgpt2api/source/README.md +391 -391
  29. package/vendor/chatgpt2api/source/VERSION +1 -1
  30. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  31. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  32. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  33. package/vendor/chatgpt2api/source/api/app.py +62 -62
  34. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  35. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  36. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  37. package/vendor/chatgpt2api/source/api/support.py +134 -134
  38. package/vendor/chatgpt2api/source/api/system.py +365 -365
  39. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  40. package/vendor/chatgpt2api/source/config.default.json +108 -108
  41. package/vendor/chatgpt2api/source/main.py +9 -9
  42. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  43. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  44. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  45. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  46. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  47. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  48. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  49. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  50. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  51. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  52. package/vendor/chatgpt2api/source/services/config.py +667 -667
  53. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  54. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  55. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  56. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  57. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  58. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  59. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  60. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  61. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  62. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  63. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  64. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  65. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  66. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  67. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  68. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  69. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  70. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  75. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  76. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  77. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  78. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  79. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  80. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  81. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  82. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  83. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  84. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  85. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  86. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  87. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  88. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  89. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  90. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  91. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  92. package/vendor/chatgpt2api/source/uv.lock +904 -904
  93. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  94. package/vendor/chatgpt2api/source/web/components.json +24 -24
  95. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  96. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  97. package/vendor/chatgpt2api/source/web/package.json +58 -58
  98. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  100. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  107. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  108. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  109. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  112. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  113. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  114. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  115. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  116. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  117. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  118. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  133. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  134. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  135. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  136. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  137. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  138. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  139. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  140. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  141. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  142. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  143. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  144. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  145. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  146. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  147. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  149. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  150. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  151. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  152. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  153. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  154. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  155. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  156. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  157. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  158. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  159. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  160. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  161. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  162. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  163. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  164. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  165. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  166. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  167. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  168. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  169. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  170. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  171. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  172. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  173. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,447 +1,447 @@
1
- "use client";
2
-
3
- import { CloudUpload, Download, Eye, LoaderCircle, Play, RefreshCcw, Shield, Trash2 } from "lucide-react";
4
- import { useState } from "react";
5
- import { toast } from "sonner";
6
-
7
- import { Badge } from "@/components/ui/badge";
8
- import { Button } from "@/components/ui/button";
9
- import { Card, CardContent } from "@/components/ui/card";
10
- import { Checkbox } from "@/components/ui/checkbox";
11
- import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
12
- import { Input } from "@/components/ui/input";
13
- import webConfig from "@/constants/common-env";
14
- import { fetchBackupDetail, getBackupDownloadUrl, type BackupDetail, type BackupInclude } from "@/lib/api";
15
- import { getStoredAuthKey } from "@/store/auth";
16
- import { useSettingsStore } from "../store";
17
-
18
- function formatDateTime(value?: string | null) {
19
- if (!value) {
20
- return "—";
21
- }
22
- const date = new Date(value);
23
- if (Number.isNaN(date.getTime())) {
24
- return value;
25
- }
26
- return new Intl.DateTimeFormat("zh-CN", {
27
- year: "numeric",
28
- month: "2-digit",
29
- day: "2-digit",
30
- hour: "2-digit",
31
- minute: "2-digit",
32
- }).format(date);
33
- }
34
-
35
- function formatBytes(value: number) {
36
- if (!Number.isFinite(value) || value <= 0) {
37
- return "0 B";
38
- }
39
- const units = ["B", "KB", "MB", "GB"];
40
- let size = value;
41
- let index = 0;
42
- while (size >= 1024 && index < units.length - 1) {
43
- size /= 1024;
44
- index += 1;
45
- }
46
- return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
47
- }
48
-
49
- function getFilenameFromContentDisposition(value: string | null) {
50
- const header = String(value || "").trim();
51
- if (!header) {
52
- return "";
53
- }
54
- const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
55
- if (utf8Match?.[1]) {
56
- try {
57
- return decodeURIComponent(utf8Match[1]);
58
- } catch {
59
- return utf8Match[1];
60
- }
61
- }
62
- const plainMatch = header.match(/filename\s*=\s*"?([^";]+)"?/i);
63
- return plainMatch?.[1] || "";
64
- }
65
-
66
- const includeLabels: Array<{ key: keyof BackupInclude; label: string }> = [
67
- { key: "config", label: "系统配置" },
68
- { key: "cpa", label: "CPA 配置" },
69
- { key: "sub2api", label: "Sub2API 配置" },
70
- { key: "logs", label: "调度与调用日志" },
71
- { key: "image_tasks", label: "图片任务记录" },
72
- { key: "accounts_snapshot", label: "账号快照" },
73
- { key: "auth_keys_snapshot", label: "用户密钥快照" },
74
- { key: "images", label: "图片文件目录" },
75
- ];
76
-
77
- export function BackupSettingsCard() {
78
- const [detailOpen, setDetailOpen] = useState(false);
79
- const [detailLoading, setDetailLoading] = useState(false);
80
- const [detail, setDetail] = useState<BackupDetail | null>(null);
81
- const config = useSettingsStore((state) => state.config);
82
- const backups = useSettingsStore((state) => state.backups);
83
- const backupState = useSettingsStore((state) => state.backupState);
84
- const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
85
- const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
86
- const isLoadingBackups = useSettingsStore((state) => state.isLoadingBackups);
87
- const isRunningBackup = useSettingsStore((state) => state.isRunningBackup);
88
- const deletingBackupKey = useSettingsStore((state) => state.deletingBackupKey);
89
- const isTestingBackup = useSettingsStore((state) => state.isTestingBackup);
90
- const saveConfig = useSettingsStore((state) => state.saveConfig);
91
- const loadBackups = useSettingsStore((state) => state.loadBackups);
92
- const runBackup = useSettingsStore((state) => state.runBackup);
93
- const removeBackup = useSettingsStore((state) => state.removeBackup);
94
- const testBackup = useSettingsStore((state) => state.testBackup);
95
- const setBackupField = useSettingsStore((state) => state.setBackupField);
96
- const setBackupInclude = useSettingsStore((state) => state.setBackupInclude);
97
-
98
- if (isLoadingConfig) {
99
- return (
100
- <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
101
- <CardContent className="flex items-center justify-center p-10">
102
- <LoaderCircle className="size-5 animate-spin text-stone-400" />
103
- </CardContent>
104
- </Card>
105
- );
106
- }
107
-
108
- const backup = config?.backup;
109
- if (!backup) {
110
- return null;
111
- }
112
-
113
- const handleOpenDetail = async (key: string) => {
114
- setDetailLoading(true);
115
- setDetailOpen(true);
116
- try {
117
- const data = await fetchBackupDetail(key);
118
- setDetail(data.item);
119
- } catch (error) {
120
- setDetail(null);
121
- toast.error(error instanceof Error ? error.message : "读取备份详情失败");
122
- } finally {
123
- setDetailLoading(false);
124
- }
125
- };
126
-
127
- const handleDownload = async (key: string, name: string) => {
128
- try {
129
- const authKey = await getStoredAuthKey();
130
- if (!authKey) {
131
- toast.error("当前登录态已失效,请重新登录后再下载");
132
- return;
133
- }
134
- const response = await fetch(`${webConfig.apiUrl.replace(/\/$/, "")}${getBackupDownloadUrl(key)}`, {
135
- headers: {
136
- Authorization: `Bearer ${authKey}`,
137
- },
138
- });
139
- if (!response.ok) {
140
- let message = "下载备份失败";
141
- try {
142
- const data = await response.json() as { detail?: { error?: string }; error?: string; message?: string };
143
- message = data.detail?.error || data.error || data.message || message;
144
- } catch {
145
- message = response.status === 401 ? "登录已失效,请重新登录后再试" : message;
146
- }
147
- throw new Error(message);
148
- }
149
- const downloadName = getFilenameFromContentDisposition(response.headers.get("Content-Disposition")) || name || "backup.bin";
150
- const blob = await response.blob();
151
- const url = window.URL.createObjectURL(blob);
152
- const anchor = document.createElement("a");
153
- anchor.href = url;
154
- anchor.download = downloadName;
155
- document.body.append(anchor);
156
- anchor.click();
157
- anchor.remove();
158
- window.URL.revokeObjectURL(url);
159
- toast.success("备份下载已开始");
160
- } catch (error) {
161
- toast.error(error instanceof Error ? error.message : "下载备份失败");
162
- }
163
- };
164
-
165
- return (
166
- <>
167
- <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
168
- <CardContent className="space-y-6 p-6">
169
- <div className="flex items-start justify-between gap-4">
170
- <div className="flex items-center gap-3">
171
- <div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
172
- <CloudUpload className="size-5 text-stone-600" />
173
- </div>
174
- <div>
175
- <h2 className="text-lg font-semibold tracking-tight">R2 备份管理</h2>
176
- <p className="text-sm text-stone-500">将关键数据定时备份到 Cloudflare R2,支持可选加密、轮替、手动执行与历史清理。</p>
177
- </div>
178
- </div>
179
- <div className="flex items-center gap-2">
180
- <Badge variant={backupState?.running ? "warning" : backupState?.last_status === "success" ? "success" : "secondary"} className="rounded-md">
181
- {backupState?.running ? "备份中" : backupState?.last_status === "success" ? "最近成功" : backupState?.last_status === "error" ? "最近失败" : "未执行"}
182
- </Badge>
183
- </div>
184
- </div>
185
-
186
- <div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm leading-6 text-stone-600">
187
- 账号与用户密钥会从当前存储后端导出逻辑快照,不依赖底层是 `json`、`sqlite`、`postgres` 还是 `git`。图片目录默认不备份,避免备份体积过大。
188
- </div>
189
-
190
- <div className="grid gap-4 md:grid-cols-2">
191
- <label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
192
- <Checkbox
193
- checked={Boolean(backup.enabled)}
194
- onCheckedChange={(checked) => setBackupField("enabled", Boolean(checked))}
195
- />
196
- 启用定时备份
197
- </label>
198
- <label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
199
- <Checkbox
200
- checked={Boolean(backup.encrypt)}
201
- onCheckedChange={(checked) => setBackupField("encrypt", Boolean(checked))}
202
- />
203
- 启用备份加密
204
- </label>
205
-
206
- <div className="space-y-2">
207
- <label className="text-sm text-stone-700">Cloudflare Account ID</label>
208
- <Input value={String(backup.account_id || "")} onChange={(event) => setBackupField("account_id", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
209
- </div>
210
- <div className="space-y-2">
211
- <label className="text-sm text-stone-700">Bucket 名称</label>
212
- <Input value={String(backup.bucket || "")} onChange={(event) => setBackupField("bucket", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
213
- </div>
214
-
215
- <div className="space-y-2">
216
- <label className="text-sm text-stone-700">Access Key ID</label>
217
- <Input value={String(backup.access_key_id || "")} onChange={(event) => setBackupField("access_key_id", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
218
- </div>
219
- <div className="space-y-2">
220
- <label className="text-sm text-stone-700">Secret Access Key</label>
221
- <Input type="password" value={String(backup.secret_access_key || "")} onChange={(event) => setBackupField("secret_access_key", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
222
- </div>
223
-
224
- <div className="space-y-2">
225
- <label className="text-sm text-stone-700">备份前缀</label>
226
- <Input value={String(backup.prefix || "")} onChange={(event) => setBackupField("prefix", event.target.value)} placeholder="backups" className="h-10 rounded-xl border-stone-200 bg-white" />
227
- <p className="text-xs text-stone-500">R2 内对象前缀,例如 `backups/prod`。</p>
228
- </div>
229
- <div className="space-y-2">
230
- <label className="text-sm text-stone-700">定时备份间隔</label>
231
- <Input value={String(backup.interval_minutes || "")} onChange={(event) => setBackupField("interval_minutes", event.target.value)} placeholder="360" className="h-10 rounded-xl border-stone-200 bg-white" />
232
- <p className="text-xs text-stone-500">单位分钟,服务启动后会按此间隔自动轮询执行。</p>
233
- </div>
234
-
235
- <div className="space-y-2">
236
- <label className="text-sm text-stone-700">保留备份数量</label>
237
- <Input value={String(backup.rotation_keep || "")} onChange={(event) => setBackupField("rotation_keep", event.target.value)} placeholder="10" className="h-10 rounded-xl border-stone-200 bg-white" />
238
- <p className="text-xs text-stone-500">成功上传后自动删除更旧的备份。填 `0` 表示不自动轮替。</p>
239
- </div>
240
- <div className="space-y-2">
241
- <label className="text-sm text-stone-700">加密口令</label>
242
- <Input type="password" value={String(backup.passphrase || "")} onChange={(event) => setBackupField("passphrase", event.target.value)} placeholder={backup.encrypt ? "启用加密后必填" : "留空"} className="h-10 rounded-xl border-stone-200 bg-white" />
243
- <p className="text-xs text-stone-500">仅在启用加密时使用。请妥善保管,否则无法解密备份内容。</p>
244
- </div>
245
- </div>
246
-
247
- <div className="space-y-3 rounded-xl border border-stone-200 bg-white px-4 py-4">
248
- <div>
249
- <div className="text-sm font-medium text-stone-800">备份内容</div>
250
- <p className="mt-1 text-xs text-stone-500">按组件勾选需要进入备份包的数据。</p>
251
- </div>
252
- <div className="grid gap-3 md:grid-cols-3">
253
- {includeLabels.map((item) => (
254
- <label key={item.key} className="flex items-center gap-3 text-sm text-stone-700">
255
- <Checkbox
256
- checked={Boolean(backup.include[item.key])}
257
- onCheckedChange={(checked) => setBackupInclude(item.key, Boolean(checked))}
258
- />
259
- {item.label}
260
- </label>
261
- ))}
262
- </div>
263
- </div>
264
-
265
- <div className="grid gap-3 rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-sm text-stone-600 md:grid-cols-3">
266
- <div>
267
- <div className="text-xs text-stone-500">最近开始</div>
268
- <div className="mt-1 font-medium text-stone-800">{formatDateTime(backupState?.last_started_at)}</div>
269
- </div>
270
- <div>
271
- <div className="text-xs text-stone-500">最近完成</div>
272
- <div className="mt-1 font-medium text-stone-800">{formatDateTime(backupState?.last_finished_at)}</div>
273
- </div>
274
- <div>
275
- <div className="text-xs text-stone-500">最近对象</div>
276
- <div className="mt-1 break-all font-medium text-stone-800">{backupState?.last_object_key || "—"}</div>
277
- </div>
278
- {backupState?.last_error ? (
279
- <div className="md:col-span-3">
280
- <div className="text-xs text-rose-500">最近错误</div>
281
- <div className="mt-1 break-all rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-rose-700">{backupState.last_error}</div>
282
- </div>
283
- ) : null}
284
- </div>
285
-
286
- <div className="flex flex-wrap justify-end gap-2">
287
- <Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void testBackup()} disabled={isTestingBackup}>
288
- {isTestingBackup ? <LoaderCircle className="size-4 animate-spin" /> : <Shield className="size-4" />}
289
- 测试连接
290
- </Button>
291
- <Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void loadBackups()} disabled={isLoadingBackups}>
292
- {isLoadingBackups ? <LoaderCircle className="size-4 animate-spin" /> : <RefreshCcw className="size-4" />}
293
- 刷新列表
294
- </Button>
295
- <Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void runBackup()} disabled={isRunningBackup || Boolean(backupState?.running)}>
296
- {isRunningBackup || backupState?.running ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
297
- 立即备份
298
- </Button>
299
- <Button className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800" onClick={() => void saveConfig()} disabled={isSavingConfig}>
300
- {isSavingConfig ? <LoaderCircle className="size-4 animate-spin" /> : <CloudUpload className="size-4" />}
301
- 保存配置
302
- </Button>
303
- </div>
304
-
305
- <div className="space-y-3">
306
- <div className="flex items-center justify-between gap-3">
307
- <div>
308
- <h3 className="text-sm font-medium text-stone-800">历史备份</h3>
309
- <p className="text-xs text-stone-500">支持查看对象信息并直接删除远端备份。</p>
310
- </div>
311
- </div>
312
-
313
- {isLoadingBackups ? (
314
- <div className="flex items-center justify-center py-10">
315
- <LoaderCircle className="size-5 animate-spin text-stone-400" />
316
- </div>
317
- ) : backups.length === 0 ? (
318
- <div className="rounded-xl bg-stone-50 px-6 py-10 text-center text-sm text-stone-500">
319
- 暂无远端备份记录。保存配置并执行一次手动备份后会出现在这里。
320
- </div>
321
- ) : (
322
- <div className="space-y-3">
323
- {backups.map((item) => {
324
- const isDeleting = deletingBackupKey === item.key;
325
- return (
326
- <div key={item.key} className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-4 md:flex-row md:items-center md:justify-between">
327
- <div className="min-w-0 space-y-2">
328
- <div className="flex flex-wrap items-center gap-2">
329
- <div className="break-all text-sm font-medium text-stone-800">{item.name}</div>
330
- {item.encrypted ? <Badge variant="secondary" className="rounded-md">已加密</Badge> : null}
331
- </div>
332
- <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500">
333
- <span>大小 {formatBytes(item.size)}</span>
334
- <span>更新时间 {formatDateTime(item.updated_at)}</span>
335
- <span className="break-all">对象 key {item.key}</span>
336
- </div>
337
- </div>
338
-
339
- <div className="flex items-center gap-2">
340
- <Button
341
- type="button"
342
- variant="outline"
343
- className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
344
- onClick={() => void handleDownload(item.key, item.name)}
345
- >
346
- <Download className="size-4" />
347
- 下载
348
- </Button>
349
- <Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void handleOpenDetail(item.key)}>
350
- <Eye className="size-4" />
351
- 查看详情
352
- </Button>
353
- <Button
354
- type="button"
355
- variant="outline"
356
- className="h-9 rounded-xl border-rose-200 bg-white px-4 text-rose-700"
357
- onClick={() => void removeBackup(item.key)}
358
- disabled={isDeleting}
359
- >
360
- {isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
361
- 删除
362
- </Button>
363
- </div>
364
- </div>
365
- );
366
- })}
367
- </div>
368
- )}
369
- </div>
370
- </CardContent>
371
- </Card>
372
-
373
- <Dialog open={detailOpen} onOpenChange={setDetailOpen}>
374
- <DialogContent className="flex max-h-[85vh] max-w-3xl flex-col overflow-hidden rounded-2xl border-white/80 bg-white">
375
- <DialogHeader className="shrink-0 border-b border-stone-200 pb-3">
376
- <DialogTitle>备份详情</DialogTitle>
377
- </DialogHeader>
378
- <div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
379
- {detailLoading ? (
380
- <div className="flex items-center justify-center py-16">
381
- <LoaderCircle className="size-5 animate-spin text-stone-400" />
382
- </div>
383
- ) : !detail ? (
384
- <div className="rounded-xl bg-stone-50 px-6 py-10 text-center text-sm text-stone-500">
385
- 暂时无法读取备份详情;如果这是加密备份,请确认当前已填写正确的加密口令并先保存配置。
386
- </div>
387
- ) : (
388
- <>
389
- <div className="grid gap-3 rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-sm text-stone-600 md:grid-cols-2">
390
- <div>
391
- <div className="text-xs text-stone-500">对象名称</div>
392
- <div className="mt-1 break-all font-medium text-stone-800">{detail.name}</div>
393
- </div>
394
- <div>
395
- <div className="text-xs text-stone-500">创建时间</div>
396
- <div className="mt-1 font-medium text-stone-800">{formatDateTime(detail.created_at)}</div>
397
- </div>
398
- <div>
399
- <div className="text-xs text-stone-500">触发方式</div>
400
- <div className="mt-1 font-medium text-stone-800">{detail.trigger || "—"}</div>
401
- </div>
402
- <div>
403
- <div className="text-xs text-stone-500">应用版本</div>
404
- <div className="mt-1 font-medium text-stone-800">{detail.app_version || "—"}</div>
405
- </div>
406
- <div className="md:col-span-2">
407
- <div className="text-xs text-stone-500">存储后端</div>
408
- <pre className="mt-1 overflow-x-auto rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs text-stone-700">{JSON.stringify(detail.storage_backend || {}, null, 2)}</pre>
409
- </div>
410
- </div>
411
-
412
- <div className="space-y-3">
413
- <h4 className="text-sm font-medium text-stone-800">文件内容</h4>
414
- <div className="space-y-2">
415
- {detail.files.map((item) => (
416
- <div key={item.name} className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm">
417
- <div className="break-all font-medium text-stone-800">{item.name}</div>
418
- <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500">
419
- <span>{item.exists ? "已包含" : "缺失"}</span>
420
- <span>大小 {formatBytes(item.size)}</span>
421
- <span>{item.content_type || "application/octet-stream"}</span>
422
- <span className="break-all">SHA256 {item.sha256 || "—"}</span>
423
- </div>
424
- </div>
425
- ))}
426
- </div>
427
- </div>
428
-
429
- <div className="space-y-3">
430
- <h4 className="text-sm font-medium text-stone-800">快照内容</h4>
431
- <div className="grid gap-3 md:grid-cols-2">
432
- {detail.snapshots.map((item) => (
433
- <div key={item.name} className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm">
434
- <div className="font-medium text-stone-800">{item.name}</div>
435
- <div className="mt-2 text-xs text-stone-500">记录数 {item.count}</div>
436
- </div>
437
- ))}
438
- </div>
439
- </div>
440
- </>
441
- )}
442
- </div>
443
- </DialogContent>
444
- </Dialog>
445
- </>
446
- );
447
- }
1
+ "use client";
2
+
3
+ import { CloudUpload, Download, Eye, LoaderCircle, Play, RefreshCcw, Shield, Trash2 } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { toast } from "sonner";
6
+
7
+ import { Badge } from "@/components/ui/badge";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Card, CardContent } from "@/components/ui/card";
10
+ import { Checkbox } from "@/components/ui/checkbox";
11
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
12
+ import { Input } from "@/components/ui/input";
13
+ import webConfig from "@/constants/common-env";
14
+ import { fetchBackupDetail, getBackupDownloadUrl, type BackupDetail, type BackupInclude } from "@/lib/api";
15
+ import { getStoredAuthKey } from "@/store/auth";
16
+ import { useSettingsStore } from "../store";
17
+
18
+ function formatDateTime(value?: string | null) {
19
+ if (!value) {
20
+ return "—";
21
+ }
22
+ const date = new Date(value);
23
+ if (Number.isNaN(date.getTime())) {
24
+ return value;
25
+ }
26
+ return new Intl.DateTimeFormat("zh-CN", {
27
+ year: "numeric",
28
+ month: "2-digit",
29
+ day: "2-digit",
30
+ hour: "2-digit",
31
+ minute: "2-digit",
32
+ }).format(date);
33
+ }
34
+
35
+ function formatBytes(value: number) {
36
+ if (!Number.isFinite(value) || value <= 0) {
37
+ return "0 B";
38
+ }
39
+ const units = ["B", "KB", "MB", "GB"];
40
+ let size = value;
41
+ let index = 0;
42
+ while (size >= 1024 && index < units.length - 1) {
43
+ size /= 1024;
44
+ index += 1;
45
+ }
46
+ return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
47
+ }
48
+
49
+ function getFilenameFromContentDisposition(value: string | null) {
50
+ const header = String(value || "").trim();
51
+ if (!header) {
52
+ return "";
53
+ }
54
+ const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
55
+ if (utf8Match?.[1]) {
56
+ try {
57
+ return decodeURIComponent(utf8Match[1]);
58
+ } catch {
59
+ return utf8Match[1];
60
+ }
61
+ }
62
+ const plainMatch = header.match(/filename\s*=\s*"?([^";]+)"?/i);
63
+ return plainMatch?.[1] || "";
64
+ }
65
+
66
+ const includeLabels: Array<{ key: keyof BackupInclude; label: string }> = [
67
+ { key: "config", label: "系统配置" },
68
+ { key: "cpa", label: "CPA 配置" },
69
+ { key: "sub2api", label: "Sub2API 配置" },
70
+ { key: "logs", label: "调度与调用日志" },
71
+ { key: "image_tasks", label: "图片任务记录" },
72
+ { key: "accounts_snapshot", label: "账号快照" },
73
+ { key: "auth_keys_snapshot", label: "用户密钥快照" },
74
+ { key: "images", label: "图片文件目录" },
75
+ ];
76
+
77
+ export function BackupSettingsCard() {
78
+ const [detailOpen, setDetailOpen] = useState(false);
79
+ const [detailLoading, setDetailLoading] = useState(false);
80
+ const [detail, setDetail] = useState<BackupDetail | null>(null);
81
+ const config = useSettingsStore((state) => state.config);
82
+ const backups = useSettingsStore((state) => state.backups);
83
+ const backupState = useSettingsStore((state) => state.backupState);
84
+ const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
85
+ const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
86
+ const isLoadingBackups = useSettingsStore((state) => state.isLoadingBackups);
87
+ const isRunningBackup = useSettingsStore((state) => state.isRunningBackup);
88
+ const deletingBackupKey = useSettingsStore((state) => state.deletingBackupKey);
89
+ const isTestingBackup = useSettingsStore((state) => state.isTestingBackup);
90
+ const saveConfig = useSettingsStore((state) => state.saveConfig);
91
+ const loadBackups = useSettingsStore((state) => state.loadBackups);
92
+ const runBackup = useSettingsStore((state) => state.runBackup);
93
+ const removeBackup = useSettingsStore((state) => state.removeBackup);
94
+ const testBackup = useSettingsStore((state) => state.testBackup);
95
+ const setBackupField = useSettingsStore((state) => state.setBackupField);
96
+ const setBackupInclude = useSettingsStore((state) => state.setBackupInclude);
97
+
98
+ if (isLoadingConfig) {
99
+ return (
100
+ <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
101
+ <CardContent className="flex items-center justify-center p-10">
102
+ <LoaderCircle className="size-5 animate-spin text-stone-400" />
103
+ </CardContent>
104
+ </Card>
105
+ );
106
+ }
107
+
108
+ const backup = config?.backup;
109
+ if (!backup) {
110
+ return null;
111
+ }
112
+
113
+ const handleOpenDetail = async (key: string) => {
114
+ setDetailLoading(true);
115
+ setDetailOpen(true);
116
+ try {
117
+ const data = await fetchBackupDetail(key);
118
+ setDetail(data.item);
119
+ } catch (error) {
120
+ setDetail(null);
121
+ toast.error(error instanceof Error ? error.message : "读取备份详情失败");
122
+ } finally {
123
+ setDetailLoading(false);
124
+ }
125
+ };
126
+
127
+ const handleDownload = async (key: string, name: string) => {
128
+ try {
129
+ const authKey = await getStoredAuthKey();
130
+ if (!authKey) {
131
+ toast.error("当前登录态已失效,请重新登录后再下载");
132
+ return;
133
+ }
134
+ const response = await fetch(`${webConfig.apiUrl.replace(/\/$/, "")}${getBackupDownloadUrl(key)}`, {
135
+ headers: {
136
+ Authorization: `Bearer ${authKey}`,
137
+ },
138
+ });
139
+ if (!response.ok) {
140
+ let message = "下载备份失败";
141
+ try {
142
+ const data = await response.json() as { detail?: { error?: string }; error?: string; message?: string };
143
+ message = data.detail?.error || data.error || data.message || message;
144
+ } catch {
145
+ message = response.status === 401 ? "登录已失效,请重新登录后再试" : message;
146
+ }
147
+ throw new Error(message);
148
+ }
149
+ const downloadName = getFilenameFromContentDisposition(response.headers.get("Content-Disposition")) || name || "backup.bin";
150
+ const blob = await response.blob();
151
+ const url = window.URL.createObjectURL(blob);
152
+ const anchor = document.createElement("a");
153
+ anchor.href = url;
154
+ anchor.download = downloadName;
155
+ document.body.append(anchor);
156
+ anchor.click();
157
+ anchor.remove();
158
+ window.URL.revokeObjectURL(url);
159
+ toast.success("备份下载已开始");
160
+ } catch (error) {
161
+ toast.error(error instanceof Error ? error.message : "下载备份失败");
162
+ }
163
+ };
164
+
165
+ return (
166
+ <>
167
+ <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
168
+ <CardContent className="space-y-6 p-6">
169
+ <div className="flex items-start justify-between gap-4">
170
+ <div className="flex items-center gap-3">
171
+ <div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
172
+ <CloudUpload className="size-5 text-stone-600" />
173
+ </div>
174
+ <div>
175
+ <h2 className="text-lg font-semibold tracking-tight">R2 备份管理</h2>
176
+ <p className="text-sm text-stone-500">将关键数据定时备份到 Cloudflare R2,支持可选加密、轮替、手动执行与历史清理。</p>
177
+ </div>
178
+ </div>
179
+ <div className="flex items-center gap-2">
180
+ <Badge variant={backupState?.running ? "warning" : backupState?.last_status === "success" ? "success" : "secondary"} className="rounded-md">
181
+ {backupState?.running ? "备份中" : backupState?.last_status === "success" ? "最近成功" : backupState?.last_status === "error" ? "最近失败" : "未执行"}
182
+ </Badge>
183
+ </div>
184
+ </div>
185
+
186
+ <div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm leading-6 text-stone-600">
187
+ 账号与用户密钥会从当前存储后端导出逻辑快照,不依赖底层是 `json`、`sqlite`、`postgres` 还是 `git`。图片目录默认不备份,避免备份体积过大。
188
+ </div>
189
+
190
+ <div className="grid gap-4 md:grid-cols-2">
191
+ <label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
192
+ <Checkbox
193
+ checked={Boolean(backup.enabled)}
194
+ onCheckedChange={(checked) => setBackupField("enabled", Boolean(checked))}
195
+ />
196
+ 启用定时备份
197
+ </label>
198
+ <label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
199
+ <Checkbox
200
+ checked={Boolean(backup.encrypt)}
201
+ onCheckedChange={(checked) => setBackupField("encrypt", Boolean(checked))}
202
+ />
203
+ 启用备份加密
204
+ </label>
205
+
206
+ <div className="space-y-2">
207
+ <label className="text-sm text-stone-700">Cloudflare Account ID</label>
208
+ <Input value={String(backup.account_id || "")} onChange={(event) => setBackupField("account_id", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
209
+ </div>
210
+ <div className="space-y-2">
211
+ <label className="text-sm text-stone-700">Bucket 名称</label>
212
+ <Input value={String(backup.bucket || "")} onChange={(event) => setBackupField("bucket", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
213
+ </div>
214
+
215
+ <div className="space-y-2">
216
+ <label className="text-sm text-stone-700">Access Key ID</label>
217
+ <Input value={String(backup.access_key_id || "")} onChange={(event) => setBackupField("access_key_id", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
218
+ </div>
219
+ <div className="space-y-2">
220
+ <label className="text-sm text-stone-700">Secret Access Key</label>
221
+ <Input type="password" value={String(backup.secret_access_key || "")} onChange={(event) => setBackupField("secret_access_key", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
222
+ </div>
223
+
224
+ <div className="space-y-2">
225
+ <label className="text-sm text-stone-700">备份前缀</label>
226
+ <Input value={String(backup.prefix || "")} onChange={(event) => setBackupField("prefix", event.target.value)} placeholder="backups" className="h-10 rounded-xl border-stone-200 bg-white" />
227
+ <p className="text-xs text-stone-500">R2 内对象前缀,例如 `backups/prod`。</p>
228
+ </div>
229
+ <div className="space-y-2">
230
+ <label className="text-sm text-stone-700">定时备份间隔</label>
231
+ <Input value={String(backup.interval_minutes || "")} onChange={(event) => setBackupField("interval_minutes", event.target.value)} placeholder="360" className="h-10 rounded-xl border-stone-200 bg-white" />
232
+ <p className="text-xs text-stone-500">单位分钟,服务启动后会按此间隔自动轮询执行。</p>
233
+ </div>
234
+
235
+ <div className="space-y-2">
236
+ <label className="text-sm text-stone-700">保留备份数量</label>
237
+ <Input value={String(backup.rotation_keep || "")} onChange={(event) => setBackupField("rotation_keep", event.target.value)} placeholder="10" className="h-10 rounded-xl border-stone-200 bg-white" />
238
+ <p className="text-xs text-stone-500">成功上传后自动删除更旧的备份。填 `0` 表示不自动轮替。</p>
239
+ </div>
240
+ <div className="space-y-2">
241
+ <label className="text-sm text-stone-700">加密口令</label>
242
+ <Input type="password" value={String(backup.passphrase || "")} onChange={(event) => setBackupField("passphrase", event.target.value)} placeholder={backup.encrypt ? "启用加密后必填" : "留空"} className="h-10 rounded-xl border-stone-200 bg-white" />
243
+ <p className="text-xs text-stone-500">仅在启用加密时使用。请妥善保管,否则无法解密备份内容。</p>
244
+ </div>
245
+ </div>
246
+
247
+ <div className="space-y-3 rounded-xl border border-stone-200 bg-white px-4 py-4">
248
+ <div>
249
+ <div className="text-sm font-medium text-stone-800">备份内容</div>
250
+ <p className="mt-1 text-xs text-stone-500">按组件勾选需要进入备份包的数据。</p>
251
+ </div>
252
+ <div className="grid gap-3 md:grid-cols-3">
253
+ {includeLabels.map((item) => (
254
+ <label key={item.key} className="flex items-center gap-3 text-sm text-stone-700">
255
+ <Checkbox
256
+ checked={Boolean(backup.include[item.key])}
257
+ onCheckedChange={(checked) => setBackupInclude(item.key, Boolean(checked))}
258
+ />
259
+ {item.label}
260
+ </label>
261
+ ))}
262
+ </div>
263
+ </div>
264
+
265
+ <div className="grid gap-3 rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-sm text-stone-600 md:grid-cols-3">
266
+ <div>
267
+ <div className="text-xs text-stone-500">最近开始</div>
268
+ <div className="mt-1 font-medium text-stone-800">{formatDateTime(backupState?.last_started_at)}</div>
269
+ </div>
270
+ <div>
271
+ <div className="text-xs text-stone-500">最近完成</div>
272
+ <div className="mt-1 font-medium text-stone-800">{formatDateTime(backupState?.last_finished_at)}</div>
273
+ </div>
274
+ <div>
275
+ <div className="text-xs text-stone-500">最近对象</div>
276
+ <div className="mt-1 break-all font-medium text-stone-800">{backupState?.last_object_key || "—"}</div>
277
+ </div>
278
+ {backupState?.last_error ? (
279
+ <div className="md:col-span-3">
280
+ <div className="text-xs text-rose-500">最近错误</div>
281
+ <div className="mt-1 break-all rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-rose-700">{backupState.last_error}</div>
282
+ </div>
283
+ ) : null}
284
+ </div>
285
+
286
+ <div className="flex flex-wrap justify-end gap-2">
287
+ <Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void testBackup()} disabled={isTestingBackup}>
288
+ {isTestingBackup ? <LoaderCircle className="size-4 animate-spin" /> : <Shield className="size-4" />}
289
+ 测试连接
290
+ </Button>
291
+ <Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void loadBackups()} disabled={isLoadingBackups}>
292
+ {isLoadingBackups ? <LoaderCircle className="size-4 animate-spin" /> : <RefreshCcw className="size-4" />}
293
+ 刷新列表
294
+ </Button>
295
+ <Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void runBackup()} disabled={isRunningBackup || Boolean(backupState?.running)}>
296
+ {isRunningBackup || backupState?.running ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
297
+ 立即备份
298
+ </Button>
299
+ <Button className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800" onClick={() => void saveConfig()} disabled={isSavingConfig}>
300
+ {isSavingConfig ? <LoaderCircle className="size-4 animate-spin" /> : <CloudUpload className="size-4" />}
301
+ 保存配置
302
+ </Button>
303
+ </div>
304
+
305
+ <div className="space-y-3">
306
+ <div className="flex items-center justify-between gap-3">
307
+ <div>
308
+ <h3 className="text-sm font-medium text-stone-800">历史备份</h3>
309
+ <p className="text-xs text-stone-500">支持查看对象信息并直接删除远端备份。</p>
310
+ </div>
311
+ </div>
312
+
313
+ {isLoadingBackups ? (
314
+ <div className="flex items-center justify-center py-10">
315
+ <LoaderCircle className="size-5 animate-spin text-stone-400" />
316
+ </div>
317
+ ) : backups.length === 0 ? (
318
+ <div className="rounded-xl bg-stone-50 px-6 py-10 text-center text-sm text-stone-500">
319
+ 暂无远端备份记录。保存配置并执行一次手动备份后会出现在这里。
320
+ </div>
321
+ ) : (
322
+ <div className="space-y-3">
323
+ {backups.map((item) => {
324
+ const isDeleting = deletingBackupKey === item.key;
325
+ return (
326
+ <div key={item.key} className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-4 md:flex-row md:items-center md:justify-between">
327
+ <div className="min-w-0 space-y-2">
328
+ <div className="flex flex-wrap items-center gap-2">
329
+ <div className="break-all text-sm font-medium text-stone-800">{item.name}</div>
330
+ {item.encrypted ? <Badge variant="secondary" className="rounded-md">已加密</Badge> : null}
331
+ </div>
332
+ <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500">
333
+ <span>大小 {formatBytes(item.size)}</span>
334
+ <span>更新时间 {formatDateTime(item.updated_at)}</span>
335
+ <span className="break-all">对象 key {item.key}</span>
336
+ </div>
337
+ </div>
338
+
339
+ <div className="flex items-center gap-2">
340
+ <Button
341
+ type="button"
342
+ variant="outline"
343
+ className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
344
+ onClick={() => void handleDownload(item.key, item.name)}
345
+ >
346
+ <Download className="size-4" />
347
+ 下载
348
+ </Button>
349
+ <Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void handleOpenDetail(item.key)}>
350
+ <Eye className="size-4" />
351
+ 查看详情
352
+ </Button>
353
+ <Button
354
+ type="button"
355
+ variant="outline"
356
+ className="h-9 rounded-xl border-rose-200 bg-white px-4 text-rose-700"
357
+ onClick={() => void removeBackup(item.key)}
358
+ disabled={isDeleting}
359
+ >
360
+ {isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
361
+ 删除
362
+ </Button>
363
+ </div>
364
+ </div>
365
+ );
366
+ })}
367
+ </div>
368
+ )}
369
+ </div>
370
+ </CardContent>
371
+ </Card>
372
+
373
+ <Dialog open={detailOpen} onOpenChange={setDetailOpen}>
374
+ <DialogContent className="flex max-h-[85vh] max-w-3xl flex-col overflow-hidden rounded-2xl border-white/80 bg-white">
375
+ <DialogHeader className="shrink-0 border-b border-stone-200 pb-3">
376
+ <DialogTitle>备份详情</DialogTitle>
377
+ </DialogHeader>
378
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
379
+ {detailLoading ? (
380
+ <div className="flex items-center justify-center py-16">
381
+ <LoaderCircle className="size-5 animate-spin text-stone-400" />
382
+ </div>
383
+ ) : !detail ? (
384
+ <div className="rounded-xl bg-stone-50 px-6 py-10 text-center text-sm text-stone-500">
385
+ 暂时无法读取备份详情;如果这是加密备份,请确认当前已填写正确的加密口令并先保存配置。
386
+ </div>
387
+ ) : (
388
+ <>
389
+ <div className="grid gap-3 rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-sm text-stone-600 md:grid-cols-2">
390
+ <div>
391
+ <div className="text-xs text-stone-500">对象名称</div>
392
+ <div className="mt-1 break-all font-medium text-stone-800">{detail.name}</div>
393
+ </div>
394
+ <div>
395
+ <div className="text-xs text-stone-500">创建时间</div>
396
+ <div className="mt-1 font-medium text-stone-800">{formatDateTime(detail.created_at)}</div>
397
+ </div>
398
+ <div>
399
+ <div className="text-xs text-stone-500">触发方式</div>
400
+ <div className="mt-1 font-medium text-stone-800">{detail.trigger || "—"}</div>
401
+ </div>
402
+ <div>
403
+ <div className="text-xs text-stone-500">应用版本</div>
404
+ <div className="mt-1 font-medium text-stone-800">{detail.app_version || "—"}</div>
405
+ </div>
406
+ <div className="md:col-span-2">
407
+ <div className="text-xs text-stone-500">存储后端</div>
408
+ <pre className="mt-1 overflow-x-auto rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs text-stone-700">{JSON.stringify(detail.storage_backend || {}, null, 2)}</pre>
409
+ </div>
410
+ </div>
411
+
412
+ <div className="space-y-3">
413
+ <h4 className="text-sm font-medium text-stone-800">文件内容</h4>
414
+ <div className="space-y-2">
415
+ {detail.files.map((item) => (
416
+ <div key={item.name} className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm">
417
+ <div className="break-all font-medium text-stone-800">{item.name}</div>
418
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500">
419
+ <span>{item.exists ? "已包含" : "缺失"}</span>
420
+ <span>大小 {formatBytes(item.size)}</span>
421
+ <span>{item.content_type || "application/octet-stream"}</span>
422
+ <span className="break-all">SHA256 {item.sha256 || "—"}</span>
423
+ </div>
424
+ </div>
425
+ ))}
426
+ </div>
427
+ </div>
428
+
429
+ <div className="space-y-3">
430
+ <h4 className="text-sm font-medium text-stone-800">快照内容</h4>
431
+ <div className="grid gap-3 md:grid-cols-2">
432
+ {detail.snapshots.map((item) => (
433
+ <div key={item.name} className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm">
434
+ <div className="font-medium text-stone-800">{item.name}</div>
435
+ <div className="mt-2 text-xs text-stone-500">记录数 {item.count}</div>
436
+ </div>
437
+ ))}
438
+ </div>
439
+ </div>
440
+ </>
441
+ )}
442
+ </div>
443
+ </DialogContent>
444
+ </Dialog>
445
+ </>
446
+ );
447
+ }