HouYunFei Codex commited on
Commit
6b31eec
·
1 Parent(s): c50f4e5

Update config and management UI

Browse files

Co-Authored-By: Codex <noreply@openai.com>

api/ai.py CHANGED
@@ -1,5 +1,8 @@
1
  from __future__ import annotations
2
 
 
 
 
3
  from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
4
  from fastapi.concurrency import run_in_threadpool
5
  from fastapi.responses import StreamingResponse
@@ -8,6 +11,10 @@ from pydantic import BaseModel, ConfigDict, Field
8
  from api.support import raise_image_quota_error, require_identity, resolve_image_base_url
9
  from services.account_service import account_service
10
  from services.chatgpt_service import ChatGPTService, ImageGenerationError
 
 
 
 
11
  from utils.helper import anthropic_sse_stream, is_image_chat_request, sse_json_stream
12
 
13
 
@@ -48,6 +55,58 @@ class AnthropicMessageRequest(BaseModel):
48
  stream: bool | None = None
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
52
  router = APIRouter()
53
 
@@ -65,7 +124,8 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
65
  request: Request,
66
  authorization: str | None = Header(default=None),
67
  ):
68
- require_identity(authorization)
 
69
  base_url = resolve_image_base_url(request)
70
  if body.stream:
71
  try:
@@ -74,17 +134,27 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
74
  raise_image_quota_error(exc)
75
  return StreamingResponse(
76
  sse_json_stream(
77
- chatgpt_service.stream_image_generation(
78
- body.prompt, body.model, body.n, body.size, body.response_format, base_url
 
 
 
 
 
 
 
79
  )
80
  ),
81
  media_type="text/event-stream",
82
  )
83
  try:
84
- return await run_in_threadpool(
85
  chatgpt_service.generate_with_pool, body.prompt, body.model, body.n, body.size, body.response_format, base_url
86
  )
 
 
87
  except ImageGenerationError as exc:
 
88
  raise_image_quota_error(exc)
89
 
90
  @router.post("/v1/images/edits")
@@ -100,7 +170,8 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
100
  response_format: str = Form(default="b64_json"),
101
  stream: bool | None = Form(default=None),
102
  ):
103
- require_identity(authorization)
 
104
  if n < 1 or n > 4:
105
  raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
106
  uploads = [*(image or []), *(image_list or [])]
@@ -117,20 +188,32 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
117
  if not account_service.has_available_account():
118
  raise_image_quota_error(RuntimeError("no available image quota"))
119
  return StreamingResponse(
120
- sse_json_stream(chatgpt_service.stream_image_edit(prompt, images, model, n, size, response_format, base_url)),
 
 
 
 
 
 
 
121
  media_type="text/event-stream",
122
  )
123
  try:
124
- return await run_in_threadpool(
125
  chatgpt_service.edit_with_pool, prompt, images, model, n, size, response_format, base_url
126
  )
 
 
127
  except ImageGenerationError as exc:
 
128
  raise_image_quota_error(exc)
129
 
130
  @router.post("/v1/chat/completions")
131
  async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
132
- require_identity(authorization)
 
133
  payload = body.model_dump(mode="python")
 
134
  if bool(payload.get("stream")):
135
  if is_image_chat_request(payload):
136
  try:
@@ -138,21 +221,35 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
138
  except RuntimeError as exc:
139
  raise_image_quota_error(exc)
140
  return StreamingResponse(
141
- sse_json_stream(chatgpt_service.stream_chat_completion(payload)),
142
  media_type="text/event-stream",
143
  )
144
- return await run_in_threadpool(chatgpt_service.create_chat_completion, payload)
 
 
 
 
 
 
145
 
146
  @router.post("/v1/responses")
147
  async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
148
- require_identity(authorization)
 
149
  payload = body.model_dump(mode="python")
 
150
  if bool(payload.get("stream")):
151
  return StreamingResponse(
152
- sse_json_stream(chatgpt_service.stream_response(payload)),
153
  media_type="text/event-stream",
154
  )
155
- return await run_in_threadpool(chatgpt_service.create_response, payload)
 
 
 
 
 
 
156
 
157
  @router.post("/v1/messages")
158
  async def create_message(
@@ -161,13 +258,21 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
161
  x_api_key: str | None = Header(default=None, alias="x-api-key"),
162
  anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
163
  ):
164
- require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
 
165
  payload = body.model_dump(mode="python")
 
166
  if bool(payload.get("stream")):
167
  return StreamingResponse(
168
- anthropic_sse_stream(chatgpt_service.stream_message(payload)),
169
  media_type="text/event-stream",
170
  )
171
- return await run_in_threadpool(chatgpt_service.create_message, payload)
 
 
 
 
 
 
172
 
173
  return router
 
1
  from __future__ import annotations
2
 
3
+ from datetime import datetime
4
+ import time
5
+
6
  from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
7
  from fastapi.concurrency import run_in_threadpool
8
  from fastapi.responses import StreamingResponse
 
11
  from api.support import raise_image_quota_error, require_identity, resolve_image_base_url
12
  from services.account_service import account_service
13
  from services.chatgpt_service import ChatGPTService, ImageGenerationError
14
+ from services.log_service import (
15
+ LOG_TYPE_CALL,
16
+ log_service,
17
+ )
18
  from utils.helper import anthropic_sse_stream, is_image_chat_request, sse_json_stream
19
 
20
 
 
55
  stream: bool | None = None
56
 
57
 
58
+ def _identity_detail(identity: dict[str, object]) -> dict[str, object]:
59
+ return {"key_id": identity.get("id"), "key_name": identity.get("name"), "role": identity.get("role")}
60
+
61
+
62
+ def _collect_urls(value: object) -> list[str]:
63
+ urls: list[str] = []
64
+ if isinstance(value, dict):
65
+ for key, item in value.items():
66
+ if key == "url" and isinstance(item, str):
67
+ urls.append(item)
68
+ elif key == "urls" and isinstance(item, list):
69
+ urls.extend(str(url) for url in item if isinstance(url, str))
70
+ else:
71
+ urls.extend(_collect_urls(item))
72
+ elif isinstance(value, list):
73
+ for item in value:
74
+ urls.extend(_collect_urls(item))
75
+ return urls
76
+
77
+
78
+ def _log_call(summary: str, identity: dict[str, object], endpoint: str, model: str, started: float, result: object = None, status: str = "success") -> None:
79
+ detail = {
80
+ **_identity_detail(identity),
81
+ "endpoint": endpoint,
82
+ "model": model,
83
+ "started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
84
+ "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
85
+ "duration_ms": int((time.time() - started) * 1000),
86
+ "status": status,
87
+ }
88
+ urls = _collect_urls(result)
89
+ if urls:
90
+ detail["urls"] = urls
91
+ log_service.add(LOG_TYPE_CALL, summary, detail)
92
+
93
+
94
+ def _stream_with_log(items, summary: str, identity: dict[str, object], endpoint: str, model: str, started: float):
95
+ urls: list[str] = []
96
+ failed = False
97
+ try:
98
+ for item in items:
99
+ urls.extend(_collect_urls(item))
100
+ yield item
101
+ except Exception:
102
+ failed = True
103
+ _log_call(summary.replace("结束", "失败"), identity, endpoint, model, started, {"urls": list(dict.fromkeys(urls))}, "failed")
104
+ raise
105
+ finally:
106
+ if not failed:
107
+ _log_call(summary, identity, endpoint, model, started, {"urls": list(dict.fromkeys(urls))})
108
+
109
+
110
  def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
111
  router = APIRouter()
112
 
 
124
  request: Request,
125
  authorization: str | None = Header(default=None),
126
  ):
127
+ identity = require_identity(authorization)
128
+ started = time.time()
129
  base_url = resolve_image_base_url(request)
130
  if body.stream:
131
  try:
 
134
  raise_image_quota_error(exc)
135
  return StreamingResponse(
136
  sse_json_stream(
137
+ _stream_with_log(
138
+ chatgpt_service.stream_image_generation(
139
+ body.prompt, body.model, body.n, body.size, body.response_format, base_url
140
+ ),
141
+ "文生图流式调用结束",
142
+ identity,
143
+ "/v1/images/generations",
144
+ body.model,
145
+ started,
146
  )
147
  ),
148
  media_type="text/event-stream",
149
  )
150
  try:
151
+ result = await run_in_threadpool(
152
  chatgpt_service.generate_with_pool, body.prompt, body.model, body.n, body.size, body.response_format, base_url
153
  )
154
+ _log_call("文生图调用完成", identity, "/v1/images/generations", body.model, started, result)
155
+ return result
156
  except ImageGenerationError as exc:
157
+ _log_call("文生图调用失败", identity, "/v1/images/generations", body.model, started, {"error": str(exc)}, "failed")
158
  raise_image_quota_error(exc)
159
 
160
  @router.post("/v1/images/edits")
 
170
  response_format: str = Form(default="b64_json"),
171
  stream: bool | None = Form(default=None),
172
  ):
173
+ identity = require_identity(authorization)
174
+ started = time.time()
175
  if n < 1 or n > 4:
176
  raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
177
  uploads = [*(image or []), *(image_list or [])]
 
188
  if not account_service.has_available_account():
189
  raise_image_quota_error(RuntimeError("no available image quota"))
190
  return StreamingResponse(
191
+ sse_json_stream(_stream_with_log(
192
+ chatgpt_service.stream_image_edit(prompt, images, model, n, size, response_format, base_url),
193
+ "图生图流式调用结束",
194
+ identity,
195
+ "/v1/images/edits",
196
+ model,
197
+ started,
198
+ )),
199
  media_type="text/event-stream",
200
  )
201
  try:
202
+ result = await run_in_threadpool(
203
  chatgpt_service.edit_with_pool, prompt, images, model, n, size, response_format, base_url
204
  )
205
+ _log_call("图生图调用完成", identity, "/v1/images/edits", model, started, result)
206
+ return result
207
  except ImageGenerationError as exc:
208
+ _log_call("图生图调用失败", identity, "/v1/images/edits", model, started, {"error": str(exc)}, "failed")
209
  raise_image_quota_error(exc)
210
 
211
  @router.post("/v1/chat/completions")
212
  async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
213
+ identity = require_identity(authorization)
214
+ started = time.time()
215
  payload = body.model_dump(mode="python")
216
+ model = str(payload.get("model") or "auto")
217
  if bool(payload.get("stream")):
218
  if is_image_chat_request(payload):
