HouYunFei commited on
Commit ·
db809d9
1
Parent(s): 8a3f6b1
feat(accounts): 添加账户导入对话框功能
Browse files- 实现了支持多种导入方式的对话框组件
- 添加了 Access Token 文本导入和 TXT 文件批量导入功能
- 集成了 Session JSON 和 CPA JSON 文件导入方式
- 实现了导入过程中的 Token 解析和验证机制
- 添加了导入确认对话框防止误操作
- 集成了 Toast 提示反馈导入结果
- README.md +4 -3
- config.example.json +2 -2
- services/account_service.py +1 -1
- services/api.py +13 -1
- services/chat_image_service.py +174 -0
- services/config.py +28 -28
- services/image_service.py +1 -2
- uv.lock +0 -0
- web/src/app/accounts/components/account-import-dialog.tsx +544 -0
- web/src/app/accounts/page.tsx +12 -89
- web/src/app/layout.tsx +1 -1
README.md
CHANGED
|
@@ -49,12 +49,13 @@ POST /v1/images/generations
|
|
| 49 |
|
| 50 |
```bash
|
| 51 |
git clone git@github.com:basketikun/chatgpt2api.git
|
| 52 |
-
|
| 53 |
-
#
|
|
|
|
| 54 |
docker compose up -d
|
| 55 |
```
|
| 56 |
|
| 57 |
## 社区支持
|
| 58 |
学 AI , 上 L 站
|
| 59 |
|
| 60 |
-
[LinuxDO](https://linux.do)
|
|
|
|
| 49 |
|
| 50 |
```bash
|
| 51 |
git clone git@github.com:basketikun/chatgpt2api.git
|
| 52 |
+
# 首次启动会自动生成 config.json,也可以手动复制后修改
|
| 53 |
+
# cp config.example.json config.json
|
| 54 |
+
# 可按需编辑 config.json 的密钥和 `refresh_account_interval_minute`
|
| 55 |
docker compose up -d
|
| 56 |
```
|
| 57 |
|
| 58 |
## 社区支持
|
| 59 |
学 AI , 上 L 站
|
| 60 |
|
| 61 |
+
[LinuxDO](https://linux.do)
|
config.example.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
{
|
| 2 |
-
"auth-key": "
|
| 3 |
-
"
|
| 4 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"auth-key": "chatgpt2api",
|
| 3 |
+
"refresh_account_interval_minute": 60
|
| 4 |
}
|
services/account_service.py
CHANGED
|
@@ -361,7 +361,7 @@ class AccountService:
|
|
| 361 |
|
| 362 |
headers, impersonate = self._build_remote_headers(access_token)
|
| 363 |
print(f"[account-refresh] start {access_token[:12]}...")
|
| 364 |
-
session = Session(impersonate=impersonate, verify=
|
| 365 |
session.headers.update(headers)
|
| 366 |
try:
|
| 367 |
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
|
|
| 361 |
|
| 362 |
headers, impersonate = self._build_remote_headers(access_token)
|
| 363 |
print(f"[account-refresh] start {access_token[:12]}...")
|
| 364 |
+
session = Session(impersonate=impersonate, verify=True)
|
| 365 |
session.headers.update(headers)
|
| 366 |
try:
|
| 367 |
with ThreadPoolExecutor(max_workers=2) as executor:
|
services/api.py
CHANGED
|
@@ -13,6 +13,7 @@ from pydantic import BaseModel, Field
|
|
| 13 |
from services.account_service import account_service
|
| 14 |
from services.config import config
|
| 15 |
from services.backend_service import BackendService
|
|
|
|
| 16 |
from services.image_service import ImageGenerationError
|
| 17 |
from services.version import get_app_version
|
| 18 |
|
|
@@ -70,6 +71,8 @@ def require_auth_key(authorization: str | None) -> None:
|
|
| 70 |
|
| 71 |
|
| 72 |
def start_limited_account_watcher(stop_event: Event) -> Thread:
|
|
|
|
|
|
|
| 73 |
def worker() -> None:
|
| 74 |
while not stop_event.is_set():
|
| 75 |
try:
|
|
@@ -79,7 +82,7 @@ def start_limited_account_watcher(stop_event: Event) -> Thread:
|
|
| 79 |
account_service.refresh_accounts(limited_tokens)
|
| 80 |
except Exception as exc:
|
| 81 |
print(f"[account-limited-watcher] fail {exc}")
|
| 82 |
-
stop_event.wait(
|
| 83 |
|
| 84 |
thread = Thread(target=worker, name="limited-account-watcher", daemon=True)
|
| 85 |
thread.start()
|
|
@@ -114,6 +117,7 @@ def resolve_web_asset(requested_path: str) -> Path | None:
|
|
| 114 |
|
| 115 |
def create_app() -> FastAPI:
|
| 116 |
service = BackendService(account_service)
|
|
|
|
| 117 |
app_version = get_app_version()
|
| 118 |
|
| 119 |
@asynccontextmanager
|
|
@@ -245,6 +249,14 @@ def create_app() -> FastAPI:
|
|
| 245 |
except ImageGenerationError as exc:
|
| 246 |
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
app.include_router(router)
|
| 249 |
|
| 250 |
@app.get("/{full_path:path}", include_in_schema=False)
|
|
|
|
| 13 |
from services.account_service import account_service
|
| 14 |
from services.config import config
|
| 15 |
from services.backend_service import BackendService
|
| 16 |
+
from services.chat_image_service import ChatImageService
|
| 17 |
from services.image_service import ImageGenerationError
|
| 18 |
from services.version import get_app_version
|
| 19 |
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
def start_limited_account_watcher(stop_event: Event) -> Thread:
|
| 74 |
+
interval_seconds = config.refresh_account_interval_minute * 60
|
| 75 |
+
|
| 76 |
def worker() -> None:
|
| 77 |
while not stop_event.is_set():
|
| 78 |
try:
|
|
|
|
| 82 |
account_service.refresh_accounts(limited_tokens)
|
| 83 |
except Exception as exc:
|
| 84 |
print(f"[account-limited-watcher] fail {exc}")
|
| 85 |
+
stop_event.wait(interval_seconds)
|
| 86 |
|
| 87 |
thread = Thread(target=worker, name="limited-account-watcher", daemon=True)
|
| 88 |
thread.start()
|
|
|
|
| 117 |
|
| 118 |
def create_app() -> FastAPI:
|
| 119 |
service = BackendService(account_service)
|
| 120 |
+
chat_image_service = ChatImageService(service)
|
| 121 |
app_version = get_app_version()
|
| 122 |
|
| 123 |
@asynccontextmanager
|
|
|
|
| 249 |
except ImageGenerationError as exc:
|
| 250 |
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 251 |
|
| 252 |
+
@router.post("/v1/chat/completions")
|
| 253 |
+
async def create_chat_completion(
|
| 254 |
+
body: dict[str, object],
|
| 255 |
+
authorization: str | None = Header(default=None),
|
| 256 |
+
):
|
| 257 |
+
require_auth_key(authorization)
|
| 258 |
+
return await run_in_threadpool(chat_image_service.create_image_completion, body)
|
| 259 |
+
|
| 260 |
app.include_router(router)
|
| 261 |
|
| 262 |
@app.get("/{full_path:path}", include_in_schema=False)
|
services/chat_image_service.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
from fastapi import HTTPException
|
| 7 |
+
|
| 8 |
+
from services.backend_service import BackendService
|
| 9 |
+
from services.image_service import ImageGenerationError
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
IMAGE_MODELS = {"gpt-image-1", "gpt-image-2"}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ChatImageService:
|
| 16 |
+
def __init__(self, backend_service: BackendService):
|
| 17 |
+
self.backend_service = backend_service
|
| 18 |
+
|
| 19 |
+
def is_image_chat_request(self, body: dict[str, object]) -> bool:
|
| 20 |
+
model = str(body.get("model") or "").strip()
|
| 21 |
+
modalities = body.get("modalities")
|
| 22 |
+
if model in IMAGE_MODELS:
|
| 23 |
+
return True
|
| 24 |
+
if isinstance(modalities, list):
|
| 25 |
+
normalized = {str(item or "").strip().lower() for item in modalities}
|
| 26 |
+
return "image" in normalized
|
| 27 |
+
return False
|
| 28 |
+
|
| 29 |
+
def create_image_completion(self, body: dict[str, object]) -> dict[str, object]:
|
| 30 |
+
if not self.is_image_chat_request(body):
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=400,
|
| 33 |
+
detail={"error": "only image generation requests are supported on this endpoint"},
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
if bool(body.get("stream")):
|
| 37 |
+
raise HTTPException(status_code=400, detail={"error": "stream is not supported for image generation"})
|
| 38 |
+
|
| 39 |
+
model = str(body.get("model") or "gpt-image-1").strip() or "gpt-image-1"
|
| 40 |
+
n = self._parse_image_count(body.get("n"))
|
| 41 |
+
prompt = self._extract_chat_prompt(body)
|
| 42 |
+
if not prompt:
|
| 43 |
+
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
image_result = self.backend_service.generate_with_pool(prompt, model, n)
|
| 47 |
+
except ImageGenerationError as exc:
|
| 48 |
+
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 49 |
+
|
| 50 |
+
return self._build_chat_image_completion(model, prompt, image_result)
|
| 51 |
+
|
| 52 |
+
def _extract_prompt_from_message_content(self, content: object) -> str:
|
| 53 |
+
if isinstance(content, str):
|
| 54 |
+
return content.strip()
|
| 55 |
+
if not isinstance(content, list):
|
| 56 |
+
return ""
|
| 57 |
+
|
| 58 |
+
parts: list[str] = []
|
| 59 |
+
for item in content:
|
| 60 |
+
if not isinstance(item, dict):
|
| 61 |
+
continue
|
| 62 |
+
item_type = str(item.get("type") or "").strip()
|
| 63 |
+
if item_type == "text":
|
| 64 |
+
text = str(item.get("text") or "").strip()
|
| 65 |
+
if text:
|
| 66 |
+
parts.append(text)
|
| 67 |
+
continue
|
| 68 |
+
if item_type == "input_text":
|
| 69 |
+
text = str(item.get("input_text") or "").strip()
|
| 70 |
+
if text:
|
| 71 |
+
parts.append(text)
|
| 72 |
+
return "\n".join(parts).strip()
|
| 73 |
+
|
| 74 |
+
def _extract_chat_prompt(self, body: dict[str, object]) -> str:
|
| 75 |
+
direct_prompt = str(body.get("prompt") or "").strip()
|
| 76 |
+
if direct_prompt:
|
| 77 |
+
return direct_prompt
|
| 78 |
+
|
| 79 |
+
messages = body.get("messages")
|
| 80 |
+
if not isinstance(messages, list):
|
| 81 |
+
return ""
|
| 82 |
+
|
| 83 |
+
prompt_parts: list[str] = []
|
| 84 |
+
for message in messages:
|
| 85 |
+
if not isinstance(message, dict):
|
| 86 |
+
continue
|
| 87 |
+
role = str(message.get("role") or "").strip().lower()
|
| 88 |
+
if role != "user":
|
| 89 |
+
continue
|
| 90 |
+
prompt = self._extract_prompt_from_message_content(message.get("content"))
|
| 91 |
+
if prompt:
|
| 92 |
+
prompt_parts.append(prompt)
|
| 93 |
+
|
| 94 |
+
return "\n".join(prompt_parts).strip()
|
| 95 |
+
|
| 96 |
+
def _parse_image_count(self, raw_value: object) -> int:
|
| 97 |
+
try:
|
| 98 |
+
value = int(raw_value or 1)
|
| 99 |
+
except (TypeError, ValueError) as exc:
|
| 100 |
+
raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
|
| 101 |
+
if value < 1 or value > 4:
|
| 102 |
+
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
|
| 103 |
+
return value
|
| 104 |
+
|
| 105 |
+
def _build_chat_image_completion(
|
| 106 |
+
self,
|
| 107 |
+
model: str,
|
| 108 |
+
prompt: str,
|
| 109 |
+
image_result: dict[str, object],
|
| 110 |
+
) -> dict[str, object]:
|
| 111 |
+
created = int(image_result.get("created") or time.time())
|
| 112 |
+
image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
|
| 113 |
+
|
| 114 |
+
markdown_images = []
|
| 115 |
+
images_payload = []
|
| 116 |
+
content_parts = []
|
| 117 |
+
|
| 118 |
+
for index, item in enumerate(image_items, start=1):
|
| 119 |
+
if not isinstance(item, dict):
|
| 120 |
+
continue
|
| 121 |
+
b64_json = str(item.get("b64_json") or "").strip()
|
| 122 |
+
revised_prompt = str(item.get("revised_prompt") or prompt).strip()
|
| 123 |
+
if not b64_json:
|
| 124 |
+
continue
|
| 125 |
+
image_data_url = f"data:image/png;base64,{b64_json}"
|
| 126 |
+
markdown_images.append(f"")
|
| 127 |
+
images_payload.append(
|
| 128 |
+
{
|
| 129 |
+
"b64_json": b64_json,
|
| 130 |
+
"revised_prompt": revised_prompt,
|
| 131 |
+
"url": image_data_url,
|
| 132 |
+
}
|
| 133 |
+
)
|
| 134 |
+
content_parts.append(
|
| 135 |
+
{
|
| 136 |
+
"type": "image_url",
|
| 137 |
+
"image_url": {
|
| 138 |
+
"url": image_data_url,
|
| 139 |
+
},
|
| 140 |
+
}
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
text_content = "\n\n".join(markdown_images) if markdown_images else "Image generation completed."
|
| 144 |
+
content_parts.insert(
|
| 145 |
+
0,
|
| 146 |
+
{
|
| 147 |
+
"type": "text",
|
| 148 |
+
"text": text_content,
|
| 149 |
+
},
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
| 154 |
+
"object": "chat.completion",
|
| 155 |
+
"created": created,
|
| 156 |
+
"model": model,
|
| 157 |
+
"choices": [
|
| 158 |
+
{
|
| 159 |
+
"index": 0,
|
| 160 |
+
"message": {
|
| 161 |
+
"role": "assistant",
|
| 162 |
+
"content": text_content,
|
| 163 |
+
"content_parts": content_parts,
|
| 164 |
+
"images": images_payload,
|
| 165 |
+
},
|
| 166 |
+
"finish_reason": "stop",
|
| 167 |
+
}
|
| 168 |
+
],
|
| 169 |
+
"usage": {
|
| 170 |
+
"prompt_tokens": 0,
|
| 171 |
+
"completion_tokens": 0,
|
| 172 |
+
"total_tokens": 0,
|
| 173 |
+
},
|
| 174 |
+
}
|
services/config.py
CHANGED
|
@@ -2,13 +2,16 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import os
|
|
|
|
| 5 |
from dataclasses import dataclass
|
| 6 |
from pathlib import Path
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 10 |
DATA_DIR = BASE_DIR / "data"
|
| 11 |
CONFIG_FILE = BASE_DIR / "config.json"
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
@dataclass(frozen=True)
|
|
@@ -17,51 +20,48 @@ class AppSettings:
|
|
| 17 |
host: str
|
| 18 |
port: int
|
| 19 |
accounts_file: Path
|
| 20 |
-
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
if
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def _load_settings() -> AppSettings:
|
| 37 |
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 38 |
-
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
if CONFIG_FILE.exists():
|
| 41 |
-
|
| 42 |
-
if text:
|
| 43 |
-
loaded = json.loads(text)
|
| 44 |
-
if not isinstance(loaded, dict):
|
| 45 |
-
raise ValueError("config.json must be a JSON object")
|
| 46 |
-
raw_config = loaded
|
| 47 |
|
| 48 |
auth_key = str(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key") or "").strip()
|
| 49 |
if not auth_key:
|
| 50 |
-
raise ValueError(
|
| 51 |
-
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
tls_verify = _parse_bool(
|
| 55 |
-
os.getenv("CHATGPT2API_TLS_VERIFY", raw_config.get("tls-verify")),
|
| 56 |
-
default=True,
|
| 57 |
-
)
|
| 58 |
|
| 59 |
return AppSettings(
|
| 60 |
auth_key=auth_key,
|
| 61 |
host="0.0.0.0",
|
| 62 |
port=8000,
|
| 63 |
accounts_file=DATA_DIR / "accounts.json",
|
| 64 |
-
|
| 65 |
)
|
| 66 |
|
| 67 |
|
|
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
+
import shutil
|
| 6 |
from dataclasses import dataclass
|
| 7 |
from pathlib import Path
|
| 8 |
+
from typing import cast
|
| 9 |
|
| 10 |
|
| 11 |
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 12 |
DATA_DIR = BASE_DIR / "data"
|
| 13 |
CONFIG_FILE = BASE_DIR / "config.json"
|
| 14 |
+
CONFIG_EXAMPLE_FILE = BASE_DIR / "config.example.json"
|
| 15 |
|
| 16 |
|
| 17 |
@dataclass(frozen=True)
|
|
|
|
| 20 |
host: str
|
| 21 |
port: int
|
| 22 |
accounts_file: Path
|
| 23 |
+
refresh_account_interval_minute: int
|
| 24 |
|
| 25 |
+
def _load_json_object(path: Path, *, name: str) -> dict[str, object]:
|
| 26 |
+
text = path.read_text(encoding="utf-8").strip()
|
| 27 |
+
if not text:
|
| 28 |
+
return {}
|
| 29 |
|
| 30 |
+
loaded = json.loads(text)
|
| 31 |
+
if not isinstance(loaded, dict):
|
| 32 |
+
raise ValueError(f"{name} must be a JSON object")
|
| 33 |
+
return loaded
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _ensure_config_file() -> None:
|
| 37 |
+
if CONFIG_FILE.exists():
|
| 38 |
+
return
|
| 39 |
+
if not CONFIG_EXAMPLE_FILE.exists():
|
| 40 |
+
return
|
| 41 |
+
shutil.copyfile(CONFIG_EXAMPLE_FILE, CONFIG_FILE)
|
| 42 |
|
| 43 |
|
| 44 |
def _load_settings() -> AppSettings:
|
| 45 |
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
_ensure_config_file()
|
| 47 |
|
| 48 |
+
raw_config: dict[str, object] = {}
|
| 49 |
+
if CONFIG_EXAMPLE_FILE.exists():
|
| 50 |
+
raw_config.update(_load_json_object(CONFIG_EXAMPLE_FILE, name="config.example.json"))
|
| 51 |
if CONFIG_FILE.exists():
|
| 52 |
+
raw_config.update(_load_json_object(CONFIG_FILE, name="config.json"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
auth_key = str(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key") or "").strip()
|
| 55 |
if not auth_key:
|
| 56 |
+
raise ValueError("config.example.json must contain a non-empty 'auth-key'")
|
| 57 |
+
refresh_account_interval_minute = cast(int, raw_config.get("refresh_account_interval_minute", 5))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
return AppSettings(
|
| 60 |
auth_key=auth_key,
|
| 61 |
host="0.0.0.0",
|
| 62 |
port=8000,
|
| 63 |
accounts_file=DATA_DIR / "accounts.json",
|
| 64 |
+
refresh_account_interval_minute=refresh_account_interval_minute,
|
| 65 |
)
|
| 66 |
|
| 67 |
|
services/image_service.py
CHANGED
|
@@ -12,7 +12,6 @@ from typing import Optional
|
|
| 12 |
from curl_cffi.requests import Session
|
| 13 |
|
| 14 |
from services.account_service import account_service
|
| 15 |
-
from services.config import config
|
| 16 |
from services import proof_of_work
|
| 17 |
|
| 18 |
|
|
@@ -89,7 +88,7 @@ def _new_session(access_token: str) -> tuple[Session, dict]:
|
|
| 89 |
fp = _build_fp(access_token)
|
| 90 |
session = Session(
|
| 91 |
impersonate=fp.get("impersonate") or "edge101",
|
| 92 |
-
verify=
|
| 93 |
)
|
| 94 |
session.headers.update(
|
| 95 |
{
|
|
|
|
| 12 |
from curl_cffi.requests import Session
|
| 13 |
|
| 14 |
from services.account_service import account_service
|
|
|
|
| 15 |
from services import proof_of_work
|
| 16 |
|
| 17 |
|
|
|
|
| 88 |
fp = _build_fp(access_token)
|
| 89 |
session = Session(
|
| 90 |
impersonate=fp.get("impersonate") or "edge101",
|
| 91 |
+
verify=True,
|
| 92 |
)
|
| 93 |
session.headers.update(
|
| 94 |
{
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/app/accounts/components/account-import-dialog.tsx
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useRef, useState, type ChangeEvent } from "react";
|
| 4 |
+
import {
|
| 5 |
+
ArrowLeft,
|
| 6 |
+
ExternalLink,
|
| 7 |
+
FileJson,
|
| 8 |
+
FileText,
|
| 9 |
+
Files,
|
| 10 |
+
KeyRound,
|
| 11 |
+
LoaderCircle,
|
| 12 |
+
Upload,
|
| 13 |
+
} from "lucide-react";
|
| 14 |
+
import { toast } from "sonner";
|
| 15 |
+
|
| 16 |
+
import { Button } from "@/components/ui/button";
|
| 17 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 18 |
+
import {
|
| 19 |
+
Dialog,
|
| 20 |
+
DialogContent,
|
| 21 |
+
DialogDescription,
|
| 22 |
+
DialogFooter,
|
| 23 |
+
DialogHeader,
|
| 24 |
+
DialogTitle,
|
| 25 |
+
} from "@/components/ui/dialog";
|
| 26 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 27 |
+
import { createAccounts, type Account } from "@/lib/api";
|
| 28 |
+
import { cn } from "@/lib/utils";
|
| 29 |
+
|
| 30 |
+
type ImportMethod = "menu" | "token" | "session" | "cpa";
|
| 31 |
+
|
| 32 |
+
type AccountImportDialogProps = {
|
| 33 |
+
disabled?: boolean;
|
| 34 |
+
onImported: (items: Account[]) => void;
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
type PendingCpaImport = {
|
| 38 |
+
tokens: string[];
|
| 39 |
+
parsedFileCount: number;
|
| 40 |
+
errorCount: number;
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
const sessionUrl = "https://chatgpt.com/api/auth/session";
|
| 44 |
+
|
| 45 |
+
function splitTokens(value: string) {
|
| 46 |
+
return value
|
| 47 |
+
.split(/\r?\n/)
|
| 48 |
+
.map((item) => item.trim())
|
| 49 |
+
.filter(Boolean);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function getSessionAccessToken(value: unknown) {
|
| 53 |
+
const token = (value as { accessToken?: unknown })?.accessToken;
|
| 54 |
+
return typeof token === "string" ? token.trim() : "";
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function getCpaAccessToken(value: unknown) {
|
| 58 |
+
const token = (value as { access_token?: unknown })?.access_token;
|
| 59 |
+
return typeof token === "string" ? token.trim() : "";
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function readFileAsText(file: File) {
|
| 63 |
+
return new Promise<string>((resolve, reject) => {
|
| 64 |
+
const reader = new FileReader();
|
| 65 |
+
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
| 66 |
+
reader.onerror = () => reject(reader.error ?? new Error(`读取文件失败: ${file.name}`));
|
| 67 |
+
reader.readAsText(file);
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function MethodCard({
|
| 72 |
+
title,
|
| 73 |
+
description,
|
| 74 |
+
icon: Icon,
|
| 75 |
+
onClick,
|
| 76 |
+
}: {
|
| 77 |
+
title: string;
|
| 78 |
+
description: string;
|
| 79 |
+
icon: typeof KeyRound;
|
| 80 |
+
onClick: () => void;
|
| 81 |
+
}) {
|
| 82 |
+
return (
|
| 83 |
+
<button
|
| 84 |
+
type="button"
|
| 85 |
+
onClick={onClick}
|
| 86 |
+
className="w-full rounded-2xl border border-stone-200 bg-white p-0 text-left transition hover:border-stone-300 hover:bg-stone-50"
|
| 87 |
+
>
|
| 88 |
+
<Card className="rounded-2xl border-0 bg-transparent shadow-none">
|
| 89 |
+
<CardContent className="flex items-start gap-4 p-4">
|
| 90 |
+
<div className="rounded-xl bg-stone-100 p-3 text-stone-700">
|
| 91 |
+
<Icon className="size-5" />
|
| 92 |
+
</div>
|
| 93 |
+
<div className="space-y-1">
|
| 94 |
+
<div className="text-sm font-semibold text-stone-900">{title}</div>
|
| 95 |
+
<div className="text-sm leading-6 text-stone-500">{description}</div>
|
| 96 |
+
</div>
|
| 97 |
+
</CardContent>
|
| 98 |
+
</Card>
|
| 99 |
+
</button>
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export function AccountImportDialog({ disabled, onImported }: AccountImportDialogProps) {
|
| 104 |
+
const [open, setOpen] = useState(false);
|
| 105 |
+
const [method, setMethod] = useState<ImportMethod>("menu");
|
| 106 |
+
const [tokenInput, setTokenInput] = useState("");
|
| 107 |
+
const [sessionInput, setSessionInput] = useState("");
|
| 108 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 109 |
+
const [pendingCpaImport, setPendingCpaImport] = useState<PendingCpaImport | null>(null);
|
| 110 |
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
| 111 |
+
|
| 112 |
+
const txtInputRef = useRef<HTMLInputElement | null>(null);
|
| 113 |
+
const cpaInputRef = useRef<HTMLInputElement | null>(null);
|
| 114 |
+
|
| 115 |
+
const resetState = () => {
|
| 116 |
+
setMethod("menu");
|
| 117 |
+
setTokenInput("");
|
| 118 |
+
setSessionInput("");
|
| 119 |
+
setPendingCpaImport(null);
|
| 120 |
+
setConfirmOpen(false);
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const handleOpenChange = (nextOpen: boolean) => {
|
| 124 |
+
setOpen(nextOpen);
|
| 125 |
+
if (!nextOpen) {
|
| 126 |
+
resetState();
|
| 127 |
+
}
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
const submitTokens = async (tokens: string[], successText?: string) => {
|
| 131 |
+
const normalizedTokens = tokens.map((item) => item.trim()).filter(Boolean);
|
| 132 |
+
|
| 133 |
+
if (normalizedTokens.length === 0) {
|
| 134 |
+
toast.error("请先提供至少一个可用 Token");
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
setIsSubmitting(true);
|
| 139 |
+
try {
|
| 140 |
+
const data = await createAccounts(normalizedTokens);
|
| 141 |
+
onImported(data.items);
|
| 142 |
+
setOpen(false);
|
| 143 |
+
resetState();
|
| 144 |
+
|
| 145 |
+
if ((data.errors?.length ?? 0) > 0) {
|
| 146 |
+
const firstError = data.errors?.[0]?.error;
|
| 147 |
+
toast.error(
|
| 148 |
+
`${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
|
| 149 |
+
);
|
| 150 |
+
} else {
|
| 151 |
+
toast.success(
|
| 152 |
+
`${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`,
|
| 153 |
+
);
|
| 154 |
+
}
|
| 155 |
+
} catch (error) {
|
| 156 |
+
const message = error instanceof Error ? error.message : "导入账户失败";
|
| 157 |
+
toast.error(message);
|
| 158 |
+
} finally {
|
| 159 |
+
setIsSubmitting(false);
|
| 160 |
+
}
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
const handleImportTokenText = async () => {
|
| 164 |
+
await submitTokens(splitTokens(tokenInput), "Access Token 导入完成");
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
const handleTxtSelected = async (event: ChangeEvent<HTMLInputElement>) => {
|
| 168 |
+
const file = event.target.files?.[0];
|
| 169 |
+
event.target.value = "";
|
| 170 |
+
|
| 171 |
+
if (!file) {
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
try {
|
| 176 |
+
const content = await readFileAsText(file);
|
| 177 |
+
const tokens = splitTokens(content);
|
| 178 |
+
|
| 179 |
+
if (tokens.length === 0) {
|
| 180 |
+
toast.error("TXT 文件里没有读取到有效 Token");
|
| 181 |
+
return;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
setTokenInput((prev) => {
|
| 185 |
+
const next = [...splitTokens(prev), ...tokens];
|
| 186 |
+
return next.join("\n");
|
| 187 |
+
});
|
| 188 |
+
toast.success(`已从 ${file.name} 读取 ${tokens.length} 个 Token`);
|
| 189 |
+
} catch (error) {
|
| 190 |
+
const message = error instanceof Error ? error.message : "读取 TXT 文件失败";
|
| 191 |
+
toast.error(message);
|
| 192 |
+
}
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
const handleImportSessionJson = async () => {
|
| 196 |
+
if (!sessionInput.trim()) {
|
| 197 |
+
toast.error("请先粘贴完整 Session JSON");
|
| 198 |
+
return;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
try {
|
| 202 |
+
const payload = JSON.parse(sessionInput) as unknown;
|
| 203 |
+
const token = getSessionAccessToken(payload);
|
| 204 |
+
|
| 205 |
+
if (!token) {
|
| 206 |
+
toast.error("未从 Session JSON 中提取到 accessToken");
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
await submitTokens([token], "Session JSON 导入完成");
|
| 211 |
+
} catch (error) {
|
| 212 |
+
const message = error instanceof Error ? error.message : "Session JSON 解析失败";
|
| 213 |
+
toast.error(message);
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
const handleCpaSelected = async (event: ChangeEvent<HTMLInputElement>) => {
|
| 218 |
+
const files = Array.from(event.target.files ?? []);
|
| 219 |
+
event.target.value = "";
|
| 220 |
+
|
| 221 |
+
if (files.length === 0) {
|
| 222 |
+
return;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
try {
|
| 226 |
+
const results = await Promise.all(
|
| 227 |
+
files.map(async (file) => {
|
| 228 |
+
const raw = await readFileAsText(file);
|
| 229 |
+
const parsed = JSON.parse(raw) as unknown;
|
| 230 |
+
const token = getCpaAccessToken(parsed);
|
| 231 |
+
return {
|
| 232 |
+
token,
|
| 233 |
+
};
|
| 234 |
+
}),
|
| 235 |
+
);
|
| 236 |
+
|
| 237 |
+
const tokens = results.map((item) => item.token).filter((item): item is string => Boolean(item));
|
| 238 |
+
const parsedFileCount = tokens.length;
|
| 239 |
+
const errorCount = results.length - parsedFileCount;
|
| 240 |
+
|
| 241 |
+
if (parsedFileCount === 0) {
|
| 242 |
+
toast.error("这些 CPA JSON 文件里没有读取到可用 access_token");
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
setPendingCpaImport({
|
| 247 |
+
tokens,
|
| 248 |
+
parsedFileCount,
|
| 249 |
+
errorCount,
|
| 250 |
+
});
|
| 251 |
+
setConfirmOpen(true);
|
| 252 |
+
} catch (error) {
|
| 253 |
+
const message = error instanceof Error ? error.message : "读取 CPA JSON 文件失败";
|
| 254 |
+
toast.error(message);
|
| 255 |
+
}
|
| 256 |
+
};
|
| 257 |
+
|
| 258 |
+
const renderMethodBody = () => {
|
| 259 |
+
if (method === "token") {
|
| 260 |
+
const tokenCount = splitTokens(tokenInput).length;
|
| 261 |
+
|
| 262 |
+
return (
|
| 263 |
+
<div className="space-y-4">
|
| 264 |
+
<div className="flex items-center justify-between">
|
| 265 |
+
<button
|
| 266 |
+
type="button"
|
| 267 |
+
onClick={() => setMethod("menu")}
|
| 268 |
+
className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
|
| 269 |
+
>
|
| 270 |
+
<ArrowLeft className="size-4" />
|
| 271 |
+
返回导入方式
|
| 272 |
+
</button>
|
| 273 |
+
<span className="text-xs text-stone-400">当前识别 {tokenCount} 个 Token</span>
|
| 274 |
+
</div>
|
| 275 |
+
<div className="space-y-2">
|
| 276 |
+
<label className="text-sm font-medium text-stone-700">Access Token 列表</label>
|
| 277 |
+
<Textarea
|
| 278 |
+
placeholder="每行一个 Access Token..."
|
| 279 |
+
value={tokenInput}
|
| 280 |
+
onChange={(event) => setTokenInput(event.target.value)}
|
| 281 |
+
className="min-h-56 resize-none rounded-xl border-stone-200"
|
| 282 |
+
/>
|
| 283 |
+
</div>
|
| 284 |
+
<div className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 p-4">
|
| 285 |
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
| 286 |
+
<div className="space-y-1">
|
| 287 |
+
<div className="text-sm font-medium text-stone-800">从 TXT 文件导入</div>
|
| 288 |
+
<div className="text-sm leading-6 text-stone-500">支持 `.txt`,文件内容也是一行一个 Token。</div>
|
| 289 |
+
</div>
|
| 290 |
+
<Button
|
| 291 |
+
type="button"
|
| 292 |
+
variant="outline"
|
| 293 |
+
className="rounded-xl border-stone-200 bg-white"
|
| 294 |
+
onClick={() => txtInputRef.current?.click()}
|
| 295 |
+
disabled={isSubmitting}
|
| 296 |
+
>
|
| 297 |
+
<FileText className="size-4" />
|
| 298 |
+
选择 TXT
|
| 299 |
+
</Button>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
<input
|
| 303 |
+
ref={txtInputRef}
|
| 304 |
+
type="file"
|
| 305 |
+
accept=".txt,text/plain"
|
| 306 |
+
className="hidden"
|
| 307 |
+
onChange={(event) => void handleTxtSelected(event)}
|
| 308 |
+
/>
|
| 309 |
+
</div>
|
| 310 |
+
);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
if (method === "session") {
|
| 314 |
+
return (
|
| 315 |
+
<div className="space-y-4">
|
| 316 |
+
<button
|
| 317 |
+
type="button"
|
| 318 |
+
onClick={() => setMethod("menu")}
|
| 319 |
+
className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
|
| 320 |
+
>
|
| 321 |
+
<ArrowLeft className="size-4" />
|
| 322 |
+
返回导入方式
|
| 323 |
+
</button>
|
| 324 |
+
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm leading-6 text-stone-600">
|
| 325 |
+
打开
|
| 326 |
+
{" "}
|
| 327 |
+
<a
|
| 328 |
+
href={sessionUrl}
|
| 329 |
+
target="_blank"
|
| 330 |
+
rel="noreferrer"
|
| 331 |
+
className="inline-flex items-center gap-1 font-medium text-stone-900 underline underline-offset-4"
|
| 332 |
+
>
|
| 333 |
+
{sessionUrl}
|
| 334 |
+
<ExternalLink className="size-3.5" />
|
| 335 |
+
</a>
|
| 336 |
+
,复制页面返回的完整 JSON,系统会自动提取其中的 `accessToken` 导入。
|
| 337 |
+
</div>
|
| 338 |
+
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
|
| 339 |
+
<div className="font-medium">风险提示</div>
|
| 340 |
+
<div>
|
| 341 |
+
不要使用自己的大号,尽量使用不常用的小号进行导入,避免出现封号风险。本项目不承担任何封号风险责任。
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
<div className="space-y-2">
|
| 345 |
+
<label className="text-sm font-medium text-stone-700">Session JSON</label>
|
| 346 |
+
<Textarea
|
| 347 |
+
placeholder='粘贴完整 JSON,例如包含 "accessToken" 的对象...'
|
| 348 |
+
value={sessionInput}
|
| 349 |
+
onChange={(event) => setSessionInput(event.target.value)}
|
| 350 |
+
className="min-h-56 resize-none rounded-xl border-stone-200 font-mono text-xs"
|
| 351 |
+
/>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
if (method === "cpa") {
|
| 358 |
+
return (
|
| 359 |
+
<div className="space-y-4">
|
| 360 |
+
<button
|
| 361 |
+
type="button"
|
| 362 |
+
onClick={() => setMethod("menu")}
|
| 363 |
+
className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
|
| 364 |
+
>
|
| 365 |
+
<ArrowLeft className="size-4" />
|
| 366 |
+
返回导入方式
|
| 367 |
+
</button>
|
| 368 |
+
<div className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 p-5">
|
| 369 |
+
<div className="space-y-2">
|
| 370 |
+
<div className="text-sm font-medium text-stone-800">多选本地 CPA JSON 文件</div>
|
| 371 |
+
<div className="text-sm leading-6 text-stone-500">
|
| 372 |
+
每个文件应为一个 JSON 对象。系统会从对象中自动提取 `access_token` 或 `accessToken`,
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
<Button
|
| 376 |
+
type="button"
|
| 377 |
+
className="mt-4 rounded-xl bg-stone-950 text-white hover:bg-stone-800"
|
| 378 |
+
onClick={() => cpaInputRef.current?.click()}
|
| 379 |
+
disabled={isSubmitting}
|
| 380 |
+
>
|
| 381 |
+
<Files className="size-4" />
|
| 382 |
+
选择多个 JSON 文件
|
| 383 |
+
</Button>
|
| 384 |
+
</div>
|
| 385 |
+
<input
|
| 386 |
+
ref={cpaInputRef}
|
| 387 |
+
type="file"
|
| 388 |
+
accept=".json,application/json"
|
| 389 |
+
multiple
|
| 390 |
+
className="hidden"
|
| 391 |
+
onChange={(event) => void handleCpaSelected(event)}
|
| 392 |
+
/>
|
| 393 |
+
{pendingCpaImport ? (
|
| 394 |
+
<div className="rounded-2xl border border-stone-200 bg-white p-4 text-sm leading-6 text-stone-600">
|
| 395 |
+
最近一次读取到 {pendingCpaImport.parsedFileCount} 个 Token
|
| 396 |
+
{pendingCpaImport.errorCount > 0 ? `,另有 ${pendingCpaImport.errorCount} 个文件未提取成功` : ""}。
|
| 397 |
+
</div>
|
| 398 |
+
) : null}
|
| 399 |
+
</div>
|
| 400 |
+
);
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
return (
|
| 404 |
+
<div className="space-y-3">
|
| 405 |
+
<MethodCard
|
| 406 |
+
title="导入 Access Token"
|
| 407 |
+
description="支持直接粘贴,一行一个;也支持从 TXT 文件读取,一行一个。"
|
| 408 |
+
icon={KeyRound}
|
| 409 |
+
onClick={() => setMethod("token")}
|
| 410 |
+
/>
|
| 411 |
+
<MethodCard
|
| 412 |
+
title="导入 Session JSON"
|
| 413 |
+
description="从 chatgpt.com 的 session 接口复制完整 JSON,自动提取 accessToken。"
|
| 414 |
+
icon={FileJson}
|
| 415 |
+
onClick={() => setMethod("session")}
|
| 416 |
+
/>
|
| 417 |
+
<MethodCard
|
| 418 |
+
title="导入 CPA JSON 文件"
|
| 419 |
+
description="支持一次多选多个本地 JSON 文件,逐个读取对象里的 access_token 后导入。"
|
| 420 |
+
icon={Files}
|
| 421 |
+
onClick={() => setMethod("cpa")}
|
| 422 |
+
/>
|
| 423 |
+
</div>
|
| 424 |
+
);
|
| 425 |
+
};
|
| 426 |
+
|
| 427 |
+
const footerDisabled = disabled || isSubmitting;
|
| 428 |
+
|
| 429 |
+
return (
|
| 430 |
+
<>
|
| 431 |
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
| 432 |
+
<Button
|
| 433 |
+
className="h-10 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800"
|
| 434 |
+
onClick={() => setOpen(true)}
|
| 435 |
+
disabled={disabled}
|
| 436 |
+
>
|
| 437 |
+
<Upload className="size-4" />
|
| 438 |
+
导入
|
| 439 |
+
</Button>
|
| 440 |
+
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
| 441 |
+
<DialogHeader className="gap-2">
|
| 442 |
+
<DialogTitle>
|
| 443 |
+
{method === "menu"
|
| 444 |
+
? "导入账户"
|
| 445 |
+
: method === "token"
|
| 446 |
+
? "导入 Access Token"
|
| 447 |
+
: method === "session"
|
| 448 |
+
? "导入 Session JSON"
|
| 449 |
+
: "导入 CPA JSON"}
|
| 450 |
+
</DialogTitle>
|
| 451 |
+
<DialogDescription className="text-sm leading-6">
|
| 452 |
+
{method === "menu"
|
| 453 |
+
? "选择一种导入方式。导入成功后会自动拉取邮箱、类型和额度。"
|
| 454 |
+
: method === "token"
|
| 455 |
+
? "支持手动粘贴或从 TXT 文件导入,一行一个 Token。"
|
| 456 |
+
: method === "session"
|
| 457 |
+
? "粘贴完整 Session JSON,系统会自动提取 accessToken。"
|
| 458 |
+
: "支持一次读取多个本地 JSON 文件,并在提交前做数量确认。"}
|
| 459 |
+
</DialogDescription>
|
| 460 |
+
</DialogHeader>
|
| 461 |
+
|
| 462 |
+
{renderMethodBody()}
|
| 463 |
+
|
| 464 |
+
<DialogFooter className="pt-2">
|
| 465 |
+
<Button
|
| 466 |
+
variant="secondary"
|
| 467 |
+
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
| 468 |
+
onClick={() => setOpen(false)}
|
| 469 |
+
disabled={footerDisabled}
|
| 470 |
+
>
|
| 471 |
+
取消
|
| 472 |
+
</Button>
|
| 473 |
+
{method === "token" ? (
|
| 474 |
+
<Button
|
| 475 |
+
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
| 476 |
+
onClick={() => void handleImportTokenText()}
|
| 477 |
+
disabled={footerDisabled}
|
| 478 |
+
>
|
| 479 |
+
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
| 480 |
+
导入 Token
|
| 481 |
+
</Button>
|
| 482 |
+
) : null}
|
| 483 |
+
{method === "session" ? (
|
| 484 |
+
<Button
|
| 485 |
+
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
| 486 |
+
onClick={() => void handleImportSessionJson()}
|
| 487 |
+
disabled={footerDisabled}
|
| 488 |
+
>
|
| 489 |
+
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
| 490 |
+
导入 JSON
|
| 491 |
+
</Button>
|
| 492 |
+
) : null}
|
| 493 |
+
{method === "cpa" ? (
|
| 494 |
+
<Button
|
| 495 |
+
className={cn(
|
| 496 |
+
"h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800",
|
| 497 |
+
!pendingCpaImport ? "hidden" : "",
|
| 498 |
+
)}
|
| 499 |
+
onClick={() => setConfirmOpen(true)}
|
| 500 |
+
disabled={footerDisabled || !pendingCpaImport}
|
| 501 |
+
>
|
| 502 |
+
查看导入确认
|
| 503 |
+
</Button>
|
| 504 |
+
) : null}
|
| 505 |
+
</DialogFooter>
|
| 506 |
+
</DialogContent>
|
| 507 |
+
</Dialog>
|
| 508 |
+
|
| 509 |
+
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
| 510 |
+
<DialogContent className="rounded-2xl p-6">
|
| 511 |
+
<DialogHeader className="gap-2">
|
| 512 |
+
<DialogTitle>确认导入 CPA Token</DialogTitle>
|
| 513 |
+
<DialogDescription className="text-sm leading-6">
|
| 514 |
+
{pendingCpaImport
|
| 515 |
+
? `确认识别到 ${pendingCpaImport.parsedFileCount} 个 Token,是否确认导入?`
|
| 516 |
+
: "尚未读取到可导入的 Token。"}
|
| 517 |
+
{pendingCpaImport?.errorCount
|
| 518 |
+
? `,另有 ${pendingCpaImport.errorCount} 个文件未提取成功。`
|
| 519 |
+
: "。"}
|
| 520 |
+
</DialogDescription>
|
| 521 |
+
</DialogHeader>
|
| 522 |
+
<DialogFooter className="pt-2">
|
| 523 |
+
<Button
|
| 524 |
+
variant="secondary"
|
| 525 |
+
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
| 526 |
+
onClick={() => setConfirmOpen(false)}
|
| 527 |
+
disabled={isSubmitting}
|
| 528 |
+
>
|
| 529 |
+
返回
|
| 530 |
+
</Button>
|
| 531 |
+
<Button
|
| 532 |
+
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
| 533 |
+
onClick={() => void submitTokens(pendingCpaImport?.tokens ?? [], "CPA JSON 导入完成")}
|
| 534 |
+
disabled={isSubmitting || !pendingCpaImport}
|
| 535 |
+
>
|
| 536 |
+
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
| 537 |
+
确认导入
|
| 538 |
+
</Button>
|
| 539 |
+
</DialogFooter>
|
| 540 |
+
</DialogContent>
|
| 541 |
+
</Dialog>
|
| 542 |
+
</>
|
| 543 |
+
);
|
| 544 |
+
}
|
web/src/app/accounts/page.tsx
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
| 13 |
Download,
|
| 14 |
LoaderCircle,
|
| 15 |
Pencil,
|
| 16 |
-
Plus,
|
| 17 |
RefreshCw,
|
| 18 |
Search,
|
| 19 |
Trash2,
|
|
@@ -32,7 +31,6 @@ import {
|
|
| 32 |
DialogFooter,
|
| 33 |
DialogHeader,
|
| 34 |
DialogTitle,
|
| 35 |
-
DialogTrigger,
|
| 36 |
} from "@/components/ui/dialog";
|
| 37 |
import { Input } from "@/components/ui/input";
|
| 38 |
import {
|
|
@@ -42,9 +40,7 @@ import {
|
|
| 42 |
SelectTrigger,
|
| 43 |
SelectValue,
|
| 44 |
} from "@/components/ui/select";
|
| 45 |
-
import { Textarea } from "@/components/ui/textarea";
|
| 46 |
import {
|
| 47 |
-
createAccounts,
|
| 48 |
deleteAccounts,
|
| 49 |
fetchAccounts,
|
| 50 |
refreshAccounts,
|
|
@@ -55,6 +51,8 @@ import {
|
|
| 55 |
} from "@/lib/api";
|
| 56 |
import { cn } from "@/lib/utils";
|
| 57 |
|
|
|
|
|
|
|
| 58 |
const accountTypeOptions: { label: string; value: AccountType | "all" }[] = [
|
| 59 |
{ label: "全部类型", value: "all" },
|
| 60 |
{ label: "Free", value: "Free" },
|
|
@@ -168,15 +166,12 @@ export default function AccountsPage() {
|
|
| 168 |
const [statusFilter, setStatusFilter] = useState<AccountStatus | "all">("all");
|
| 169 |
const [page, setPage] = useState(1);
|
| 170 |
const [pageSize, setPageSize] = useState("10");
|
| 171 |
-
const [open, setOpen] = useState(false);
|
| 172 |
-
const [newTokens, setNewTokens] = useState("");
|
| 173 |
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
| 174 |
const [editType, setEditType] = useState<AccountType>("Free");
|
| 175 |
const [editStatus, setEditStatus] = useState<AccountStatus>("正常");
|
| 176 |
const [editQuota, setEditQuota] = useState("0");
|
| 177 |
const [isLoading, setIsLoading] = useState(true);
|
| 178 |
const [isRefreshing, setIsRefreshing] = useState(false);
|
| 179 |
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 180 |
const [isDeleting, setIsDeleting] = useState(false);
|
| 181 |
const [isUpdating, setIsUpdating] = useState(false);
|
| 182 |
|
|
@@ -258,41 +253,6 @@ export default function AccountsPage() {
|
|
| 258 |
return items;
|
| 259 |
}, [pageCount, safePage]);
|
| 260 |
|
| 261 |
-
const handleAddAccounts = async () => {
|
| 262 |
-
const tokens = newTokens
|
| 263 |
-
.split(/\r?\n/)
|
| 264 |
-
.map((item) => item.trim())
|
| 265 |
-
.filter(Boolean);
|
| 266 |
-
|
| 267 |
-
if (tokens.length === 0) {
|
| 268 |
-
toast.error("请先粘贴至少一个 Access Token");
|
| 269 |
-
return;
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
setIsSubmitting(true);
|
| 273 |
-
try {
|
| 274 |
-
const data = await createAccounts(tokens);
|
| 275 |
-
setAccounts(normalizeAccounts(data.items));
|
| 276 |
-
setSelectedIds([]);
|
| 277 |
-
setOpen(false);
|
| 278 |
-
setNewTokens("");
|
| 279 |
-
setPage(1);
|
| 280 |
-
if ((data.errors?.length ?? 0) > 0) {
|
| 281 |
-
const firstError = data.errors?.[0]?.error;
|
| 282 |
-
toast.error(
|
| 283 |
-
`新增 ${data.added ?? 0} 个账户,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
|
| 284 |
-
);
|
| 285 |
-
} else {
|
| 286 |
-
toast.success(`新增 ${data.added ?? 0} 个账户,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`);
|
| 287 |
-
}
|
| 288 |
-
} catch (error) {
|
| 289 |
-
const message = error instanceof Error ? error.message : "新增账户失败";
|
| 290 |
-
toast.error(message);
|
| 291 |
-
} finally {
|
| 292 |
-
setIsSubmitting(false);
|
| 293 |
-
}
|
| 294 |
-
};
|
| 295 |
-
|
| 296 |
const handleDeleteTokens = async (tokens: string[]) => {
|
| 297 |
if (tokens.length === 0) {
|
| 298 |
toast.error("请先选择要删除的账户");
|
|
@@ -394,7 +354,7 @@ export default function AccountsPage() {
|
|
| 394 |
variant="outline"
|
| 395 |
className="h-10 rounded-xl border-stone-200 bg-white/80 px-4 text-stone-700 hover:bg-white"
|
| 396 |
onClick={() => void loadAccounts()}
|
| 397 |
-
disabled={isLoading || isRefreshing ||
|
| 398 |
>
|
| 399 |
<RefreshCw className={cn("size-4", isLoading ? "animate-spin" : "")} />
|
| 400 |
刷新
|
|
@@ -403,56 +363,19 @@ export default function AccountsPage() {
|
|
| 403 |
variant="outline"
|
| 404 |
className="h-10 rounded-xl border-stone-200 bg-white/80 px-4 text-stone-700 hover:bg-white"
|
| 405 |
onClick={() => void handleRefreshAccounts(accounts.map((item) => item.access_token))}
|
| 406 |
-
disabled={isLoading || isRefreshing ||
|
| 407 |
>
|
| 408 |
<RefreshCw className={cn("size-4", isRefreshing ? "animate-spin" : "")} />
|
| 409 |
一键刷新所有账号信息和额度
|
| 410 |
</Button>
|
| 411 |
-
<
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
<DialogHeader className="gap-2">
|
| 420 |
-
<DialogTitle>新增账户</DialogTitle>
|
| 421 |
-
<DialogDescription className="text-sm leading-6">
|
| 422 |
-
每行一个 Access Token。保存后会自动拉取邮箱、类型和额度。
|
| 423 |
-
</DialogDescription>
|
| 424 |
-
</DialogHeader>
|
| 425 |
-
<div className="space-y-4">
|
| 426 |
-
<div className="space-y-2">
|
| 427 |
-
<label className="text-sm font-medium text-stone-700">Token 列表</label>
|
| 428 |
-
<Textarea
|
| 429 |
-
placeholder="粘贴 Token,每行一个..."
|
| 430 |
-
value={newTokens}
|
| 431 |
-
onChange={(event) => setNewTokens(event.target.value)}
|
| 432 |
-
className="min-h-48 resize-none rounded-xl border-stone-200"
|
| 433 |
-
/>
|
| 434 |
-
</div>
|
| 435 |
-
</div>
|
| 436 |
-
<DialogFooter className="pt-2">
|
| 437 |
-
<Button
|
| 438 |
-
variant="secondary"
|
| 439 |
-
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
| 440 |
-
onClick={() => setOpen(false)}
|
| 441 |
-
disabled={isSubmitting}
|
| 442 |
-
>
|
| 443 |
-
取消
|
| 444 |
-
</Button>
|
| 445 |
-
<Button
|
| 446 |
-
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
| 447 |
-
onClick={() => void handleAddAccounts()}
|
| 448 |
-
disabled={isSubmitting}
|
| 449 |
-
>
|
| 450 |
-
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
| 451 |
-
新增账户
|
| 452 |
-
</Button>
|
| 453 |
-
</DialogFooter>
|
| 454 |
-
</DialogContent>
|
| 455 |
-
</Dialog>
|
| 456 |
<Button
|
| 457 |
variant="outline"
|
| 458 |
className="h-10 rounded-xl border-stone-200 bg-white/80 px-4 text-stone-700 hover:bg-white"
|
|
|
|
| 13 |
Download,
|
| 14 |
LoaderCircle,
|
| 15 |
Pencil,
|
|
|
|
| 16 |
RefreshCw,
|
| 17 |
Search,
|
| 18 |
Trash2,
|
|
|
|
| 31 |
DialogFooter,
|
| 32 |
DialogHeader,
|
| 33 |
DialogTitle,
|
|
|
|
| 34 |
} from "@/components/ui/dialog";
|
| 35 |
import { Input } from "@/components/ui/input";
|
| 36 |
import {
|
|
|
|
| 40 |
SelectTrigger,
|
| 41 |
SelectValue,
|
| 42 |
} from "@/components/ui/select";
|
|
|
|
| 43 |
import {
|
|
|
|
| 44 |
deleteAccounts,
|
| 45 |
fetchAccounts,
|
| 46 |
refreshAccounts,
|
|
|
|
| 51 |
} from "@/lib/api";
|
| 52 |
import { cn } from "@/lib/utils";
|
| 53 |
|
| 54 |
+
import { AccountImportDialog } from "./components/account-import-dialog";
|
| 55 |
+
|
| 56 |
const accountTypeOptions: { label: string; value: AccountType | "all" }[] = [
|
| 57 |
{ label: "全部类型", value: "all" },
|
| 58 |
{ label: "Free", value: "Free" },
|
|
|
|
| 166 |
const [statusFilter, setStatusFilter] = useState<AccountStatus | "all">("all");
|
| 167 |
const [page, setPage] = useState(1);
|
| 168 |
const [pageSize, setPageSize] = useState("10");
|
|
|
|
|
|
|
| 169 |
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
| 170 |
const [editType, setEditType] = useState<AccountType>("Free");
|
| 171 |
const [editStatus, setEditStatus] = useState<AccountStatus>("正常");
|
| 172 |
const [editQuota, setEditQuota] = useState("0");
|
| 173 |
const [isLoading, setIsLoading] = useState(true);
|
| 174 |
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
|
| 175 |
const [isDeleting, setIsDeleting] = useState(false);
|
| 176 |
const [isUpdating, setIsUpdating] = useState(false);
|
| 177 |
|
|
|
|
| 253 |
return items;
|
| 254 |
}, [pageCount, safePage]);
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
const handleDeleteTokens = async (tokens: string[]) => {
|
| 257 |
if (tokens.length === 0) {
|
| 258 |
toast.error("请先选择要删除的账户");
|
|
|
|
| 354 |
variant="outline"
|
| 355 |
className="h-10 rounded-xl border-stone-200 bg-white/80 px-4 text-stone-700 hover:bg-white"
|
| 356 |
onClick={() => void loadAccounts()}
|
| 357 |
+
disabled={isLoading || isRefreshing || isDeleting}
|
| 358 |
>
|
| 359 |
<RefreshCw className={cn("size-4", isLoading ? "animate-spin" : "")} />
|
| 360 |
刷新
|
|
|
|
| 363 |
variant="outline"
|
| 364 |
className="h-10 rounded-xl border-stone-200 bg-white/80 px-4 text-stone-700 hover:bg-white"
|
| 365 |
onClick={() => void handleRefreshAccounts(accounts.map((item) => item.access_token))}
|
| 366 |
+
disabled={isLoading || isRefreshing || isDeleting || accounts.length === 0}
|
| 367 |
>
|
| 368 |
<RefreshCw className={cn("size-4", isRefreshing ? "animate-spin" : "")} />
|
| 369 |
一键刷新所有账号信息和额度
|
| 370 |
</Button>
|
| 371 |
+
<AccountImportDialog
|
| 372 |
+
disabled={isLoading || isRefreshing || isDeleting}
|
| 373 |
+
onImported={(items) => {
|
| 374 |
+
setAccounts(normalizeAccounts(items));
|
| 375 |
+
setSelectedIds([]);
|
| 376 |
+
setPage(1);
|
| 377 |
+
}}
|
| 378 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
<Button
|
| 380 |
variant="outline"
|
| 381 |
className="h-10 rounded-xl border-stone-200 bg-white/80 px-4 text-stone-700 hover:bg-white"
|
web/src/app/layout.tsx
CHANGED
|
@@ -14,7 +14,7 @@ export default function RootLayout({
|
|
| 14 |
children: React.ReactNode;
|
| 15 |
}>) {
|
| 16 |
return (
|
| 17 |
-
<html lang="zh-CN">
|
| 18 |
<body
|
| 19 |
className="antialiased"
|
| 20 |
style={{
|
|
|
|
| 14 |
children: React.ReactNode;
|
| 15 |
}>) {
|
| 16 |
return (
|
| 17 |
+
<html lang="zh-CN" suppressHydrationWarning>
|
| 18 |
<body
|
| 19 |
className="antialiased"
|
| 20 |
style={{
|