HouYunFei Codex commited on
Commit ·
6b31eec
1
Parent(s): c50f4e5
Update config and management UI
Browse filesCo-Authored-By: Codex <noreply@openai.com>
- api/ai.py +121 -16
- api/app.py +1 -0
- api/system.py +14 -2
- config.json +2 -0
- services/account_service.py +14 -7
- services/chatgpt_service.py +32 -13
- services/config.py +32 -0
- services/image_service.py +33 -0
- services/log_service.py +51 -0
- services/openai_backend_api.py +1 -0
- web/bun.lock +15 -0
- web/package.json +3 -0
- web/src/app/image-manager/page.tsx +175 -0
- web/src/app/logs/page.tsx +229 -0
- web/src/app/settings/components/config-card.tsx +20 -0
- web/src/app/settings/components/user-keys-card.tsx +37 -3
- web/src/app/settings/store.ts +14 -0
- web/src/components/date-range-filter.tsx +49 -0
- web/src/components/image-lightbox.tsx +7 -0
- web/src/components/top-nav.tsx +2 -0
- web/src/components/ui/calendar.tsx +46 -0
- web/src/components/ui/field.tsx +13 -0
- web/src/components/ui/popover.tsx +38 -0
- web/src/components/ui/table.tsx +29 -0
- web/src/lib/api.ts +35 -0
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 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
)
|
| 80 |
),
|
| 81 |
media_type="text/event-stream",
|
| 82 |
)
|
| 83 |
try:
|
| 84 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
media_type="text/event-stream",
|
| 122 |
)
|
| 123 |
try:
|
| 124 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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.
|
| 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 |
-
|
| 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.
|
| 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.
|
| 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 |
-
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 (
|
| 110 |
-
if (!
|
| 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={() =>
|
| 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 |
}
|