219
  try:
 
221
  except RuntimeError as exc:
222
  raise_image_quota_error(exc)
223
  return StreamingResponse(
224
+ sse_json_stream(_stream_with_log(chatgpt_service.stream_chat_completion(payload), "文本生成流式调用结束", identity, "/v1/chat/completions", model, started)),
225
  media_type="text/event-stream",
226
  )
227
+ try:
228
+ result = await run_in_threadpool(chatgpt_service.create_chat_completion, payload)
229
+ _log_call("文本生成调用完成", identity, "/v1/chat/completions", model, started, result)
230
+ return result
231
+ except Exception as exc:
232
+ _log_call("文本生成调用失败", identity, "/v1/chat/completions", model, started, {"error": str(exc)}, "failed")
233
+ raise
234
 
235
  @router.post("/v1/responses")
236
  async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
237
+ identity = require_identity(authorization)
238
+ started = time.time()
239
  payload = body.model_dump(mode="python")
240
+ model = str(payload.get("model") or "auto")
241
  if bool(payload.get("stream")):
242
  return StreamingResponse(
243
+ sse_json_stream(_stream_with_log(chatgpt_service.stream_response(payload), "Responses 流式调用结束", identity, "/v1/responses", model, started)),
244
  media_type="text/event-stream",
245
  )
246
+ try:
247
+ result = await run_in_threadpool(chatgpt_service.create_response, payload)
248
+ _log_call("Responses 调用完成", identity, "/v1/responses", model, started, result)
249
+ return result
250
+ except Exception as exc:
251
+ _log_call("Responses 调用失败", identity, "/v1/responses", model, started, {"error": str(exc)}, "failed")
252
+ raise
253
 
254
  @router.post("/v1/messages")
255
  async def create_message(
 
258
  x_api_key: str | None = Header(default=None, alias="x-api-key"),
259
  anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
260
  ):
261
+ identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
262
+ started = time.time()
263
  payload = body.model_dump(mode="python")
264
+ model = str(payload.get("model") or "auto")
265
  if bool(payload.get("stream")):
266
  return StreamingResponse(
267
+ anthropic_sse_stream(_stream_with_log(chatgpt_service.stream_message(payload), "Messages 流式调用结束", identity, "/v1/messages", model, started)),
268
  media_type="text/event-stream",
269
  )
270
+ try:
271
+ result = await run_in_threadpool(chatgpt_service.create_message, payload)
272
+ _log_call("Messages 调用完成", identity, "/v1/messages", model, started, result)
273
+ return result
274
+ except Exception as exc:
275
+ _log_call("Messages 调用失败", identity, "/v1/messages", model, started, {"error": str(exc)}, "failed")
276
+ raise
277
 
278
  return router
api/app.py CHANGED
@@ -23,6 +23,7 @@ def create_app() -> FastAPI:
23
  async def lifespan(_: FastAPI):
24
  stop_event = Event()
25
  thread = start_limited_account_watcher(stop_event)
 
26
  try:
27
  yield
28
  finally:
 
23
  async def lifespan(_: FastAPI):
24
  stop_event = Event()
25
  thread = start_limited_account_watcher(stop_event)
26
+ config.cleanup_old_images()
27
  try:
28
  yield
29
  finally:
api/system.py CHANGED
@@ -1,11 +1,13 @@
1
  from __future__ import annotations
2
 
3
- from fastapi import APIRouter, Header, HTTPException
4
  from fastapi.concurrency import run_in_threadpool
5
  from pydantic import BaseModel, ConfigDict
6
 
7
- from api.support import require_admin, require_identity
8
  from services.config import config
 
 
9
  from services.proxy_service import test_proxy
10
 
11
 
@@ -45,6 +47,16 @@ def create_router(app_version: str) -> APIRouter:
45
  require_admin(authorization)
46
  return {"config": config.update(body.model_dump(mode="python"))}
47
 
 
 
 
 
 
 
 
 
 
 
48
  @router.post("/api/proxy/test")
49
  async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
50
  require_admin(authorization)
 
1
  from __future__ import annotations
2
 
3
+ from fastapi import APIRouter, Header, HTTPException, Request
4
  from fastapi.concurrency import run_in_threadpool
5
  from pydantic import BaseModel, ConfigDict
6
 
7
+ from api.support import require_admin, require_identity, resolve_image_base_url
8
  from services.config import config
9
+ from services.image_service import list_images
10
+ from services.log_service import log_service
11
  from services.proxy_service import test_proxy
12
 
13
 
 
47
  require_admin(authorization)
48
  return {"config": config.update(body.model_dump(mode="python"))}
49
 
