HouYunFei commited on
Commit
db809d9
·
1 Parent(s): 8a3f6b1

feat(accounts): 添加账户导入对话框功能

Browse files

- 实现了支持多种导入方式的对话框组件
- 添加了 Access Token 文本导入和 TXT 文件批量导入功能
- 集成了 Session JSON 和 CPA JSON 文件导入方式
- 实现了导入过程中的 Token 解析和验证机制
- 添加了导入确认对话框防止误操作
- 集成了 Toast 提示反馈导入结果

README.md CHANGED
@@ -49,12 +49,13 @@ POST /v1/images/generations
49
 
50
  ```bash
51
  git clone git@github.com:basketikun/chatgpt2api.git
52
- cp config.example.json config.json
53
- # 编辑 config.json密钥
 
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": "replace-with-your-auth-key",
3
- "tls-verify": true
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=config.tls_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(300)
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"![image_{index}]({image_data_url})")
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
- tls_verify: bool
21
 
 
 
 
 
22
 
23
- def _parse_bool(value: object, *, default: bool) -> bool:
24
- if value is None:
25
- return default
26
- if isinstance(value, bool):
27
- return value
28
- text = str(value).strip().lower()
29
- if text in {"1", "true", "yes", "on"}:
30
- return True
31
- if text in {"0", "false", "no", "off"}:
32
- return False
33
- raise ValueError("config 'tls-verify' must be a boolean")
 
34
 
35
 
36
  def _load_settings() -> AppSettings:
37
  DATA_DIR.mkdir(parents=True, exist_ok=True)
38
- raw_config: dict[str, object] = {}
39
 
 
 
 
40
  if CONFIG_FILE.exists():
41
- text = CONFIG_FILE.read_text(encoding="utf-8").strip()
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
- "config.json must contain a non-empty 'auth-key' or CHATGPT2API_AUTH_KEY must be set"
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
- tls_verify=tls_verify,
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=config.tls_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 || isSubmitting || isDeleting}
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 || isSubmitting || isDeleting || accounts.length === 0}
407
  >
408
  <RefreshCw className={cn("size-4", isRefreshing ? "animate-spin" : "")} />
409
  一键刷新所有账号信息和额度
410
  </Button>
411
- <Dialog open={open} onOpenChange={setOpen}>
412
- <DialogTrigger asChild>
413
- <Button className="h-10 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800">
414
- <Plus className="size-4" />
415
- 新增
416
- </Button>
417
- </DialogTrigger>
418
- <DialogContent showCloseButton={false} className="rounded-2xl p-6">
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={{