50
+ @router.get("/api/images")
51
+ async def get_images(request: Request, start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
52
+ require_admin(authorization)
53
+ return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip())
54
+
55
+ @router.get("/api/logs")
56
+ async def get_logs(type: str = "", start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
57
+ require_admin(authorization)
58
+ return {"items": log_service.list(type=type.strip(), start_date=start_date.strip(), end_date=end_date.strip())}
59
+
60
  @router.post("/api/proxy/test")
61
  async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
62
  require_admin(authorization)
config.json CHANGED
@@ -1,6 +1,8 @@
1
  {
2
  "auth-key": "chatgpt2api",
3
  "refresh_account_interval_minute": 60,
 
 
4
  "proxy": "",
5
  "base_url": ""
6
  }
 
1
  {
2
  "auth-key": "chatgpt2api",
3
  "refresh_account_interval_minute": 60,
4
+ "image_retention_days": 15,
5
+ "auto_remove_invalid_accounts": false,
6
  "proxy": "",
7
  "base_url": ""
8
  }
services/account_service.py CHANGED
@@ -12,6 +12,10 @@ from datetime import datetime
12
  from curl_cffi.requests import Session
13
 
14
  from services.config import config
 
 
 
 
15
  from services.proxy_service import proxy_settings
16
  from services.storage.base import StorageBackend
17
  from utils.helper import anonymize_token
@@ -248,6 +252,9 @@ class AccountService:
248
  message = str(exc)
249
  print(f"[account-available] refresh token={token_ref} fail {message}")
250
  if "/backend-api/me failed: HTTP 401" in message:
 
 
 
251
  return self.update_account(
252
  access_token,
253
  {
@@ -331,6 +338,7 @@ class AccountService:
331
  self._accounts = list(indexed.values())
332
  self._save_accounts()
333
  items = self._public_items(self._accounts)
 
334
  return {"added": added, "skipped": skipped, "items": items}
335
 
336
  def delete_accounts(self, tokens: list[str]) -> dict:
@@ -348,6 +356,7 @@ class AccountService:
348
  self._index = 0
349
  if removed:
350
  self._save_accounts()
 
351
  items = self._public_items(self._accounts)
352
  return {"removed": removed, "items": items}
353
 
@@ -367,6 +376,7 @@ class AccountService:
367
  return None
368
  self._accounts[index] = account
369
  self._save_accounts()
 
370
  return dict(account)
371
  return None
372
 
@@ -495,13 +505,10 @@ class AccountService:
495
  message = str(exc)
496
  print(f"[account-refresh] fail {anonymize_token(access_token)} {message}")
497
  if "/backend-api/me failed: HTTP 401" in message:
498
- self.update_account(
499
- access_token,
500
- {
501
- "status": "异常",
502
- "quota": 0,
503
- },
504
- )
505
  message = "检测到封号"
506
  errors.append({"access_token": access_token, "error": message})
507
 
 
12
  from curl_cffi.requests import Session
13
 
14
  from services.config import config
15
+ from services.log_service import (
16
+ LOG_TYPE_ACCOUNT,
17
+ log_service,
18
+ )
19
  from services.proxy_service import proxy_settings
20
  from services.storage.base import StorageBackend
21
  from utils.helper import anonymize_token
 
252
  message = str(exc)
253
  print(f"[account-available] refresh token={token_ref} fail {message}")
254
  if "/backend-api/me failed: HTTP 401" in message:
255
+ if config.auto_remove_invalid_accounts and self.remove_token(access_token):
256
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": "refresh_account_state", "token": token_ref})
257
+ return None
258
  return self.update_account(
259
  access_token,
260
  {
 
338
  self._accounts = list(indexed.values())
339
  self._save_accounts()
340
  items = self._public_items(self._accounts)
341
+ log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个", {"added": added, "skipped": skipped})
342
  return {"added": added, "skipped": skipped, "items": items}
343
 
344
  def delete_accounts(self, tokens: list[str]) -> dict:
 
356
  self._index = 0
357
  if removed:
358
  self._save_accounts()
359
+ log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
360
  items = self._public_items(self._accounts)
361
  return {"removed": removed, "items": items}
362
 
 
376
  return None
377
  self._accounts[index] = account
378
  self._save_accounts()
379
+ log_service.add(LOG_TYPE_ACCOUNT, "更新账号", {"token": anonymize_token(access_token), "status": account.get("status")})
380
  return dict(account)
381
  return None
382
 
 
505
  message = str(exc)
506
  print(f"[account-refresh] fail {anonymize_token(access_token)} {message}")
507
  if "/backend-api/me failed: HTTP 401" in message:
508
+ if config.auto_remove_invalid_accounts and self.remove_token(access_token):
509
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": "refresh_accounts", "token": anonymize_token(access_token)})
510
+ else:
511
+ self.update_account(access_token, {"status": "异常", "quota": 0})
 
 
 
512
  message = "检测到封号"
513
  errors.append({"access_token": access_token, "error": message})
514
 
services/chatgpt_service.py CHANGED
@@ -13,9 +13,11 @@ from fastapi import HTTPException
13
  from services.account_service import AccountService
14
  from services.anthropic_protocol import preprocess_payload
15
  from services.config import config
 
16
  from services.openai_backend_api import CODEX_IMAGE_MODEL, OpenAIBackendAPI
17
  from utils.helper import (
18
  IMAGE_MODELS,
 
19
  extract_chat_image,
20
  extract_chat_prompt,
21
  extract_image_from_message_content,
@@ -43,6 +45,7 @@ def is_token_invalid_error(message: str) -> bool:
43
 
44
 
45
  def _save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
 
46
  file_hash = hashlib.md5(image_data).hexdigest()
47
  timestamp = int(time.time())
48
  filename = f"{timestamp}_{file_hash}.png"
@@ -348,7 +351,7 @@ class ChatGPTService:
348
  raise HTTPException(status_code=502, detail={"error": "image generation failed"})
349
 
350
  created = int(image_result.get("created") or time.time())
351
- return {
352
  "id": f"resp_{created}",
353
  "object": "response",
354
  "created_at": created,
@@ -359,6 +362,7 @@ class ChatGPTService:
359
  "output": output,
360
  "parallel_tool_calls": False,
361
  }
 
362
 
363
  def _stream_token_image_response(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
364
  prompt = extract_response_prompt(body.get("input"))
@@ -453,7 +457,8 @@ class ChatGPTService:
453
  b64_json = str(item.get("b64_json") or "").strip()
454
  if response_format == "b64_json":
455
  if b64_json:
456
- formatted_items.append({"b64_json": b64_json, "revised_prompt": revised_prompt})
 
457
  continue
458
  if not b64_json:
459
  continue
@@ -462,6 +467,14 @@ class ChatGPTService:
462
  {"url": _save_image_bytes(image_data, base_url), "revised_prompt": revised_prompt})
463
  return {"created": created, "data": formatted_items}
464
 
 
 
 
 
 
 
 
 
465
  @staticmethod
466
  def _extract_image_data_urls(markdown_content: str) -> list[str]:
467
  return re.findall(r"!\[[^\]]*\]\((data:image/[^;]+;base64,[^)]+)\)", markdown_content or "")
@@ -639,7 +652,7 @@ class ChatGPTService:
639
  "status": account.get("status") if account else "unknown",
640
  })
641
  if is_token_invalid_error(message):
642
- self.account_service.remove_token(request_token)
643
  logger.warning({
644
  "event": "image_generate_remove_invalid_token",
645
  "request_token": request_token,
@@ -660,10 +673,11 @@ class ChatGPTService:
660
  data = result.get("data")
661
  if isinstance(data, list):
662
  image_items.extend(item for item in data if isinstance(item, dict))
663
- return {
664
  "created": created,
665
  "data": image_items,
666
  }
 
667
 
668
  def stream_image_generation(
669
  self,
@@ -742,7 +756,7 @@ class ChatGPTService:
742
  "status": account.get("status") if account else "unknown",
743
  })
744
  if not emitted_for_request and is_token_invalid_error(message):
745
- self.account_service.remove_token(request_token)
746
  logger.warning({
747
  "event": "image_generate_stream_remove_invalid_token",
748
  "request_token": request_token,
@@ -822,7 +836,7 @@ class ChatGPTService:
822
  "status": account.get("status") if account else "unknown",
823
  })
824
  if is_token_invalid_error(message):
825
- self.account_service.remove_token(request_token)
826
  logger.warning({
827
  "event": "image_edit_remove_invalid_token",
828
  "request_token": request_token,
@@ -833,10 +847,11 @@ class ChatGPTService:
833
  if not image_items:
834
  raise ImageGenerationError(last_error or "image edit failed")
835
 
836
- return {
837
  "created": created,
838
  "data": image_items,
839
  }
 
840
 
841
  def stream_image_edit(
842
  self,
@@ -923,7 +938,7 @@ class ChatGPTService:
923
  "status": account.get("status") if account else "unknown",
924
  })
925
  if not emitted_for_request and is_token_invalid_error(message):
926
- self.account_service.remove_token(request_token)
927
  logger.warning({
928
  "event": "image_edit_stream_remove_invalid_token",
929
  "request_token": request_token,
@@ -1044,7 +1059,7 @@ class ChatGPTService:
1044
  "status": account.get("status") if account else "unknown",
1045
  })
1046
  if not emitted and is_token_invalid_error(message):
1047
- self.account_service.remove_token(request_token)
1048
  logger.warning({
1049
  "event": "image_stream_remove_invalid_token",
1050
  "request_token": request_token,
@@ -1056,7 +1071,8 @@ class ChatGPTService:
1056
  model = str(body.get("model") or "auto").strip() or "auto"
1057
  messages = self._chat_messages_from_body(body)
1058
  try:
1059
- return self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=False)
 
1060
  except Exception as exc:
1061
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1062
 
@@ -1082,13 +1098,14 @@ class ChatGPTService:
1082
  model = str(body.get("model") or "auto").strip() or "auto"
1083
  messages = self._chat_messages_from_body(body)
1084
  try:
1085
- return self._new_backend(self._get_text_access_token()).messages(
1086
  messages=messages,
1087
  model=model,
1088
  stream=False,
1089
  system=body.get("system"),
1090
  tools=body.get("tools"),
1091
  )
 
1092
  except Exception as exc:
1093
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1094
 
@@ -1141,12 +1158,13 @@ class ChatGPTService:
1141
 
1142
  def create_response(self, body: dict[str, object]) -> dict[str, object]:
1143
  if self._is_text_response_request(body):
1144
- return self._create_text_response(body)
 
1145
  if not self._is_codex_image_response_request(body):
1146
  return self._create_token_image_response(body)
1147
  try:
1148
  access_token = self._get_response_access_token(body)
1149
- return self._new_backend(access_token).responses(
1150
  input=body.get("input") or "",
1151
  model=str(body.get("model") or "gpt-5.4").strip() or "gpt-5.4",
1152
  tools=body.get("tools") if isinstance(body.get("tools"), list) else None,
@@ -1155,5 +1173,6 @@ class ChatGPTService:
1155
  stream=False,
1156
  store=bool(body.get("store")),
1157
  )
 
1158
  except Exception as exc:
1159
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
 
13
  from services.account_service import AccountService
14
  from services.anthropic_protocol import preprocess_payload
15
  from services.config import config
16
+ from services.log_service import LOG_TYPE_ACCOUNT, log_service
17
  from services.openai_backend_api import CODEX_IMAGE_MODEL, OpenAIBackendAPI
18
  from utils.helper import (
19
  IMAGE_MODELS,
20
+ anonymize_token,
21
  extract_chat_image,
22
  extract_chat_prompt,
23
  extract_image_from_message_content,
 
45
 
46
 
47
  def _save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
48
+ config.cleanup_old_images()
49
  file_hash = hashlib.md5(image_data).hexdigest()
50
  timestamp = int(time.time())
51
  filename = f"{timestamp}_{file_hash}.png"
 
351
  raise HTTPException(status_code=502, detail={"error": "image generation failed"})
352
 
353
  created = int(image_result.get("created") or time.time())
354
+ response = {
355
  "id": f"resp_{created}",
356
  "object": "response",
357
  "created_at": created,
 
362
  "output": output,
363
  "parallel_tool_calls": False,
364
  }
365
+ return response
366
 
367
  def _stream_token_image_response(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
368
  prompt = extract_response_prompt(body.get("input"))
 
457
  b64_json = str(item.get("b64_json") or "").strip()
458
  if response_format == "b64_json":
459
  if b64_json:
460
+ image_url = _save_image_bytes(base64.b64decode(b64_json), base_url)
461
+ formatted_items.append({"b64_json": b64_json, "url": image_url, "revised_prompt": revised_prompt})
462
  continue
463
  if not b64_json:
464
  continue
 
467
  {"url": _save_image_bytes(image_data, base_url), "revised_prompt": revised_prompt})
468
  return {"created": created, "data": formatted_items}
469
 
470
+ def _remove_invalid_token(self, access_token: str, event: str) -> bool:
471
+ if not config.auto_remove_invalid_accounts:
472
+ return False
473
+ removed = self.account_service.remove_token(access_token)
474
+ if removed:
475
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": event, "token": anonymize_token(access_token)})
476
+ return removed
477
+
478
  @staticmethod
479
  def _extract_image_data_urls(markdown_content: str) -> list[str]:
480
  return re.findall(r"!\[[^\]]*\]\((data:image/[^;]+;base64,[^)]+)\)", markdown_content or "")
 
652
  "status": account.get("status") if account else "unknown",
653
  })
654
  if is_token_invalid_error(message):
655
+ self._remove_invalid_token(request_token, "image_generate")
656
  logger.warning({
657
  "event": "image_generate_remove_invalid_token",
658
  "request_token": request_token,
 
673
  data = result.get("data")
674
  if isinstance(data, list):
675
  image_items.extend(item for item in data if isinstance(item, dict))
676
+ result = {
677
  "created": created,
678
  "data": image_items,
679
  }
680
+ return result
681
 
682
  def stream_image_generation(
683
  self,
 
756
  "status": account.get("status") if account else "unknown",
757
  })
758
  if not emitted_for_request and is_token_invalid_error(message):
759
+ self._remove_invalid_token(request_token, "image_generate_stream")
760
  logger.warning({
761
  "event": "image_generate_stream_remove_invalid_token",
762
  "request_token": request_token,
 
836
  "status": account.get("status") if account else "unknown",
837
  })
838
  if is_token_invalid_error(message):
839
+ self._remove_invalid_token(request_token, "image_edit")
840
  logger.warning({
841
  "event": "image_edit_remove_invalid_token",
842
  "request_token": request_token,
 
847
  if not image_items:
848
  raise ImageGenerationError(last_error or "image edit failed")
849
 
850
+ result = {
851
  "created": created,
852
  "data": image_items,
853
  }
854
+ return result
855
 
856
  def stream_image_edit(
857
  self,
 
938
  "status": account.get("status") if account else "unknown",
939
  })
940
  if not emitted_for_request and is_token_invalid_error(message):
941
+ self._remove_invalid_token(request_token, "image_edit_stream")
942
  logger.warning({
943
  "event": "image_edit_stream_remove_invalid_token",
944
  "request_token": request_token,
 
1059
  "status": account.get("status") if account else "unknown",
1060
  })
1061
  if not emitted and is_token_invalid_error(message):
1062
+ self._remove_invalid_token(request_token, "image_stream")
1063
  logger.warning({
1064
  "event": "image_stream_remove_invalid_token",
1065
  "request_token": request_token,
 
1071
  model = str(body.get("model") or "auto").strip() or "auto"
1072
  messages = self._chat_messages_from_body(body)
1073
  try:
1074
+ result = self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=False)
1075
+ return result
1076
  except Exception as exc:
1077
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1078
 
 
1098
  model = str(body.get("model") or "auto").strip() or "auto"
1099
  messages = self._chat_messages_from_body(body)
1100
  try:
1101
+ result = self._new_backend(self._get_text_access_token()).messages(
1102
  messages=messages,
1103
  model=model,
1104
  stream=False,
1105
  system=body.get("system"),
1106
  tools=body.get("tools"),
1107
  )
1108
+ return result
1109
  except Exception as exc:
1110
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1111
 
 
1158
 
1159
  def create_response(self, body: dict[str, object]) -> dict[str, object]:
1160
  if self._is_text_response_request(body):
1161
+ result = self._create_text_response(body)
1162
+ return result
1163
  if not self._is_codex_image_response_request(body):
1164
  return self._create_token_image_response(body)
1165
  try:
1166
  access_token = self._get_response_access_token(body)
1167
+ result = self._new_backend(access_token).responses(
1168
  input=body.get("input") or "",
1169
  model=str(body.get("model") or "gpt-5.4").strip() or "gpt-5.4",
1170
  tools=body.get("tools") if isinstance(body.get("tools"), list) else None,
 
1173
  stream=False,
1174
  store=bool(body.get("store")),
1175
  )
1176
+ return result
1177
  except Exception as exc:
1178
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
services/config.py CHANGED
@@ -5,6 +5,7 @@ import json
5
  import os
6
  import sys
7
  from pathlib import Path
 
8
 
9
  from services.storage.base import StorageBackend
10
 
@@ -102,12 +103,40 @@ class ConfigStore:
102
  except (TypeError, ValueError):
103
  return 5
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  @property
106
  def images_dir(self) -> Path:
107
  path = DATA_DIR / "images"
108
  path.mkdir(parents=True, exist_ok=True)
109
  return path
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  @property
112
  def base_url(self) -> str:
113
  return str(
@@ -126,6 +155,9 @@ class ConfigStore:
126
 
127
  def get(self) -> dict[str, object]:
128
  data = dict(self.data)
 
 
 
129
  data.pop("auth-key", None)
130
  return data
131
 
 
5
  import os
6
  import sys
7
  from pathlib import Path
8
+ import time
9
 
10
  from services.storage.base import StorageBackend
11
 
 
103
  except (TypeError, ValueError):
104
  return 5
105
 
106
+ @property
107
+ def image_retention_days(self) -> int:
108
+ try:
109
+ return max(1, int(self.data.get("image_retention_days", 30)))
110
+ except (TypeError, ValueError):
111
+ return 30
112
+
113
+ @property
114
+ def auto_remove_invalid_accounts(self) -> bool:
115
+ value = self.data.get("auto_remove_invalid_accounts", False)
116
+ if isinstance(value, str):
117
+ return value.strip().lower() in {"1", "true", "yes", "on"}
118
+ return bool(value)
119
+
120
  @property
121
  def images_dir(self) -> Path:
122
  path = DATA_DIR / "images"
123
  path.mkdir(parents=True, exist_ok=True)
124
  return path
125
 
126
+ def cleanup_old_images(self) -> int:
127
+ cutoff = time.time() - self.image_retention_days * 86400
128
+ removed = 0
129
+ for path in self.images_dir.rglob("*"):
130
+ if path.is_file() and path.stat().st_mtime < cutoff:
131
+ path.unlink()
132
+ removed += 1
133
+ for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
134
+ try:
135
+ path.rmdir()
136
+ except OSError:
137
+ pass
138
+ return removed
139
+
140
  @property
141
  def base_url(self) -> str:
142
  return str(
 
155
 
156
  def get(self) -> dict[str, object]:
157
  data = dict(self.data)
158
+ data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
159
+ data["image_retention_days"] = self.image_retention_days
160
+ data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
161
  data.pop("auth-key", None)
162
  return data
163
 
services/image_service.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from services.config import config
6
+
7
+
8
+ def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
9
+ config.cleanup_old_images()
10
+ items = []
11
+ root = config.images_dir
12
+ for path in root.rglob("*"):
13
+ if not path.is_file():
14
+ continue
15
+ rel = path.relative_to(root).as_posix()
16
+ parts = rel.split("/")
17
+ day = "-".join(parts[:3]) if len(parts) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d")
18
+ if start_date and day < start_date:
19
+ continue
20
+ if end_date and day > end_date:
21
+ continue
22
+ items.append({
23
+ "name": path.name,
24
+ "date": day,
25
+ "size": path.stat().st_size,
26
+ "url": f"{base_url.rstrip('/')}/images/{rel}",
27
+ "created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
28
+ })
29
+ items.sort(key=lambda item: str(item["created_at"]), reverse=True)
30
+ groups: dict[str, list[dict[str, object]]] = {}
31
+ for item in items:
32
+ groups.setdefault(str(item["date"]), []).append(item)
33
+ return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]}
services/log_service.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from services.config import DATA_DIR
9
+
10
+ LOG_TYPE_CALL = "call"
11
+ LOG_TYPE_ACCOUNT = "account"
12
+
13
+ class LogService:
14
+ def __init__(self, path: Path):
15
+ self.path = path
16
+ self.path.parent.mkdir(parents=True, exist_ok=True)
17
+
18
+ def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
19
+ item = {
20
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
21
+ "type": type,
22
+ "summary": summary,
23
+ "detail": detail or data,
24
+ }
25
+ with self.path.open("a", encoding="utf-8") as file:
26
+ file.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n")
27
+
28
+ def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
29
+ if not self.path.exists():
30
+ return []
31
+ items: list[dict[str, Any]] = []
32
+ for line in reversed(self.path.read_text(encoding="utf-8").splitlines()):
33
+ try:
34
+ item = json.loads(line)
35
+ except Exception:
36
+ continue
37
+ t = str(item.get("time") or "")
38
+ day = t[:10]
39
+ if type and item.get("type") != type:
40
+ continue
41
+ if start_date and day < start_date:
42
+ continue
43
+ if end_date and day > end_date:
44
+ continue
45
+ items.append(item)
46
+ if len(items) >= limit:
47
+ break
48
+ return items
49
+
50
+
51
+ log_service = LogService(DATA_DIR / "logs.jsonl")
services/openai_backend_api.py CHANGED
@@ -634,6 +634,7 @@ class OpenAIBackendAPI:
634
  return data.get("download_url") or data.get("url") or ""
635
 
636
  def _save_image_bytes(self, image_data: bytes) -> str:
 
637
  file_name = f"{int(time.time())}_{new_uuid().replace('-', '')}.png"
638
  relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
639
  file_path = config.images_dir / relative_dir / file_name
 
634
  return data.get("download_url") or data.get("url") or ""
635
 
636
  def _save_image_bytes(self, image_data: bytes) -> str:
637
+ config.cleanup_old_images()
638
  file_name = f"{int(time.time())}_{new_uuid().replace('-', '')}.png"
639
  relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
640
  file_path = config.images_dir / relative_dir / file_name
web/bun.lock CHANGED
@@ -7,17 +7,20 @@
7
  "dependencies": {
8
  "@radix-ui/react-checkbox": "^1.3.3",
9
  "@radix-ui/react-dialog": "^1.1.15",
 
10
  "@radix-ui/react-select": "^2.2.6",
11
  "@radix-ui/react-slot": "^1.2.3",
12
  "axios": "^1.9.0",
13
  "class-variance-authority": "^0.7.1",
14
  "clsx": "^2.1.1",
 
15
  "immer": "^10.1.3",
16
  "localforage": "^1.10.0",
17
  "lucide-react": "^0.523.0",
18
  "motion": "^12.38.0",
19
  "next": "16.2.3",
20
  "react": "19.2.5",
 
21
  "react-dom": "19.2.5",
22
  "react-hook-form": "^7.62.0",
23
  "react-medium-image-zoom": "^5.3.0",
@@ -84,6 +87,8 @@
84
 
85
  "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
86
 
 
 
87
  "@emnapi/core": ["@emnapi/core@1.4.3", "https://registry.npmmirror.com/@emnapi/core/-/core-1.4.3.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
88
 
89
  "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
@@ -244,6 +249,8 @@
244
 
245
  "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
246
 
 
 
247
  "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
248
 
249
  "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@@ -280,6 +287,8 @@
280
 
281
  "@swc/helpers": ["@swc/helpers@0.5.15", "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
282
 
 
 
283
  "@tailwindcss/node": ["@tailwindcss/node@4.1.10", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.10.tgz", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.10" } }, "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ=="],
284
 
285
  "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.10.tgz", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.10", "@tailwindcss/oxide-darwin-arm64": "4.1.10", "@tailwindcss/oxide-darwin-x64": "4.1.10", "@tailwindcss/oxide-freebsd-x64": "4.1.10", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", "@tailwindcss/oxide-linux-x64-musl": "4.1.10", "@tailwindcss/oxide-wasm32-wasi": "4.1.10", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q=="],
@@ -496,6 +505,10 @@
496
 
497
  "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
498
 
 
 
 
 
499
  "dayjs": ["dayjs@1.11.13", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
500
 
501
  "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -966,6 +979,8 @@
966
 
967
  "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
968
 
 
 
969
  "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
970
 
971
  "react-hook-form": ["react-hook-form@7.62.0", "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.62.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="],
 
7
  "dependencies": {
8
  "@radix-ui/react-checkbox": "^1.3.3",
9
  "@radix-ui/react-dialog": "^1.1.15",
10
+ "@radix-ui/react-popover": "^1.1.15",
11
  "@radix-ui/react-select": "^2.2.6",
12
  "@radix-ui/react-slot": "^1.2.3",
13
  "axios": "^1.9.0",
14
  "class-variance-authority": "^0.7.1",
15
  "clsx": "^2.1.1",
16
+ "date-fns": "^4.1.0",
17
  "immer": "^10.1.3",
18
  "localforage": "^1.10.0",
19
  "lucide-react": "^0.523.0",
20
  "motion": "^12.38.0",
21
  "next": "16.2.3",
22
  "react": "19.2.5",
23
+ "react-day-picker": "^9.14.0",
24
  "react-dom": "19.2.5",
25
  "react-hook-form": "^7.62.0",
26
  "react-medium-image-zoom": "^5.3.0",
 
87
 
88
  "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
89
 
90
+ "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
91
+
92
  "@emnapi/core": ["@emnapi/core@1.4.3", "https://registry.npmmirror.com/@emnapi/core/-/core-1.4.3.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
93
 
94
  "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
 
249
 
250
  "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
251
 
252
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
253
+
254
  "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
255
 
256
  "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
 
287
 
288
  "@swc/helpers": ["@swc/helpers@0.5.15", "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
289
 
290
+ "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "https://registry.npmmirror.com/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
291
+
292
  "@tailwindcss/node": ["@tailwindcss/node@4.1.10", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.10.tgz", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.10" } }, "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ=="],
293
 
294
  "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.10.tgz", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.10", "@tailwindcss/oxide-darwin-arm64": "4.1.10", "@tailwindcss/oxide-darwin-x64": "4.1.10", "@tailwindcss/oxide-freebsd-x64": "4.1.10", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", "@tailwindcss/oxide-linux-x64-musl": "4.1.10", "@tailwindcss/oxide-wasm32-wasi": "4.1.10", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q=="],
 
505
 
506
  "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
507
 
508
+ "date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
509
+
510
+ "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "https://registry.npmmirror.com/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
511
+
512
  "dayjs": ["dayjs@1.11.13", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
513
 
514
  "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
 
979
 
980
  "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
981
 
982
+ "react-day-picker": ["react-day-picker@9.14.0", "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-9.14.0.tgz", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
983
+
984
  "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
985
 
986
  "react-hook-form": ["react-hook-form@7.62.0", "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.62.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="],
web/package.json CHANGED
@@ -11,17 +11,20 @@
11
  "dependencies": {
12
  "@radix-ui/react-checkbox": "^1.3.3",
13
  "@radix-ui/react-dialog": "^1.1.15",
 
14
  "@radix-ui/react-select": "^2.2.6",
15
  "@radix-ui/react-slot": "^1.2.3",
16
  "axios": "^1.9.0",
17
  "class-variance-authority": "^0.7.1",
18
  "clsx": "^2.1.1",
 
19
  "immer": "^10.1.3",
20
  "localforage": "^1.10.0",
21
  "lucide-react": "^0.523.0",
22
  "motion": "^12.38.0",
23
  "next": "16.2.3",
24
  "react": "19.2.5",
 
25
  "react-dom": "19.2.5",
26
  "react-hook-form": "^7.62.0",
27
  "react-medium-image-zoom": "^5.3.0",
 
11
  "dependencies": {
12
  "@radix-ui/react-checkbox": "^1.3.3",
13
  "@radix-ui/react-dialog": "^1.1.15",
14
+ "@radix-ui/react-popover": "^1.1.15",
15
  "@radix-ui/react-select": "^2.2.6",
16
  "@radix-ui/react-slot": "^1.2.3",
17
  "axios": "^1.9.0",
18
  "class-variance-authority": "^0.7.1",
19
  "clsx": "^2.1.1",
20
+ "date-fns": "^4.1.0",
21
  "immer": "^10.1.3",
22
  "localforage": "^1.10.0",
23
  "lucide-react": "^0.523.0",
24
  "motion": "^12.38.0",
25
  "next": "16.2.3",
26
  "react": "19.2.5",
27
+ "react-day-picker": "^9.14.0",
28
  "react-dom": "19.2.5",
29
  "react-hook-form": "^7.62.0",
30
  "react-medium-image-zoom": "^5.3.0",
web/src/app/image-manager/page.tsx ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { CalendarDays, ChevronLeft, ChevronRight, Copy, ImageIcon, LoaderCircle, Maximize2, RefreshCw, Search } from "lucide-react";
5
+ import { toast } from "sonner";
6
+
7
+ import { DateRangeFilter } from "@/components/date-range-filter";
8
+ import { ImageLightbox } from "@/components/image-lightbox";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Card, CardContent } from "@/components/ui/card";
11
+ import { fetchManagedImages, type ManagedImage } from "@/lib/api";
12
+ import { useAuthGuard } from "@/lib/use-auth-guard";
13
+
14
+ function formatSize(size: number) {
15
+ return size > 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(2)} MB` : `${Math.ceil(size / 1024)} KB`;
16
+ }
17
+
18
+ function ImageManagerContent() {
19
+ const [items, setItems] = useState<ManagedImage[]>([]);
20
+ const [startDate, setStartDate] = useState("");
21
+ const [endDate, setEndDate] = useState("");
22
+ const [lightboxIndex, setLightboxIndex] = useState(0);
23
+ const [lightboxOpen, setLightboxOpen] = useState(false);
24
+ const [page, setPage] = useState(1);
25
+ const [dimensions, setDimensions] = useState<Record<string, string>>({});
26
+ const [isLoading, setIsLoading] = useState(true);
27
+ const lightboxImages = items.map((item) => ({
28
+ id: item.name,
29
+ src: item.url,
30
+ sizeLabel: formatSize(item.size),
31
+ dimensions: dimensions[item.url],
32
+ }));
33
+ const pageSize = 12;
34
+ const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
35
+ const safePage = Math.min(page, pageCount);
36
+ const currentRows = items.slice((safePage - 1) * pageSize, safePage * pageSize);
37
+
38
+ const loadImages = async () => {
39
+ setIsLoading(true);
40
+ try {
41
+ const data = await fetchManagedImages({ start_date: startDate, end_date: endDate });
42
+ setItems(data.items);
43
+ setPage(1);
44
+ } catch (error) {
45
+ toast.error(error instanceof Error ? error.message : "加载图片失败");
46
+ } finally {
47
+ setIsLoading(false);
48
+ }
49
+ };
50
+
51
+ const clearFilters = () => {
52
+ setStartDate("");
53
+ setEndDate("");
54
+ };
55
+
56
+ useEffect(() => {
57
+ void loadImages();
58
+ }, [startDate, endDate]);
59
+
60
+ return (
61
+ <section className="space-y-5">
62
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
63
+ <div className="space-y-1">
64
+ <div className="text-xs font-semibold tracking-[0.18em] text-stone-500 uppercase">Images</div>
65
+ <h1 className="text-2xl font-semibold tracking-tight">图片管理</h1>
66
+ </div>
67
+ <div className="flex flex-wrap gap-2">
68
+ <DateRangeFilter startDate={startDate} endDate={endDate} onChange={(start, end) => { setStartDate(start); setEndDate(end); }} />
69
+ <Button variant="outline" onClick={clearFilters} className="h-10 rounded-xl border-stone-200 bg-white px-4 text-stone-700">
70
+ 清除筛选条件
71
+ </Button>
72
+ <Button onClick={() => void loadImages()} disabled={isLoading} className="h-10 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800">
73
+ {isLoading ? <LoaderCircle className="size-4 animate-spin" /> : <Search className="size-4" />}
74
+ 查询
75
+ </Button>
76
+ </div>
77
+ </div>
78
+
79
+ <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
80
+ <CardContent className="p-0">
81
+ <div className="flex items-center justify-between border-b border-stone-100 px-5 py-4">
82
+ <div className="flex items-center gap-2 text-sm text-stone-600">
83
+ <ImageIcon className="size-4" />
84
+ 共 {items.length} 张
85
+ </div>
86
+ <Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-500" onClick={() => void loadImages()} disabled={isLoading}>
87
+ <RefreshCw className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
88
+ 刷新
89
+ </Button>
90
+ </div>
91
+ <div className="grid gap-0 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
92
+ {currentRows.map((item, index) => {
93
+ const imageIndex = items.findIndex((row) => row.url === item.url);
94
+ return (
95
+ <div key={item.url} className="group border-r border-b border-stone-100 p-4 transition hover:bg-stone-50">
96
+ <button
97
+ type="button"
98
+ className="relative block aspect-square w-full cursor-zoom-in overflow-hidden rounded-lg bg-stone-100 text-left"
99
+ onClick={() => {
100
+ setLightboxIndex(imageIndex);
101
+ setLightboxOpen(true);
102
+ }}
103
+ >
104
+ <img
105
+ src={item.url}
106
+ alt={item.name}
107
+ className="h-full w-full object-cover transition group-hover:scale-[1.02]"
108
+ onLoad={(event) => {
109
+ const image = event.currentTarget;
110
+ setDimensions((current) => ({
111
+ ...current,
112
+ [item.url]: `${image.naturalWidth} x ${image.naturalHeight}`,
113
+ }));
114
+ }}
115
+ />
116
+ <span className="absolute right-2 bottom-2 rounded-full bg-black/50 p-2 text-white opacity-0 transition group-hover:opacity-100">
117
+ <Maximize2 className="size-4" />
118
+ </span>
119
+ </button>
120
+ <div className="mt-3 space-y-1 text-xs text-stone-500">
121
+ <div className="flex items-center justify-between gap-2">
122
+ <div className="flex items-center gap-1 font-medium text-stone-700">
123
+ <CalendarDays className="size-3.5" />
124
+ {item.created_at}
125
+ </div>
126
+ <Button
127
+ variant="ghost"
128
+ size="icon"
129
+ className="size-8 rounded-lg text-stone-400 hover:bg-stone-100 hover:text-stone-700"
130
+ onClick={() => {
131
+ void navigator.clipboard.writeText(item.url);
132
+ toast.success("图片地址已复制");
133
+ }}
134
+ >
135
+ <Copy className="size-4" />
136
+ </Button>
137
+ </div>
138
+ <div className="flex items-center justify-between gap-2">
139
+ <span>{formatSize(item.size)}</span>
140
+ <span>{dimensions[item.url] || "-"}</span>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ )})}
145
+ </div>
146
+ <div className="flex items-center justify-end gap-2 border-t border-stone-100 px-4 py-3 text-sm text-stone-500">
147
+ <span>第 {safePage} / {pageCount} 页,共 {items.length} 张</span>
148
+ <Button variant="outline" size="icon" className="size-9 rounded-lg border-stone-200 bg-white" disabled={safePage <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>
149
+ <ChevronLeft className="size-4" />
150
+ </Button>
151
+ <Button variant="outline" size="icon" className="size-9 rounded-lg border-stone-200 bg-white" disabled={safePage >= pageCount} onClick={() => setPage((value) => Math.min(pageCount, value + 1))}>
152
+ <ChevronRight className="size-4" />
153
+ </Button>
154
+ </div>
155
+ {!isLoading && items.length === 0 ? <div className="px-6 py-14 text-center text-sm text-stone-500">没有找到图片</div> : null}
156
+ </CardContent>
157
+ </Card>
158
+ <ImageLightbox
159
+ images={lightboxImages}
160
+ currentIndex={lightboxIndex}
161
+ open={lightboxOpen}
162
+ onOpenChange={setLightboxOpen}
163
+ onIndexChange={setLightboxIndex}
164
+ />
165
+ </section>
166
+ );
167
+ }
168
+
169
+ export default function ImageManagerPage() {
170
+ const { isCheckingAuth, session } = useAuthGuard(["admin"]);
171
+ if (isCheckingAuth || !session || session.role !== "admin") {
172
+ return <div className="flex min-h-[40vh] items-center justify-center"><LoaderCircle className="size-5 animate-spin text-stone-400" /></div>;
173
+ }
174
+ return <ImageManagerContent />;
175
+ }
web/src/app/logs/page.tsx ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ChevronLeft, ChevronRight, LoaderCircle, RefreshCw, Search } from "lucide-react";
5
+ import { toast } from "sonner";
6
+
7
+ import { DateRangeFilter } from "@/components/date-range-filter";
8
+ import { ImageLightbox } from "@/components/image-lightbox";
9
+ import { Badge } from "@/components/ui/badge";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Card, CardContent } from "@/components/ui/card";
12
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
13
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
14
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
15
+ import { fetchSystemLogs, type SystemLog } from "@/lib/api";
16
+ import { useAuthGuard } from "@/lib/use-auth-guard";
17
+
18
+ const LogType = {
19
+ Call: "call",
20
+ Account: "account",
21
+ } as const;
22
+
23
+ const typeLabels: Record<string, string> = {
24
+ [LogType.Call]: "调用日志",
25
+ [LogType.Account]: "账号管理日志",
26
+ };
27
+
28
+ function getDetailText(item: SystemLog, key: string) {
29
+ const value = item.detail?.[key];
30
+ return typeof value === "string" || typeof value === "number" ? String(value) : "-";
31
+ }
32
+
33
+ function formatDuration(item: SystemLog) {
34
+ const value = item.detail?.duration_ms;
35
+ return typeof value === "number" ? `${(value / 1000).toFixed(2)} s` : "-";
36
+ }
37
+
38
+ function getUrls(item: SystemLog | null) {
39
+ const urls = item?.detail?.urls;
40
+ return Array.isArray(urls) ? urls.filter((url): url is string => typeof url === "string") : [];
41
+ }
42
+
43
+ function getStatus(item: SystemLog) {
44
+ const status = item.detail?.status;
45
+ if (status === "success") return "成功";
46
+ if (status === "failed") return "失败";
47
+ return "-";
48
+ }
49
+
50
+ function LogsContent() {
51
+ const [items, setItems] = useState<SystemLog[]>([]);
52
+ const [type, setType] = useState(LogType.Call);
53
+ const [startDate, setStartDate] = useState("");
54
+ const [endDate, setEndDate] = useState("");
55
+ const [detailLog, setDetailLog] = useState<SystemLog | null>(null);
56
+ const [lightboxIndex, setLightboxIndex] = useState(0);
57
+ const [lightboxOpen, setLightboxOpen] = useState(false);
58
+ const [page, setPage] = useState(1);
59
+ const [isLoading, setIsLoading] = useState(true);
60
+ const detailUrls = getUrls(detailLog);
61
+ const detailImages = detailUrls.map((url, index) => ({ id: `${index}`, src: url }));
62
+ const isCallLog = type === LogType.Call;
63
+ const pageSize = 10;
64
+ const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
65
+ const safePage = Math.min(page, pageCount);
66
+ const currentRows = items.slice((safePage - 1) * pageSize, safePage * pageSize);
67
+
68
+ const loadLogs = async () => {
69
+ setIsLoading(true);
70
+ try {
71
+ const data = await fetchSystemLogs({ type, start_date: startDate, end_date: endDate });
72
+ setItems(data.items);
73
+ setPage(1);
74
+ } catch (error) {
75
+ toast.error(error instanceof Error ? error.message : "加载日志失败");
76
+ } finally {
77
+ setIsLoading(false);
78
+ }
79
+ };
80
+
81
+ const clearFilters = () => {
82
+ setStartDate("");
83
+ setEndDate("");
84
+ };
85
+
86
+ useEffect(() => {
87
+ void loadLogs();
88
+ }, [type, startDate, endDate]);
89
+
90
+ return (
91
+ <section className="space-y-5">
92
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
93
+ <div className="space-y-1">
94
+ <div className="text-xs font-semibold tracking-[0.18em] text-stone-500 uppercase">Logs</div>
95
+ <h1 className="text-2xl font-semibold tracking-tight">日志管理</h1>
96
+ </div>
97
+ <div className="flex flex-wrap gap-2">
98
+ <Select value={type} onValueChange={setType}>
99
+ <SelectTrigger className="h-10 w-[150px] rounded-xl border-stone-200 bg-white"><SelectValue /></SelectTrigger>
100
+ <SelectContent>
101
+ <SelectItem value={LogType.Call}>调用日志</SelectItem>
102
+ <SelectItem value={LogType.Account}>账号管理日志</SelectItem>
103
+ </SelectContent>
104
+ </Select>
105
+ <DateRangeFilter startDate={startDate} endDate={endDate} onChange={(start, end) => { setStartDate(start); setEndDate(end); }} />
106
+ <Button variant="outline" onClick={clearFilters} className="h-10 rounded-xl border-stone-200 bg-white px-4 text-stone-700">
107
+ 清除筛选条件
108
+ </Button>
109
+ <Button onClick={() => void loadLogs()} disabled={isLoading} className="h-10 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800">
110
+ {isLoading ? <LoaderCircle className="size-4 animate-spin" /> : <Search className="size-4" />}
111
+ 查询
112
+ </Button>
113
+ </div>
114
+ </div>
115
+
116
+ <Card className="overflow-hidden rounded-2xl border-white/80 bg-white/90 shadow-sm">
117
+ <CardContent className="p-0">
118
+ <div className="flex items-center justify-between border-b border-stone-100 px-5 py-4 text-sm text-stone-600">
119
+ <span>共 {items.length} 条</span>
120
+ <Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-500" onClick={() => void loadLogs()} disabled={isLoading}>
121
+ <RefreshCw className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
122
+ 刷新
123
+ </Button>
124
+ </div>
125
+ <div className="overflow-x-auto">
126
+ <Table className="min-w-[820px]">
127
+ <TableHeader>
128
+ <TableRow>
129
+ <TableHead>时间</TableHead>
130
+ <TableHead>类型</TableHead>
131
+ {isCallLog ? <TableHead>令牌名称</TableHead> : null}
132
+ {isCallLog ? <TableHead>调用耗时</TableHead> : null}
133
+ {isCallLog ? <TableHead>状态</TableHead> : null}
134
+ <TableHead>简述</TableHead>
135
+ <TableHead className="w-28">详情</TableHead>
136
+ </TableRow>
137
+ </TableHeader>
138
+ <TableBody>
139
+ {currentRows.map((item, index) => (
140
+ <TableRow key={`${item.time}-${index}`} className="text-stone-600">
141
+ <TableCell className="whitespace-nowrap">{item.time}</TableCell>
142
+ <TableCell><Badge variant="secondary" className="rounded-md">{typeLabels[item.type] || item.type}</Badge></TableCell>
143
+ {isCallLog ? <TableCell>{getDetailText(item, "key_name")}</TableCell> : null}
144
+ {isCallLog ? <TableCell>{formatDuration(item)}</TableCell> : null}
145
+ {isCallLog ? (
146
+ <TableCell>
147
+ <Badge variant={item.detail?.status === "failed" ? "danger" : "success"} className="rounded-md">
148
+ {getStatus(item)}
149
+ </Badge>
150
+ </TableCell>
151
+ ) : null}
152
+ <TableCell className="max-w-[420px] truncate text-stone-500">{item.summary || "-"}</TableCell>
153
+ <TableCell>
154
+ <Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-600" onClick={() => setDetailLog(item)}>
155
+ 查看详情
156
+ </Button>
157
+ </TableCell>
158
+ </TableRow>
159
+ ))}
160
+ </TableBody>
161
+ </Table>
162
+ </div>
163
+ <div className="flex items-center justify-end gap-2 border-t border-stone-100 px-4 py-3 text-sm text-stone-500">
164
+ <span>第 {safePage} / {pageCount} 页,共 {items.length} 条</span>
165
+ <Button variant="outline" size="icon" className="size-9 rounded-lg border-stone-200 bg-white" disabled={safePage <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>
166
+ <ChevronLeft className="size-4" />
167
+ </Button>
168
+ <Button variant="outline" size="icon" className="size-9 rounded-lg border-stone-200 bg-white" disabled={safePage >= pageCount} onClick={() => setPage((value) => Math.min(pageCount, value + 1))}>
169
+ <ChevronRight className="size-4" />
170
+ </Button>
171
+ </div>
172
+ {!isLoading && items.length === 0 ? <div className="px-6 py-14 text-center text-sm text-stone-500">没有找到日志</div> : null}
173
+ </CardContent>
174
+ </Card>
175
+ <Dialog open={Boolean(detailLog)} onOpenChange={(open) => (!open ? setDetailLog(null) : null)}>
176
+ <DialogContent className="w-[min(92vw,920px)] rounded-2xl p-6">
177
+ <DialogHeader>
178
+ <DialogTitle>日志详情</DialogTitle>
179
+ </DialogHeader>
180
+ <div className="grid gap-3 rounded-xl border border-stone-200 bg-white p-4 text-sm text-stone-600 md:grid-cols-2">
181
+ {Object.entries(detailLog?.detail || {})
182
+ .filter(([key, value]) => key !== "urls" && typeof value !== "object")
183
+ .map(([key, value]) => (
184
+ <div key={key} className="flex items-start justify-between gap-4">
185
+ <span className="text-stone-400">{key}</span>
186
+ <span className="text-right font-medium text-stone-700">{String(value)}</span>
187
+ </div>
188
+ ))}
189
+ </div>
190
+ {detailUrls.length ? (
191
+ <div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
192
+ {detailUrls.map((url, index) => (
193
+ <button
194
+ key={url}
195
+ type="button"
196
+ className="aspect-square overflow-hidden rounded-xl border border-stone-200 bg-stone-100"
197
+ onClick={() => {
198
+ setLightboxIndex(index);
199
+ setLightboxOpen(true);
200
+ }}
201
+ >
202
+ <img src={url} alt="" className="h-full w-full object-cover" />
203
+ </button>
204
+ ))}
205
+ </div>
206
+ ) : null}
207
+ <pre className="max-h-[72vh] overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-4 text-xs leading-6 text-stone-700">
208
+ {JSON.stringify(detailLog?.detail || {}, null, 2)}
209
+ </pre>
210
+ </DialogContent>
211
+ </Dialog>
212
+ <ImageLightbox
213
+ images={detailImages}
214
+ currentIndex={lightboxIndex}
215
+ open={lightboxOpen}
216
+ onOpenChange={setLightboxOpen}
217
+ onIndexChange={setLightboxIndex}
218
+ />
219
+ </section>
220
+ );
221
+ }
222
+
223
+ export default function LogsPage() {
224
+ const { isCheckingAuth, session } = useAuthGuard(["admin"]);
225
+ if (isCheckingAuth || !session || session.role !== "admin") {
226
+ return <div className="flex min-h-[40vh] items-center justify-center"><LoaderCircle className="size-5 animate-spin text-stone-400" /></div>;
227
+ }
228
+ return <LogsContent />;
229
+ }
web/src/app/settings/components/config-card.tsx CHANGED
@@ -6,6 +6,7 @@ import { toast } from "sonner";
6
 
7
  import { Button } from "@/components/ui/button";
8
  import { Card, CardContent } from "@/components/ui/card";
 
9
  import { Input } from "@/components/ui/input";
10
  import { testProxy, type ProxyTestResult } from "@/lib/api";
11
 
@@ -18,6 +19,8 @@ export function ConfigCard() {
18
  const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
19
  const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
20
  const setRefreshAccountIntervalMinute = useSettingsStore((state) => state.setRefreshAccountIntervalMinute);
 
 
21
  const setProxy = useSettingsStore((state) => state.setProxy);
22
  const setBaseUrl = useSettingsStore((state) => state.setBaseUrl);
23
  const saveConfig = useSettingsStore((state) => state.saveConfig);
@@ -120,6 +123,23 @@ export function ConfigCard() {
120
  />
121
  <p className="text-xs text-stone-500">用于生成图片结果的访问前缀地址。</p>
122
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  </div>
124
 
125
  <div className="flex justify-end">
 
6
 
7
  import { Button } from "@/components/ui/button";
8
  import { Card, CardContent } from "@/components/ui/card";
9
+ import { Checkbox } from "@/components/ui/checkbox";
10
  import { Input } from "@/components/ui/input";
11
  import { testProxy, type ProxyTestResult } from "@/lib/api";
12
 
 
19
  const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
20
  const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
21
  const setRefreshAccountIntervalMinute = useSettingsStore((state) => state.setRefreshAccountIntervalMinute);
22
+ const setImageRetentionDays = useSettingsStore((state) => state.setImageRetentionDays);
23
+ const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
24
  const setProxy = useSettingsStore((state) => state.setProxy);
25
  const setBaseUrl = useSettingsStore((state) => state.setBaseUrl);
26
  const saveConfig = useSettingsStore((state) => state.saveConfig);
 
123
  />
124
  <p className="text-xs text-stone-500">用于生成图片结果的访问前缀地址。</p>
125
  </div>
126
+ <div className="space-y-2">
127
+ <label className="text-sm text-stone-700">图片自动清理</label>
128
+ <Input
129
+ value={String(config?.image_retention_days || "")}
130
+ onChange={(event) => setImageRetentionDays(event.target.value)}
131
+ placeholder="30"
132
+ className="h-10 rounded-xl border-stone-200 bg-white"
133
+ />
134
+ <p className="text-xs text-stone-500">自动删除多少天前的本地图片。</p>
135
+ </div>
136
+ <label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
137
+ <Checkbox
138
+ checked={Boolean(config?.auto_remove_invalid_accounts)}
139
+ onCheckedChange={(checked) => setAutoRemoveInvalidAccounts(Boolean(checked))}
140
+ />
141
+ 自动移除异常账号
142
+ </label>
143
  </div>
144
 
145
  <div className="flex justify-end">
web/src/app/settings/components/user-keys-card.tsx CHANGED
@@ -44,6 +44,7 @@ export function UserKeysCard() {
44
  const [isCreating, setIsCreating] = useState(false);
45
  const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
46
  const [revealedKey, setRevealedKey] = useState("");
 
47
 
48
  const load = async () => {
49
  setIsLoading(true);
@@ -106,14 +107,16 @@ export function UserKeysCard() {
106
  }
107
  };
108
 
109
- const handleDelete = async (item: UserKey) => {
110
- if (!window.confirm(`确认删除用户密钥「${item.name}」吗?`)) {
111
  return;
112
  }
 
113
  setItemPending(item.id, true);
114
  try {
115
  const data = await deleteUserKey(item.id);
116
  setItems(data.items);
 
117
  toast.success("用户密钥已删除");
118
  } catch (error) {
119
  toast.error(error instanceof Error ? error.message : "删除用户密钥失败");
@@ -217,7 +220,7 @@ export function UserKeysCard() {
217
  type="button"
218
  variant="outline"
219
  className="h-9 rounded-xl border-rose-200 bg-white px-4 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
220
- onClick={() => void handleDelete(item)}
221
  disabled={isPending}
222
  >
223
  {isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
@@ -271,6 +274,37 @@ export function UserKeysCard() {
271
  </DialogFooter>
272
  </DialogContent>
273
  </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  </>
275
  );
276
  }
 
44
  const [isCreating, setIsCreating] = useState(false);
45
  const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
46
  const [revealedKey, setRevealedKey] = useState("");
47
+ const [deletingItem, setDeletingItem] = useState<UserKey | null>(null);
48
 
49
  const load = async () => {
50
  setIsLoading(true);
 
107
  }
108
  };
109
 
110
+ const handleDelete = async () => {
111
+ if (!deletingItem) {
112
  return;
113
  }
114
+ const item = deletingItem;
115
  setItemPending(item.id, true);
116
  try {
117
  const data = await deleteUserKey(item.id);
118
  setItems(data.items);
119
+ setDeletingItem(null);
120
  toast.success("用户密钥已删除");
121
  } catch (error) {
122
  toast.error(error instanceof Error ? error.message : "删除用户密钥失败");
 
220
  type="button"
221
  variant="outline"
222
  className="h-9 rounded-xl border-rose-200 bg-white px-4 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
223
+ onClick={() => setDeletingItem(item)}
224
  disabled={isPending}
225
  >
226
  {isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
 
274
  </DialogFooter>
275
  </DialogContent>
276
  </Dialog>
277
+
278
+ <Dialog open={Boolean(deletingItem)} onOpenChange={(open) => (!open ? setDeletingItem(null) : null)}>
279
+ <DialogContent className="rounded-2xl p-6">
280
+ <DialogHeader className="gap-2">
281
+ <DialogTitle>删除用户密钥</DialogTitle>
282
+ <DialogDescription className="text-sm leading-6">
283
+ 确认删除用户密钥「{deletingItem?.name}」吗?删除后该密钥将无法继续调用接口。
284
+ </DialogDescription>
285
+ </DialogHeader>
286
+ <DialogFooter>
287
+ <Button
288
+ type="button"
289
+ variant="secondary"
290
+ className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
291
+ onClick={() => setDeletingItem(null)}
292
+ disabled={deletingItem ? pendingIds.has(deletingItem.id) : false}
293
+ >
294
+ 取消
295
+ </Button>
296
+ <Button
297
+ type="button"
298
+ className="h-10 rounded-xl bg-rose-600 px-5 text-white hover:bg-rose-700"
299
+ onClick={() => void handleDelete()}
300
+ disabled={deletingItem ? pendingIds.has(deletingItem.id) : false}
301
+ >
302
+ {deletingItem && pendingIds.has(deletingItem.id) ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
303
+ 删除
304
+ </Button>
305
+ </DialogFooter>
306
+ </DialogContent>
307
+ </Dialog>
308
  </>
309
  );
310
  }
web/src/app/settings/store.ts CHANGED
@@ -25,6 +25,8 @@ function normalizeConfig(config: SettingsConfig): SettingsConfig {
25
  return {
26
  ...config,
27
  refresh_account_interval_minute: Number(config.refresh_account_interval_minute || 5),
 
 
28
  proxy: typeof config.proxy === "string" ? config.proxy : "",
29
  base_url: typeof config.base_url === "string" ? config.base_url : "",
30
  };
@@ -78,6 +80,8 @@ type SettingsStore = {
78
  loadConfig: () => Promise<void>;
79
  saveConfig: () => Promise<void>;
80
  setRefreshAccountIntervalMinute: (value: string) => void;
 
 
81
  setProxy: (value: string) => void;
82
  setBaseUrl: (value: string) => void;
83
 
@@ -158,6 +162,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
158
  const data = await updateSettingsConfig({
159
  ...config,
160
  refresh_account_interval_minute: Math.max(1, Number(config.refresh_account_interval_minute) || 1),
 
 
161
  proxy: config.proxy.trim(),
162
  base_url: String(config.base_url || "").trim(),
163
  });
@@ -186,6 +192,14 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
186
  });
187
  },
188
 
 
 
 
 
 
 
 
 
189
  setProxy: (value) => {
190
  set((state) => {
191
  if (!state.config) {
 
25
  return {
26
  ...config,
27
  refresh_account_interval_minute: Number(config.refresh_account_interval_minute || 5),
28
+ image_retention_days: Number(config.image_retention_days || 30),
29
+ auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
30
  proxy: typeof config.proxy === "string" ? config.proxy : "",
31
  base_url: typeof config.base_url === "string" ? config.base_url : "",
32
  };
 
80
  loadConfig: () => Promise<void>;
81
  saveConfig: () => Promise<void>;
82
  setRefreshAccountIntervalMinute: (value: string) => void;
83
+ setImageRetentionDays: (value: string) => void;
84
+ setAutoRemoveInvalidAccounts: (value: boolean) => void;
85
  setProxy: (value: string) => void;
86
  setBaseUrl: (value: string) => void;
87
 
 
162
  const data = await updateSettingsConfig({
163
  ...config,
164
  refresh_account_interval_minute: Math.max(1, Number(config.refresh_account_interval_minute) || 1),
165
+ image_retention_days: Math.max(1, Number(config.image_retention_days) || 30),
166
+ auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
167
  proxy: config.proxy.trim(),
168
  base_url: String(config.base_url || "").trim(),
169
  });
 
192
  });
193
  },
194
 
195
+ setImageRetentionDays: (value) => {
196
+ set((state) => state.config ? { config: { ...state.config, image_retention_days: value } } : {});
197
+ },
198
+
199
+ setAutoRemoveInvalidAccounts: (value) => {
200
+ set((state) => state.config ? { config: { ...state.config, auto_remove_invalid_accounts: value } } : {});
201
+ },
202
+
203
  setProxy: (value) => {
204
  set((state) => {
205
  if (!state.config) {
web/src/components/date-range-filter.tsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { format, parse } from "date-fns";
4
+ import { CalendarIcon } from "lucide-react";
5
+ import type { DateRange } from "react-day-picker";
6
+
7
+ import { Button } from "@/components/ui/button";
8
+ import { Calendar } from "@/components/ui/calendar";
9
+ import { Field } from "@/components/ui/field";
10
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
11
+
12
+ type DateRangeFilterProps = {
13
+ startDate: string;
14
+ endDate: string;
15
+ onChange: (startDate: string, endDate: string) => void;
16
+ };
17
+
18
+ export function DateRangeFilter({ startDate, endDate, onChange }: DateRangeFilterProps) {
19
+ const selected: DateRange | undefined = startDate
20
+ ? {
21
+ from: parse(startDate, "yyyy-MM-dd", new Date()),
22
+ to: endDate ? parse(endDate, "yyyy-MM-dd", new Date()) : undefined,
23
+ }
24
+ : undefined;
25
+
26
+ const label = startDate ? `${startDate} 至 ${endDate || startDate}` : "选择日期范围";
27
+
28
+ return (
29
+ <Field className="w-[240px]">
30
+ <Popover>
31
+ <PopoverTrigger asChild>
32
+ <Button variant="outline" className="h-10 justify-start rounded-xl border-stone-200 bg-white px-3 font-normal text-stone-700">
33
+ <CalendarIcon className="size-4 text-stone-400" />
34
+ {label}
35
+ </Button>
36
+ </PopoverTrigger>
37
+ <PopoverContent className="w-auto p-3" align="start">
38
+ <Calendar
39
+ mode="range"
40
+ defaultMonth={selected?.from}
41
+ selected={selected}
42
+ onSelect={(value) => onChange(value?.from ? format(value.from, "yyyy-MM-dd") : "", value?.to ? format(value.to, "yyyy-MM-dd") : "")}
43
+ numberOfMonths={2}
44
+ />
45
+ </PopoverContent>
46
+ </Popover>
47
+ </Field>
48
+ );
49
+ }
web/src/components/image-lightbox.tsx CHANGED
@@ -9,6 +9,8 @@ import { cn } from "@/lib/utils";
9
  type LightboxImage = {
10
  id: string;
11
  src: string;
 
 
12
  };
13
 
14
  type ImageLightboxProps = {
@@ -79,6 +81,11 @@ export function ImageLightbox({
79
 
80
  {/* toolbar */}
81
  <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
 
 
 
 
 
82
  {images.length > 1 && (
83
  <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
84
  {currentIndex + 1} / {images.length}
 
9
  type LightboxImage = {
10
  id: string;
11
  src: string;
12
+ sizeLabel?: string;
13
+ dimensions?: string;
14
  };
15
 
16
  type ImageLightboxProps = {
 
81
 
82
  {/* toolbar */}
83
  <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
84
+ {current.sizeLabel || current.dimensions ? (
85
+ <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
86
+ {[current.sizeLabel, current.dimensions].filter(Boolean).join(" · ")}
87
+ </span>
88
+ ) : null}
89
  {images.length > 1 && (
90
  <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
91
  {currentIndex + 1} / {images.length}
web/src/components/top-nav.tsx CHANGED
@@ -12,6 +12,8 @@ import { cn } from "@/lib/utils";
12
  const adminNavItems = [
13
  { href: "/image", label: "画图" },
14
  { href: "/accounts", label: "号池管理" },
 
 
15
  { href: "/settings", label: "设置" },
16
  ];
17
 
 
12
  const adminNavItems = [
13
  { href: "/image", label: "画图" },
14
  { href: "/accounts", label: "号池管理" },
15
+ { href: "/image-manager", label: "图片管理" },
16
+ { href: "/logs", label: "日志管理" },
17
  { href: "/settings", label: "设置" },
18
  ];
19
 
web/src/components/ui/calendar.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronLeft, ChevronRight } from "lucide-react";
5
+ import { DayPicker } from "react-day-picker";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ function Calendar({
10
+ className,
11
+ classNames,
12
+ showOutsideDays = true,
13
+ ...props
14
+ }: React.ComponentProps<typeof DayPicker>) {
15
+ return (
16
+ <DayPicker
17
+ showOutsideDays={showOutsideDays}
18
+ className={cn("p-1 text-sm", className)}
19
+ classNames={{
20
+ months: "flex flex-col gap-4 sm:flex-row",
21
+ month: "relative",
22
+ month_caption: "flex h-9 items-center justify-center font-medium",
23
+ nav: "absolute inset-x-2 top-2 flex items-center justify-between",
24
+ button_previous: "inline-flex size-8 items-center justify-center rounded-lg hover:bg-stone-100",
25
+ button_next: "inline-flex size-8 items-center justify-center rounded-lg hover:bg-stone-100",
26
+ weekdays: "mt-2 grid grid-cols-7 text-xs text-stone-400",
27
+ weekday: "flex h-8 items-center justify-center font-normal",
28
+ week: "grid grid-cols-7",
29
+ day: "size-9 p-0 text-center",
30
+ day_button: "size-9 rounded-lg text-sm transition hover:bg-stone-100",
31
+ today: "font-semibold text-stone-950",
32
+ selected: "[&_button]:bg-stone-950 [&_button]:text-white [&_button]:hover:bg-stone-800",
33
+ outside: "text-stone-300",
34
+ disabled: "text-stone-300 opacity-50",
35
+ ...classNames,
36
+ }}
37
+ components={{
38
+ Chevron: ({ orientation }) =>
39
+ orientation === "left" ? <ChevronLeft className="size-4" /> : <ChevronRight className="size-4" />,
40
+ }}
41
+ {...props}
42
+ />
43
+ );
44
+ }
45
+
46
+ export { Calendar };
web/src/components/ui/field.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Field({ className, ...props }: React.ComponentProps<"div">) {
6
+ return <div className={cn("grid gap-2", className)} {...props} />;
7
+ }
8
+
9
+ function FieldLabel({ className, ...props }: React.ComponentProps<"label">) {
10
+ return <label className={cn("text-sm font-medium text-stone-700", className)} {...props} />;
11
+ }
12
+
13
+ export { Field, FieldLabel };
web/src/components/ui/popover.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
5
+
6
+ import { cn } from "@/lib/utils";
7
+
8
+ function Popover(props: React.ComponentProps<typeof PopoverPrimitive.Root>) {
9
+ return <PopoverPrimitive.Root data-slot="popover" {...props} />;
10
+ }
11
+
12
+ function PopoverTrigger(props: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
13
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
14
+ }
15
+
16
+ function PopoverContent({
17
+ className,
18
+ align = "center",
19
+ sideOffset = 4,
20
+ ...props
21
+ }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
22
+ return (
23
+ <PopoverPrimitive.Portal>
24
+ <PopoverPrimitive.Content
25
+ data-slot="popover-content"
26
+ align={align}
27
+ sideOffset={sideOffset}
28
+ className={cn(
29
+ "z-50 rounded-2xl border border-white/80 bg-white p-3 text-stone-950 shadow-[0_20px_60px_-30px_rgba(15,23,42,0.35)] outline-none",
30
+ className,
31
+ )}
32
+ {...props}
33
+ />
34
+ </PopoverPrimitive.Portal>
35
+ );
36
+ }
37
+
38
+ export { Popover, PopoverContent, PopoverTrigger };
web/src/components/ui/table.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
6
+ return <table className={cn("w-full caption-bottom text-sm", className)} {...props} />;
7
+ }
8
+
9
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
10
+ return <thead className={cn("border-b border-stone-100 text-[11px] tracking-[0.18em] text-stone-400 uppercase", className)} {...props} />;
11
+ }
12
+
13
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
14
+ return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
15
+ }
16
+
17
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
18
+ return <tr className={cn("border-b border-stone-100 transition-colors hover:bg-stone-50/70", className)} {...props} />;
19
+ }
20
+
21
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
22
+ return <th className={cn("h-11 px-4 text-left align-middle font-medium", className)} {...props} />;
23
+ }
24
+
25
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
26
+ return <td className={cn("px-4 py-3 align-middle", className)} {...props} />;
27
+ }
28
+
29
+ export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
web/src/lib/api.ts CHANGED
@@ -54,6 +54,24 @@ export type SettingsConfig = {
54
  proxy: string;
55
  base_url?: string;
56
  refresh_account_interval_minute?: number | string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  [key: string]: unknown;
58
  };
59
 
@@ -180,6 +198,23 @@ export async function updateSettingsConfig(settings: SettingsConfig) {
180
  });
181
  }
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  export async function fetchUserKeys() {
184
  return httpRequest<{ items: UserKey[] }>("/api/auth/users");
185
  }
 
54
  proxy: string;
55
  base_url?: string;
56
  refresh_account_interval_minute?: number | string;
57
+ image_retention_days?: number | string;
58
+ auto_remove_invalid_accounts?: boolean;
59
+ [key: string]: unknown;
60
+ };
61
+
62
+ export type ManagedImage = {
63
+ name: string;
64
+ date: string;
65
+ size: number;
66
+ url: string;
67
+ created_at: string;
68
+ };
69
+
70
+ export type SystemLog = {
71
+ time: string;
72
+ type: "call" | "account" | string;
73
+ summary?: string;
74
+ detail?: Record<string, unknown>;
75
  [key: string]: unknown;
76
  };
77
 
 
198
  });
199
  }
200
 
201
+ export async function fetchManagedImages(filters: { start_date?: string; end_date?: string }) {
202
+ const params = new URLSearchParams();
203
+ if (filters.start_date) params.set("start_date", filters.start_date);
204
+ if (filters.end_date) params.set("end_date", filters.end_date);
205
+ return httpRequest<{ items: ManagedImage[]; groups: Array<{ date: string; items: ManagedImage[] }> }>(
206
+ `/api/images${params.toString() ? `?${params.toString()}` : ""}`,
207
+ );
208
+ }
209
+
210
+ export async function fetchSystemLogs(filters: { type?: string; start_date?: string; end_date?: string }) {
211
+ const params = new URLSearchParams();
212
+ if (filters.type) params.set("type", filters.type);
213
+ if (filters.start_date) params.set("start_date", filters.start_date);
214
+ if (filters.end_date) params.set("end_date", filters.end_date);
215
+ return httpRequest<{ items: SystemLog[] }>(`/api/logs${params.toString() ? `?${params.toString()}` : ""}`);
216
+ }
217
+
218
  export async function fetchUserKeys() {
219
  return httpRequest<{ items: UserKey[] }>("/api/auth/users");
220
  }