xiaolongmr commited on
Commit
519f85d
·
1 Parent(s): 8034201

Add configurable branding and login sessions

Browse files
api/app.py CHANGED
@@ -1,13 +1,15 @@
1
  from __future__ import annotations
2
 
3
  from contextlib import asynccontextmanager
 
4
  from mimetypes import guess_type
5
  from pathlib import Path
 
6
  from threading import Event
7
 
8
  from fastapi import FastAPI, HTTPException, Request
9
  from fastapi.middleware.cors import CORSMiddleware
10
- from fastapi.responses import FileResponse
11
 
12
  from api import accounts, ai, image_tasks, register, shop, system
13
  from api.errors import install_exception_handlers
@@ -19,6 +21,8 @@ STATIC_CACHE_CONTROL = "public, max-age=31536000, immutable"
19
  ASSET_CACHE_CONTROL = "public, max-age=86400"
20
  HTML_CACHE_CONTROL = "no-cache"
21
  COMPRESSIBLE_SUFFIXES = {".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"}
 
 
22
 
23
 
24
  def _web_cache_headers(full_path: str, asset: Path) -> dict[str, str]:
@@ -55,6 +59,49 @@ def _is_api_like_path(full_path: str) -> bool:
55
  return clean_path.startswith(("api/", "auth/", "v1/"))
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  def create_app() -> FastAPI:
59
  app_version = config.app_version
60
 
@@ -91,15 +138,13 @@ def create_app() -> FastAPI:
91
  async def serve_web(full_path: str, request: Request):
92
  asset = resolve_web_asset(full_path)
93
  if asset is not None:
94
- selected_asset, headers, media_type = _select_web_asset(request, full_path, asset)
95
- return FileResponse(selected_asset, headers=headers, media_type=media_type)
96
  if full_path.strip("/").startswith("_next/"):
97
  raise HTTPException(status_code=404, detail="Not Found")
98
  fallback = resolve_web_asset("")
99
  if fallback is None:
100
  raise HTTPException(status_code=404, detail="Not Found")
101
- selected_asset, headers, media_type = _select_web_asset(request, "", fallback)
102
- return FileResponse(selected_asset, headers=headers, media_type=media_type)
103
 
104
  @app.head("/{full_path:path}", include_in_schema=False)
105
  async def serve_web_head(full_path: str, request: Request):
 
1
  from __future__ import annotations
2
 
3
  from contextlib import asynccontextmanager
4
+ from html import escape
5
  from mimetypes import guess_type
6
  from pathlib import Path
7
+ import re
8
  from threading import Event
9
 
10
  from fastapi import FastAPI, HTTPException, Request
11
  from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import FileResponse, HTMLResponse, Response
13
 
14
  from api import accounts, ai, image_tasks, register, shop, system
15
  from api.errors import install_exception_handlers
 
21
  ASSET_CACHE_CONTROL = "public, max-age=86400"
22
  HTML_CACHE_CONTROL = "no-cache"
23
  COMPRESSIBLE_SUFFIXES = {".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"}
24
+ TITLE_RE = re.compile(r"<title>.*?</title>", re.IGNORECASE | re.DOTALL)
25
+ ICON_LINK_RE = re.compile(r"<link\b[^>]*\brel=[\"'][^\"']*(?:icon|apple-touch-icon)[^\"']*[\"'][^>]*>", re.IGNORECASE)
26
 
27
 
28
  def _web_cache_headers(full_path: str, asset: Path) -> dict[str, str]:
 
59
  return clean_path.startswith(("api/", "auth/", "v1/"))
60
 
61
 
62
+ def _guess_icon_type(href: str) -> str:
63
+ clean_href = href.split("?", 1)[0].split("#", 1)[0].lower()
64
+ if clean_href.endswith(".svg"):
65
+ return "image/svg+xml"
66
+ if clean_href.endswith((".jpg", ".jpeg")):
67
+ return "image/jpeg"
68
+ if clean_href.endswith(".webp"):
69
+ return "image/webp"
70
+ if clean_href.endswith(".ico"):
71
+ return "image/x-icon"
72
+ return "image/png"
73
+
74
+
75
+ def _inject_branding_head(html_text: str) -> str:
76
+ branding = config.get_branding()
77
+ title = escape(str(branding.get("site_title") or "ChatGPT 号池管理"), quote=False)
78
+ raw_icon_href = str(branding.get("site_logo_url") or "/favicon.ico")
79
+ icon_href = escape(raw_icon_href, quote=True)
80
+ icon_type = escape(_guess_icon_type(raw_icon_href), quote=True)
81
+ icon_markup = (
82
+ f'<link rel="icon" href="{icon_href}" type="{icon_type}" data-branding-icon="favicon">'
83
+ f'<link rel="apple-touch-icon" href="{icon_href}" data-branding-icon="apple-touch-icon">'
84
+ )
85
+ next_html = TITLE_RE.sub(f"<title>{title}</title>", html_text, count=1)
86
+ if next_html == html_text and "</head>" in next_html:
87
+ next_html = next_html.replace("</head>", f"<title>{title}</title></head>", 1)
88
+ next_html = ICON_LINK_RE.sub("", next_html)
89
+ if "</head>" in next_html:
90
+ next_html = next_html.replace("</head>", f"{icon_markup}</head>", 1)
91
+ return next_html
92
+
93
+
94
+ def _web_response(full_path: str, request: Request, asset: Path) -> Response:
95
+ if asset.suffix.lower() == ".html":
96
+ html_text = asset.read_text(encoding="utf-8")
97
+ return HTMLResponse(
98
+ _inject_branding_head(html_text),
99
+ headers=_web_cache_headers(full_path, asset),
100
+ )
101
+ selected_asset, headers, media_type = _select_web_asset(request, full_path, asset)
102
+ return FileResponse(selected_asset, headers=headers, media_type=media_type)
103
+
104
+
105
  def create_app() -> FastAPI:
106
  app_version = config.app_version
107
 
 
138
  async def serve_web(full_path: str, request: Request):
139
  asset = resolve_web_asset(full_path)
140
  if asset is not None:
141
+ return _web_response(full_path, request, asset)
 
142
  if full_path.strip("/").startswith("_next/"):
143
  raise HTTPException(status_code=404, detail="Not Found")
144
  fallback = resolve_web_asset("")
145
  if fallback is None:
146
  raise HTTPException(status_code=404, detail="Not Found")
147
+ return _web_response("", request, fallback)
 
148
 
149
  @app.head("/{full_path:path}", include_in_schema=False)
150
  async def serve_web_head(full_path: str, request: Request):
api/system.py CHANGED
@@ -63,6 +63,7 @@ def create_router(app_version: str) -> APIRouter:
63
  "subject_id": identity.get("id"),
64
  "name": identity.get("name"),
65
  "account_pool_enabled": bool(identity.get("role") == "admin" or identity.get("account_pool_enabled")),
 
66
  }
67
 
68
  @router.get("/api/auth/wechat/status")
@@ -70,8 +71,13 @@ def create_router(app_version: str) -> APIRouter:
70
  return {
71
  "enabled": config.wechat_login_enabled,
72
  "auto_register_enabled": config.wechat_auto_register_enabled,
 
73
  }
74
 
 
 
 
 
75
  @router.post("/api/auth/wechat/login")
76
  async def wechat_login(body: WechatLoginRequest):
77
  if not config.wechat_login_enabled:
@@ -121,6 +127,7 @@ def create_router(app_version: str) -> APIRouter:
121
  "account_pool_enabled": bool(item.get("account_pool_enabled")),
122
  "key": raw_key,
123
  "created": created,
 
124
  "credits": auth_service.credit_summary(item),
125
  "wechat": {
126
  "openid": openid,
 
63
  "subject_id": identity.get("id"),
64
  "name": identity.get("name"),
65
  "account_pool_enabled": bool(identity.get("role") == "admin" or identity.get("account_pool_enabled")),
66
+ "login_session_duration_hours": config.login_session_duration_hours_for_role(identity.get("role")),
67
  }
68
 
69
  @router.get("/api/auth/wechat/status")
 
71
  return {
72
  "enabled": config.wechat_login_enabled,
73
  "auto_register_enabled": config.wechat_auto_register_enabled,
74
+ "login_session_duration_hours": config.normal_user_login_session_duration_hours,
75
  }
76
 
77
+ @router.get("/api/branding")
78
+ async def get_branding():
79
+ return {"branding": config.get_branding()}
80
+
81
  @router.post("/api/auth/wechat/login")
82
  async def wechat_login(body: WechatLoginRequest):
83
  if not config.wechat_login_enabled:
 
127
  "account_pool_enabled": bool(item.get("account_pool_enabled")),
128
  "key": raw_key,
129
  "created": created,
130
+ "login_session_duration_hours": config.normal_user_login_session_duration_hours,
131
  "credits": auth_service.credit_summary(item),
132
  "wechat": {
133
  "openid": openid,
services/config.py CHANGED
@@ -47,6 +47,25 @@ DEFAULT_IMAGE_STORAGE = {
47
  "public_base_url": "",
48
  }
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  ANNOUNCEMENT_TYPES = {"notice", "timeline"}
51
  ANNOUNCEMENT_TARGETS = {"internal", "normal", "all"}
52
  SHARED_ANNOUNCEMENTS_KEY = "announcements"
@@ -56,6 +75,8 @@ SHARED_SETTINGS_KEYS = (
56
  "normal_user_daily_credits",
57
  "wechat_login_enabled",
58
  "wechat_auto_register_enabled",
 
 
59
  )
60
  SHARED_SETTINGS_CACHE_SECONDS = 10.0
61
 
@@ -84,6 +105,26 @@ def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> in
84
  return max(minimum, normalized)
85
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def _normalize_backup_include(value: object) -> dict[str, bool]:
88
  source = value if isinstance(value, dict) else {}
89
  normalized = dict(DEFAULT_BACKUP_INCLUDE)
@@ -222,6 +263,20 @@ def _normalize_shared_settings(value: object, fallback: dict[str, object] | None
222
  source.get("wechat_auto_register_enabled"),
223
  _normalize_bool(fallback.get("wechat_auto_register_enabled"), True),
224
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  }
226
 
227
 
@@ -378,6 +433,20 @@ class ConfigStore:
378
  def wechat_auto_register_enabled(self) -> bool:
379
  return _normalize_bool(self._load_shared_settings().get("wechat_auto_register_enabled"), True)
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  @property
382
  def shop_purchase_url(self) -> str:
383
  return str(self.data.get("shop_purchase_url") or "").strip()
@@ -481,6 +550,9 @@ class ConfigStore:
481
  data["normal_user_daily_credits"] = self.normal_user_daily_credits
482
  data["wechat_login_enabled"] = self.wechat_login_enabled
483
  data["wechat_auto_register_enabled"] = self.wechat_auto_register_enabled
 
 
 
484
  data["shop_purchase_url"] = self.shop_purchase_url
485
  data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
486
  data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
@@ -509,6 +581,8 @@ class ConfigStore:
509
  _validate_image_storage_settings(next_data["image_storage"])
510
  if "image_proxy_base_url" in next_data:
511
  next_data["image_proxy_base_url"] = _normalize_image_proxy_base_url(next_data.get("image_proxy_base_url"))
 
 
512
  if any(key in incoming_data for key in SHARED_SETTINGS_KEYS):
513
  next_data.update(self._save_shared_settings(_normalize_shared_settings(next_data, self.data)))
514
  if "announcements" in next_data:
 
47
  "public_base_url": "",
48
  }
49
 
50
+ BRANDING_KEYS = (
51
+ "site_title",
52
+ "site_logo_url",
53
+ "site_header_title",
54
+ "site_external_link_label",
55
+ "site_external_link_url",
56
+ "site_external_link_icon_url",
57
+ "site_external_link_icon_svg",
58
+ )
59
+ DEFAULT_BRANDING = {
60
+ "site_title": "ChatGPT 号池管理",
61
+ "site_logo_url": "",
62
+ "site_header_title": "chatgpt2api",
63
+ "site_external_link_label": "GitHub",
64
+ "site_external_link_url": "https://github.com/basketikun/chatgpt2api",
65
+ "site_external_link_icon_url": "",
66
+ "site_external_link_icon_svg": "",
67
+ }
68
+
69
  ANNOUNCEMENT_TYPES = {"notice", "timeline"}
70
  ANNOUNCEMENT_TARGETS = {"internal", "normal", "all"}
71
  SHARED_ANNOUNCEMENTS_KEY = "announcements"
 
75
  "normal_user_daily_credits",
76
  "wechat_login_enabled",
77
  "wechat_auto_register_enabled",
78
+ "internal_user_login_session_duration_hours",
79
+ "normal_user_login_session_duration_hours",
80
  )
81
  SHARED_SETTINGS_CACHE_SECONDS = 10.0
82
 
 
105
  return max(minimum, normalized)
106
 
107
 
108
+ def _normalize_text(value: object, default: str = "", max_length: int = 500) -> str:
109
+ if value is None:
110
+ return default
111
+ normalized = str(value).strip()
112
+ return normalized[:max_length]
113
+
114
+
115
+ def _normalize_branding_settings(value: dict[str, object] | None) -> dict[str, str]:
116
+ source = value if isinstance(value, dict) else {}
117
+ return {
118
+ "site_title": _normalize_text(source.get("site_title"), DEFAULT_BRANDING["site_title"], 80) or DEFAULT_BRANDING["site_title"],
119
+ "site_logo_url": _normalize_text(source.get("site_logo_url"), DEFAULT_BRANDING["site_logo_url"], 500),
120
+ "site_header_title": _normalize_text(source.get("site_header_title"), DEFAULT_BRANDING["site_header_title"], 40) or DEFAULT_BRANDING["site_header_title"],
121
+ "site_external_link_label": _normalize_text(source.get("site_external_link_label"), DEFAULT_BRANDING["site_external_link_label"], 40),
122
+ "site_external_link_url": _normalize_text(source.get("site_external_link_url"), DEFAULT_BRANDING["site_external_link_url"], 500),
123
+ "site_external_link_icon_url": _normalize_text(source.get("site_external_link_icon_url"), DEFAULT_BRANDING["site_external_link_icon_url"], 500),
124
+ "site_external_link_icon_svg": _normalize_text(source.get("site_external_link_icon_svg"), DEFAULT_BRANDING["site_external_link_icon_svg"], 6000),
125
+ }
126
+
127
+
128
  def _normalize_backup_include(value: object) -> dict[str, bool]:
129
  source = value if isinstance(value, dict) else {}
130
  normalized = dict(DEFAULT_BACKUP_INCLUDE)
 
263
  source.get("wechat_auto_register_enabled"),
264
  _normalize_bool(fallback.get("wechat_auto_register_enabled"), True),
265
  ),
266
+ "internal_user_login_session_duration_hours": _normalize_positive_int(
267
+ source.get("internal_user_login_session_duration_hours"),
268
+ _normalize_positive_int(fallback.get("internal_user_login_session_duration_hours"), 0, 0),
269
+ 0,
270
+ ),
271
+ "normal_user_login_session_duration_hours": _normalize_positive_int(
272
+ source.get("normal_user_login_session_duration_hours"),
273
+ _normalize_positive_int(
274
+ fallback.get("normal_user_login_session_duration_hours", fallback.get("login_session_duration_hours")),
275
+ 72,
276
+ 0,
277
+ ),
278
+ 0,
279
+ ),
280
  }
281
 
282
 
 
433
  def wechat_auto_register_enabled(self) -> bool:
434
  return _normalize_bool(self._load_shared_settings().get("wechat_auto_register_enabled"), True)
435
 
436
+ @property
437
+ def internal_user_login_session_duration_hours(self) -> int:
438
+ return int(self._load_shared_settings().get("internal_user_login_session_duration_hours") or 0)
439
+
440
+ @property
441
+ def normal_user_login_session_duration_hours(self) -> int:
442
+ return int(self._load_shared_settings().get("normal_user_login_session_duration_hours") or 0)
443
+
444
+ def login_session_duration_hours_for_role(self, role: object) -> int:
445
+ return self.normal_user_login_session_duration_hours if str(role or "").strip() == "normal" else self.internal_user_login_session_duration_hours
446
+
447
+ def get_branding(self) -> dict[str, str]:
448
+ return _normalize_branding_settings(self.data)
449
+
450
  @property
451
  def shop_purchase_url(self) -> str:
452
  return str(self.data.get("shop_purchase_url") or "").strip()
 
550
  data["normal_user_daily_credits"] = self.normal_user_daily_credits
551
  data["wechat_login_enabled"] = self.wechat_login_enabled
552
  data["wechat_auto_register_enabled"] = self.wechat_auto_register_enabled
553
+ data["internal_user_login_session_duration_hours"] = self.internal_user_login_session_duration_hours
554
+ data["normal_user_login_session_duration_hours"] = self.normal_user_login_session_duration_hours
555
+ data.update(self.get_branding())
556
  data["shop_purchase_url"] = self.shop_purchase_url
557
  data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
558
  data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
 
581
  _validate_image_storage_settings(next_data["image_storage"])
582
  if "image_proxy_base_url" in next_data:
583
  next_data["image_proxy_base_url"] = _normalize_image_proxy_base_url(next_data.get("image_proxy_base_url"))
584
+ if any(key in incoming_data for key in BRANDING_KEYS):
585
+ next_data.update(_normalize_branding_settings(next_data))
586
  if any(key in incoming_data for key in SHARED_SETTINGS_KEYS):
587
  next_data.update(self._save_shared_settings(_normalize_shared_settings(next_data, self.data)))
588
  if "announcements" in next_data:
web/src/app/login/page.tsx CHANGED
@@ -11,7 +11,7 @@ import { Input } from "@/components/ui/input";
11
  import { fetchWechatLoginStatus, login, wechatLogin } from "@/lib/api";
12
  import { useRedirectIfAuthenticated } from "@/lib/use-auth-guard";
13
  import { cn } from "@/lib/utils";
14
- import { getDefaultRouteForRole, setStoredAuthSession } from "@/store/auth";
15
 
16
  const WECHAT_QR_CODE_URL = "https://wx.z-l.top/qrcode.png";
17
 
@@ -66,6 +66,7 @@ export default function LoginPage() {
66
  subjectId: data.subject_id,
67
  name: data.name,
68
  accountPoolEnabled: Boolean(data.account_pool_enabled),
 
69
  });
70
  router.replace(getDefaultRouteForRole(data.role));
71
  } catch (error) {
@@ -97,6 +98,7 @@ export default function LoginPage() {
97
  name: data.name,
98
  accountPoolEnabled: Boolean(data.account_pool_enabled),
99
  wechatOpenid: data.wechat?.openid,
 
100
  });
101
  toast.success(data.created ? "微信登录成功,已自动创建账号" : "微信登录成功");
102
  router.replace(getDefaultRouteForRole(data.role));
 
11
  import { fetchWechatLoginStatus, login, wechatLogin } from "@/lib/api";
12
  import { useRedirectIfAuthenticated } from "@/lib/use-auth-guard";
13
  import { cn } from "@/lib/utils";
14
+ import { buildSessionExpiresAt, getDefaultRouteForRole, setStoredAuthSession } from "@/store/auth";
15
 
16
  const WECHAT_QR_CODE_URL = "https://wx.z-l.top/qrcode.png";
17
 
 
66
  subjectId: data.subject_id,
67
  name: data.name,
68
  accountPoolEnabled: Boolean(data.account_pool_enabled),
69
+ expiresAt: buildSessionExpiresAt(data.login_session_duration_hours),
70
  });
71
  router.replace(getDefaultRouteForRole(data.role));
72
  } catch (error) {
 
98
  name: data.name,
99
  accountPoolEnabled: Boolean(data.account_pool_enabled),
100
  wechatOpenid: data.wechat?.openid,
101
+ expiresAt: buildSessionExpiresAt(data.login_session_duration_hours),
102
  });
103
  toast.success(data.created ? "微信登录成功,已自动创建账号" : "微信登录成功");
104
  router.replace(getDefaultRouteForRole(data.role));
web/src/app/settings/components/config-card.tsx CHANGED
@@ -30,6 +30,14 @@ export function ConfigCard() {
30
  const setNormalUserDailyCredits = useSettingsStore((state) => state.setNormalUserDailyCredits);
31
  const setWechatLoginEnabled = useSettingsStore((state) => state.setWechatLoginEnabled);
32
  const setWechatAutoRegisterEnabled = useSettingsStore((state) => state.setWechatAutoRegisterEnabled);
 
 
 
 
 
 
 
 
33
  const setShopPurchaseUrl = useSettingsStore((state) => state.setShopPurchaseUrl);
34
  const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
35
  const setAutoRemoveRateLimitedAccounts = useSettingsStore((state) => state.setAutoRemoveRateLimitedAccounts);
@@ -88,6 +96,72 @@ export function ConfigCard() {
88
  管理员登录密钥继续从部署配置读取,不再在此页面展示;如需分发给其他人,请在下方创建内部用户密钥。
89
  </div>
90
  <div className="grid gap-4 md:grid-cols-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  <div className="space-y-2">
92
  <label className="text-sm text-stone-700">账号刷新间隔</label>
93
  <Input
@@ -252,6 +326,30 @@ export function ConfigCard() {
252
  </span>
253
  </span>
254
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  <div className="space-y-2">
256
  <label className="text-sm text-stone-700">卡密购买地址</label>
257
  <Input
 
30
  const setNormalUserDailyCredits = useSettingsStore((state) => state.setNormalUserDailyCredits);
31
  const setWechatLoginEnabled = useSettingsStore((state) => state.setWechatLoginEnabled);
32
  const setWechatAutoRegisterEnabled = useSettingsStore((state) => state.setWechatAutoRegisterEnabled);
33
+ const setInternalUserLoginSessionDurationHours = useSettingsStore((state) => state.setInternalUserLoginSessionDurationHours);
34
+ const setNormalUserLoginSessionDurationHours = useSettingsStore((state) => state.setNormalUserLoginSessionDurationHours);
35
+ const setSiteTitle = useSettingsStore((state) => state.setSiteTitle);
36
+ const setSiteLogoUrl = useSettingsStore((state) => state.setSiteLogoUrl);
37
+ const setSiteHeaderTitle = useSettingsStore((state) => state.setSiteHeaderTitle);
38
+ const setSiteExternalLinkLabel = useSettingsStore((state) => state.setSiteExternalLinkLabel);
39
+ const setSiteExternalLinkUrl = useSettingsStore((state) => state.setSiteExternalLinkUrl);
40
+ const setSiteExternalLinkIconSvg = useSettingsStore((state) => state.setSiteExternalLinkIconSvg);
41
  const setShopPurchaseUrl = useSettingsStore((state) => state.setShopPurchaseUrl);
42
  const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
43
  const setAutoRemoveRateLimitedAccounts = useSettingsStore((state) => state.setAutoRemoveRateLimitedAccounts);
 
96
  管理员登录密钥继续从部署配置读取,不再在此页面展示;如需分发给其他人,请在下方创建内部用户密钥。
97
  </div>
98
  <div className="grid gap-4 md:grid-cols-2">
99
+ <div className="space-y-3 rounded-xl border border-stone-200 bg-white px-4 py-3 md:col-span-2">
100
+ <div>
101
+ <div className="text-sm font-medium text-stone-900">网站品牌显示</div>
102
+ <p className="mt-1 text-xs leading-5 text-stone-500">
103
+ 可自定义浏览器标题、Logo/favicon、左上角名称,以及旁边链接的文字和地址。
104
+ </p>
105
+ </div>
106
+ <div className="grid gap-4 md:grid-cols-2">
107
+ <div className="space-y-2">
108
+ <label className="text-sm text-stone-700">网站标题</label>
109
+ <Input
110
+ value={String(config?.site_title || "")}
111
+ onChange={(event) => setSiteTitle(event.target.value)}
112
+ placeholder="ChatGPT 号池管理"
113
+ className="h-10 rounded-xl border-stone-200 bg-white"
114
+ />
115
+ </div>
116
+ <div className="space-y-2">
117
+ <label className="text-sm text-stone-700">网站 Logo 地址</label>
118
+ <Input
119
+ value={String(config?.site_logo_url || "")}
120
+ onChange={(event) => setSiteLogoUrl(event.target.value)}
121
+ placeholder="/favicon.ico 或 https://example.com/logo.png"
122
+ className="h-10 rounded-xl border-stone-200 bg-white"
123
+ />
124
+ </div>
125
+ <div className="space-y-2">
126
+ <label className="text-sm text-stone-700">左上角名称</label>
127
+ <Input
128
+ value={String(config?.site_header_title || "")}
129
+ onChange={(event) => setSiteHeaderTitle(event.target.value)}
130
+ placeholder="chatgpt2api"
131
+ className="h-10 rounded-xl border-stone-200 bg-white"
132
+ />
133
+ </div>
134
+ <div className="space-y-2">
135
+ <label className="text-sm text-stone-700">旁边链接文字</label>
136
+ <Input
137
+ value={String(config?.site_external_link_label ?? "")}
138
+ onChange={(event) => setSiteExternalLinkLabel(event.target.value)}
139
+ placeholder="GitHub"
140
+ className="h-10 rounded-xl border-stone-200 bg-white"
141
+ />
142
+ </div>
143
+ <div className="space-y-2">
144
+ <label className="text-sm text-stone-700">旁边链接 SVG 图标代码</label>
145
+ <Textarea
146
+ value={String(config?.site_external_link_icon_svg || "")}
147
+ onChange={(event) => setSiteExternalLinkIconSvg(event.target.value)}
148
+ placeholder={'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">...</svg>'}
149
+ className="min-h-24 rounded-xl border-stone-200 bg-white font-mono text-xs"
150
+ />
151
+ <p className="text-xs text-stone-500">建议使用 currentColor 作为 fill 或 stroke,这样 hover 时图标会跟随文字一起变色;留空则使用默认图标。</p>
152
+ </div>
153
+ <div className="space-y-2">
154
+ <label className="text-sm text-stone-700">旁边链接地址</label>
155
+ <Input
156
+ value={String(config?.site_external_link_url ?? "")}
157
+ onChange={(event) => setSiteExternalLinkUrl(event.target.value)}
158
+ placeholder="https://github.com/basketikun/chatgpt2api"
159
+ className="h-10 rounded-xl border-stone-200 bg-white"
160
+ />
161
+ <p className="text-xs text-stone-500">链接文字或地址留空时,顶部不会显示这个链接。</p>
162
+ </div>
163
+ </div>
164
+ </div>
165
  <div className="space-y-2">
166
  <label className="text-sm text-stone-700">账号刷新间隔</label>
167
  <Input
 
326
  </span>
327
  </span>
328
  </label>
329
+ <div className="space-y-2">
330
+ <label className="text-sm text-stone-700">内部用户登录会话时长</label>
331
+ <Input
332
+ value={String(config?.internal_user_login_session_duration_hours ?? 0)}
333
+ onChange={(event) => setInternalUserLoginSessionDurationHours(event.target.value)}
334
+ type="number"
335
+ min="0"
336
+ placeholder="0"
337
+ className="h-10 rounded-xl border-stone-200 bg-white"
338
+ />
339
+ <p className="text-xs text-stone-500">单位小时,0 表示长期有效;适用于管理员和内部密钥登录。</p>
340
+ </div>
341
+ <div className="space-y-2">
342
+ <label className="text-sm text-stone-700">微信/普通用户登录会话时长</label>
343
+ <Input
344
+ value={String(config?.normal_user_login_session_duration_hours ?? 72)}
345
+ onChange={(event) => setNormalUserLoginSessionDurationHours(event.target.value)}
346
+ type="number"
347
+ min="0"
348
+ placeholder="72"
349
+ className="h-10 rounded-xl border-stone-200 bg-white"
350
+ />
351
+ <p className="text-xs text-stone-500">单位小时,默认 72 小时(3 天);过期后需要重新用公众号验证码或密钥登录。</p>
352
+ </div>
353
  <div className="space-y-2">
354
  <label className="text-sm text-stone-700">卡密购买地址</label>
355
  <Input
web/src/app/settings/store.ts CHANGED
@@ -92,6 +92,15 @@ function normalizeConfig(config: SettingsConfig): SettingsConfig {
92
  normal_user_daily_credits: Number(config.normal_user_daily_credits || 0),
93
  wechat_login_enabled: config.wechat_login_enabled !== false,
94
  wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
 
 
 
 
 
 
 
 
 
95
  shop_purchase_url: String(config.shop_purchase_url || ""),
96
  announcements: normalizeAnnouncements(config.announcements),
97
  auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
@@ -239,6 +248,15 @@ type SettingsStore = {
239
  setNormalUserDailyCredits: (value: string) => void;
240
  setWechatLoginEnabled: (value: boolean) => void;
241
  setWechatAutoRegisterEnabled: (value: boolean) => void;
 
 
 
 
 
 
 
 
 
242
  setShopPurchaseUrl: (value: string) => void;
243
  setAnnouncements: (items: Announcement[]) => void;
244
  setAutoRemoveInvalidAccounts: (value: boolean) => void;
@@ -383,6 +401,15 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
383
  normal_user_daily_credits: Math.max(0, Number(config.normal_user_daily_credits) || 0),
384
  wechat_login_enabled: config.wechat_login_enabled !== false,
385
  wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
 
 
 
 
 
 
 
 
 
386
  shop_purchase_url: String(config.shop_purchase_url || "").trim(),
387
  announcements: normalizeAnnouncements(config.announcements).map((item) => ({
388
  ...item,
@@ -480,6 +507,42 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
480
  set((state) => state.config ? { config: { ...state.config, wechat_auto_register_enabled: value } } : {});
481
  },
482
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  setShopPurchaseUrl: (value) => {
484
  set((state) => state.config ? { config: { ...state.config, shop_purchase_url: value } } : {});
485
  },
 
92
  normal_user_daily_credits: Number(config.normal_user_daily_credits || 0),
93
  wechat_login_enabled: config.wechat_login_enabled !== false,
94
  wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
95
+ internal_user_login_session_duration_hours: Math.max(0, Number(config.internal_user_login_session_duration_hours) || 0),
96
+ normal_user_login_session_duration_hours: Math.max(0, Number(config.normal_user_login_session_duration_hours ?? config.login_session_duration_hours ?? 72)),
97
+ site_title: String(config.site_title || "ChatGPT 号池管理"),
98
+ site_logo_url: String(config.site_logo_url || ""),
99
+ site_header_title: String(config.site_header_title || "chatgpt2api"),
100
+ site_external_link_label: String(config.site_external_link_label ?? "GitHub"),
101
+ site_external_link_url: String(config.site_external_link_url ?? "https://github.com/basketikun/chatgpt2api"),
102
+ site_external_link_icon_url: String(config.site_external_link_icon_url || ""),
103
+ site_external_link_icon_svg: String(config.site_external_link_icon_svg || ""),
104
  shop_purchase_url: String(config.shop_purchase_url || ""),
105
  announcements: normalizeAnnouncements(config.announcements),
106
  auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
 
248
  setNormalUserDailyCredits: (value: string) => void;
249
  setWechatLoginEnabled: (value: boolean) => void;
250
  setWechatAutoRegisterEnabled: (value: boolean) => void;
251
+ setInternalUserLoginSessionDurationHours: (value: string) => void;
252
+ setNormalUserLoginSessionDurationHours: (value: string) => void;
253
+ setSiteTitle: (value: string) => void;
254
+ setSiteLogoUrl: (value: string) => void;
255
+ setSiteHeaderTitle: (value: string) => void;
256
+ setSiteExternalLinkLabel: (value: string) => void;
257
+ setSiteExternalLinkUrl: (value: string) => void;
258
+ setSiteExternalLinkIconUrl: (value: string) => void;
259
+ setSiteExternalLinkIconSvg: (value: string) => void;
260
  setShopPurchaseUrl: (value: string) => void;
261
  setAnnouncements: (items: Announcement[]) => void;
262
  setAutoRemoveInvalidAccounts: (value: boolean) => void;
 
401
  normal_user_daily_credits: Math.max(0, Number(config.normal_user_daily_credits) || 0),
402
  wechat_login_enabled: config.wechat_login_enabled !== false,
403
  wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
404
+ internal_user_login_session_duration_hours: Math.max(0, Number(config.internal_user_login_session_duration_hours) || 0),
405
+ normal_user_login_session_duration_hours: Math.max(0, Number(config.normal_user_login_session_duration_hours) || 0),
406
+ site_title: String(config.site_title || "").trim(),
407
+ site_logo_url: String(config.site_logo_url || "").trim(),
408
+ site_header_title: String(config.site_header_title || "").trim(),
409
+ site_external_link_label: String(config.site_external_link_label ?? "").trim(),
410
+ site_external_link_url: String(config.site_external_link_url ?? "").trim(),
411
+ site_external_link_icon_url: String(config.site_external_link_icon_url || "").trim(),
412
+ site_external_link_icon_svg: String(config.site_external_link_icon_svg || "").trim(),
413
  shop_purchase_url: String(config.shop_purchase_url || "").trim(),
414
  announcements: normalizeAnnouncements(config.announcements).map((item) => ({
415
  ...item,
 
507
  set((state) => state.config ? { config: { ...state.config, wechat_auto_register_enabled: value } } : {});
508
  },
509
 
510
+ setInternalUserLoginSessionDurationHours: (value) => {
511
+ set((state) => state.config ? { config: { ...state.config, internal_user_login_session_duration_hours: value } } : {});
512
+ },
513
+
514
+ setNormalUserLoginSessionDurationHours: (value) => {
515
+ set((state) => state.config ? { config: { ...state.config, normal_user_login_session_duration_hours: value } } : {});
516
+ },
517
+
518
+ setSiteTitle: (value) => {
519
+ set((state) => state.config ? { config: { ...state.config, site_title: value } } : {});
520
+ },
521
+
522
+ setSiteLogoUrl: (value) => {
523
+ set((state) => state.config ? { config: { ...state.config, site_logo_url: value } } : {});
524
+ },
525
+
526
+ setSiteHeaderTitle: (value) => {
527
+ set((state) => state.config ? { config: { ...state.config, site_header_title: value } } : {});
528
+ },
529
+
530
+ setSiteExternalLinkLabel: (value) => {
531
+ set((state) => state.config ? { config: { ...state.config, site_external_link_label: value } } : {});
532
+ },
533
+
534
+ setSiteExternalLinkUrl: (value) => {
535
+ set((state) => state.config ? { config: { ...state.config, site_external_link_url: value } } : {});
536
+ },
537
+
538
+ setSiteExternalLinkIconUrl: (value) => {
539
+ set((state) => state.config ? { config: { ...state.config, site_external_link_icon_url: value } } : {});
540
+ },
541
+
542
+ setSiteExternalLinkIconSvg: (value) => {
543
+ set((state) => state.config ? { config: { ...state.config, site_external_link_icon_svg: value } } : {});
544
+ },
545
+
546
  setShopPurchaseUrl: (value) => {
547
  set((state) => state.config ? { config: { ...state.config, shop_purchase_url: value } } : {});
548
  },
web/src/components/app-shell.tsx CHANGED
@@ -4,9 +4,11 @@ import { usePathname } from "next/navigation";
4
  import type { ReactNode } from "react";
5
 
6
  import { TopNav } from "@/components/top-nav";
 
7
 
8
  export function AppShell({ children }: { children: ReactNode }) {
9
  const pathname = usePathname();
 
10
  const normalizedPath = pathname.replace(/\/+$/, "") || "/";
11
  const isLoginPage = normalizedPath === "/login";
12
  const isImagePage = normalizedPath === "/image";
@@ -20,7 +22,7 @@ export function AppShell({ children }: { children: ReactNode }) {
20
  <main className="app-page-background min-h-screen overflow-x-hidden px-4 pt-0 pb-2 text-stone-900 sm:px-6 lg:px-8">
21
  <div className="app-page-background fixed inset-x-0 top-0 z-40 px-4 pt-[env(safe-area-inset-top)] pb-2 sm:px-6 sm:pt-2 lg:px-8">
22
  <div className="mx-auto max-w-[1440px]">
23
- <TopNav />
24
  </div>
25
  </div>
26
  <div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[calc(env(safe-area-inset-top)+4.5rem)] sm:gap-5 sm:pt-[5.25rem]">
@@ -33,7 +35,7 @@ export function AppShell({ children }: { children: ReactNode }) {
33
  return (
34
  <main className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 pt-0 pb-2 text-stone-900 sm:px-6 sm:pt-2 lg:px-8">
35
  <div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[env(safe-area-inset-top)] sm:gap-5 sm:pt-0">
36
- <TopNav />
37
  {children}
38
  </div>
39
  </main>
 
4
  import type { ReactNode } from "react";
5
 
6
  import { TopNav } from "@/components/top-nav";
7
+ import { useBranding } from "@/lib/branding";
8
 
9
  export function AppShell({ children }: { children: ReactNode }) {
10
  const pathname = usePathname();
11
+ const branding = useBranding({ applyDocument: true });
12
  const normalizedPath = pathname.replace(/\/+$/, "") || "/";
13
  const isLoginPage = normalizedPath === "/login";
14
  const isImagePage = normalizedPath === "/image";
 
22
  <main className="app-page-background min-h-screen overflow-x-hidden px-4 pt-0 pb-2 text-stone-900 sm:px-6 lg:px-8">
23
  <div className="app-page-background fixed inset-x-0 top-0 z-40 px-4 pt-[env(safe-area-inset-top)] pb-2 sm:px-6 sm:pt-2 lg:px-8">
24
  <div className="mx-auto max-w-[1440px]">
25
+ <TopNav branding={branding} />
26
  </div>
27
  </div>
28
  <div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[calc(env(safe-area-inset-top)+4.5rem)] sm:gap-5 sm:pt-[5.25rem]">
 
35
  return (
36
  <main className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 pt-0 pb-2 text-stone-900 sm:px-6 sm:pt-2 lg:px-8">
37
  <div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[env(safe-area-inset-top)] sm:gap-5 sm:pt-0">
38
+ <TopNav branding={branding} />
39
  {children}
40
  </div>
41
  </main>
web/src/components/top-nav.tsx CHANGED
@@ -2,12 +2,13 @@
2
 
3
  import Link from "next/link";
4
  import { useEffect, useState } from "react";
5
- import { Github } from "lucide-react";
6
  import { usePathname } from "next/navigation";
7
 
8
  import webConfig from "@/constants/common-env";
9
  import { AnnouncementsPopover } from "@/components/announcements-popover";
10
  import { getValidatedAuthSession } from "@/lib/auth-session";
 
11
  import { cn } from "@/lib/utils";
12
  import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
13
 
@@ -35,7 +36,58 @@ function getRoleLabel(role: StoredAuthSession["role"]) {
35
  return "内部用户";
36
  }
37
 
38
- export function TopNav() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  const pathname = usePathname();
40
  const [session, setSession] = useState<StoredAuthSession | null | undefined>(undefined);
41
 
@@ -91,6 +143,13 @@ export function TopNav() {
91
  : userNavItems.filter((item) => item.href !== "/accounts");
92
  const roleLabel = getRoleLabel(session.role);
93
  const displayName = session.name.trim() || roleLabel;
 
 
 
 
 
 
 
94
 
95
  return (
96
  <header className="border-b border-stone-100/50">
@@ -98,20 +157,29 @@ export function TopNav() {
98
  <div className="flex items-center justify-between gap-2 sm:justify-start sm:gap-3">
99
  <Link
100
  href="/image"
101
- className="shrink-0 py-1 text-[15px] font-bold tracking-tight text-stone-950 transition hover:text-stone-700"
102
  >
103
- chatgpt2api
 
104
  </Link>
105
- <a
106
- href="https://github.com/basketikun/chatgpt2api"
107
- target="_blank"
108
- rel="noreferrer"
109
- className="inline-flex items-center gap-1.5 py-1 text-sm text-stone-400 transition hover:text-stone-700"
110
- aria-label="GitHub repository"
111
- >
112
- <Github className="size-4" />
113
- <span className="hidden md:inline">GitHub</span>
114
- </a>
 
 
 
 
 
 
 
 
115
  <button
116
  type="button"
117
  className="ml-auto shrink-0 py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:hidden"
 
2
 
3
  import Link from "next/link";
4
  import { useEffect, useState } from "react";
5
+ import { ExternalLink, Github } from "lucide-react";
6
  import { usePathname } from "next/navigation";
7
 
8
  import webConfig from "@/constants/common-env";
9
  import { AnnouncementsPopover } from "@/components/announcements-popover";
10
  import { getValidatedAuthSession } from "@/lib/auth-session";
11
+ import { DEFAULT_BRANDING, type normalizeBranding } from "@/lib/branding";
12
  import { cn } from "@/lib/utils";
13
  import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
14
 
 
36
  return "内部用户";
37
  }
38
 
39
+ function sanitizeSvgMarkup(markup: string) {
40
+ const trimmed = markup.trim();
41
+ if (!/^\s*<svg[\s>][\s\S]*<\/svg>\s*$/i.test(trimmed)) {
42
+ return "";
43
+ }
44
+ const safeMarkup = trimmed
45
+ .replace(/<\s*(script|foreignObject)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, "")
46
+ .replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
47
+ .replace(/\s+(?:href|xlink:href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi, "");
48
+ return colorizeSvgForHover(addMissingViewBox(safeMarkup));
49
+ }
50
+
51
+ function readSvgAttribute(markup: string, name: string) {
52
+ const match = markup.match(new RegExp(`<svg\\b[^>]*\\s${name}=["']?([^"'\\s>]+)`, "i"));
53
+ return match?.[1] || "";
54
+ }
55
+
56
+ function addMissingViewBox(markup: string) {
57
+ if (/\sviewBox\s*=/i.test(markup)) {
58
+ return markup;
59
+ }
60
+ const width = Number.parseFloat(readSvgAttribute(markup, "width"));
61
+ const height = Number.parseFloat(readSvgAttribute(markup, "height"));
62
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
63
+ return markup;
64
+ }
65
+ return markup.replace(/<svg\b/i, `<svg viewBox="0 0 ${width} ${height}"`);
66
+ }
67
+
68
+ function colorizeSvgForHover(markup: string) {
69
+ return markup
70
+ .replace(/\s(fill|stroke)=["'](?!none\b|currentColor\b|url\()[^"']+["']/gi, ' $1="currentColor"')
71
+ .replace(/\b(fill|stroke)\s*:\s*(?!none\b|currentColor\b|url\()[^;"']+/gi, "$1:currentColor");
72
+ }
73
+
74
+ function InlineSvgIcon({ svg }: { svg: string }) {
75
+ const safeSvg = sanitizeSvgMarkup(svg);
76
+ if (!safeSvg) {
77
+ return null;
78
+ }
79
+ return (
80
+ <span
81
+ className="inline-flex size-4 shrink-0 items-center justify-center overflow-visible text-current leading-none [&_svg]:block [&_svg]:size-full [&_svg]:shrink-0 [&_svg]:overflow-visible"
82
+ aria-hidden="true"
83
+ dangerouslySetInnerHTML={{ __html: safeSvg }}
84
+ />
85
+ );
86
+ }
87
+
88
+ type Branding = ReturnType<typeof normalizeBranding>;
89
+
90
+ export function TopNav({ branding = DEFAULT_BRANDING }: { branding?: Branding }) {
91
  const pathname = usePathname();
92
  const [session, setSession] = useState<StoredAuthSession | null | undefined>(undefined);
93
 
 
143
  : userNavItems.filter((item) => item.href !== "/accounts");
144
  const roleLabel = getRoleLabel(session.role);
145
  const displayName = session.name.trim() || roleLabel;
146
+ const headerTitle = branding.site_header_title.trim() || DEFAULT_BRANDING.site_header_title;
147
+ const logoUrl = branding.site_logo_url.trim();
148
+ const externalLinkLabel = branding.site_external_link_label.trim();
149
+ const externalLinkUrl = branding.site_external_link_url.trim();
150
+ const externalLinkIconUrl = branding.site_external_link_icon_url.trim();
151
+ const externalLinkIconSvg = branding.site_external_link_icon_svg.trim();
152
+ const ExternalLinkIcon = /github/i.test(`${externalLinkLabel} ${externalLinkUrl}`) ? Github : ExternalLink;
153
 
154
  return (
155
  <header className="border-b border-stone-100/50">
 
157
  <div className="flex items-center justify-between gap-2 sm:justify-start sm:gap-3">
158
  <Link
159
  href="/image"
160
+ className="inline-flex shrink-0 items-center gap-1.5 py-1 text-[15px] font-bold tracking-tight text-stone-950 transition hover:text-stone-700"
161
  >
162
+ {logoUrl ? <img src={logoUrl} alt="" className="size-5 rounded-md object-cover" /> : null}
163
+ <span>{headerTitle}</span>
164
  </Link>
165
+ {externalLinkUrl && externalLinkLabel ? (
166
+ <a
167
+ href={externalLinkUrl}
168
+ target="_blank"
169
+ rel="noreferrer"
170
+ className="inline-flex items-center gap-1.5 py-1 text-sm text-stone-400 transition hover:text-stone-700"
171
+ aria-label={externalLinkLabel}
172
+ >
173
+ {externalLinkIconSvg ? (
174
+ <InlineSvgIcon svg={externalLinkIconSvg} />
175
+ ) : externalLinkIconUrl ? (
176
+ <img src={externalLinkIconUrl} alt="" className="size-4 rounded-sm object-cover" />
177
+ ) : (
178
+ <ExternalLinkIcon className="size-4" />
179
+ )}
180
+ <span className="hidden md:inline">{externalLinkLabel}</span>
181
+ </a>
182
+ ) : null}
183
  <button
184
  type="button"
185
  className="ml-auto shrink-0 py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:hidden"
web/src/lib/api.ts CHANGED
@@ -107,6 +107,16 @@ export type SettingsConfig = {
107
  normal_user_daily_credits?: number | string;
108
  wechat_login_enabled?: boolean;
109
  wechat_auto_register_enabled?: boolean;
 
 
 
 
 
 
 
 
 
 
110
  shop_purchase_url?: string;
111
  auto_remove_invalid_accounts?: boolean;
112
  auto_remove_rate_limited_accounts?: boolean;
@@ -266,10 +276,21 @@ export type LoginResponse = {
266
  subject_id: string;
267
  name: string;
268
  account_pool_enabled: boolean;
 
269
  key?: string;
270
  created?: boolean;
271
  };
272
 
 
 
 
 
 
 
 
 
 
 
273
  export type UserKey = {
274
  id: string;
275
  name: string;
@@ -792,7 +813,13 @@ export async function wechatLogin(code: string) {
792
  }
793
 
794
  export async function fetchWechatLoginStatus() {
795
- return httpRequest<{ enabled: boolean; auto_register_enabled?: boolean }>("/api/auth/wechat/status", {
 
 
 
 
 
 
796
  redirectOnUnauthorized: false,
797
  });
798
  }
 
107
  normal_user_daily_credits?: number | string;
108
  wechat_login_enabled?: boolean;
109
  wechat_auto_register_enabled?: boolean;
110
+ internal_user_login_session_duration_hours?: number | string;
111
+ normal_user_login_session_duration_hours?: number | string;
112
+ login_session_duration_hours?: number | string;
113
+ site_title?: string;
114
+ site_logo_url?: string;
115
+ site_header_title?: string;
116
+ site_external_link_label?: string;
117
+ site_external_link_url?: string;
118
+ site_external_link_icon_url?: string;
119
+ site_external_link_icon_svg?: string;
120
  shop_purchase_url?: string;
121
  auto_remove_invalid_accounts?: boolean;
122
  auto_remove_rate_limited_accounts?: boolean;
 
276
  subject_id: string;
277
  name: string;
278
  account_pool_enabled: boolean;
279
+ login_session_duration_hours?: number;
280
  key?: string;
281
  created?: boolean;
282
  };
283
 
284
+ export type BrandingConfig = {
285
+ site_title?: string;
286
+ site_logo_url?: string;
287
+ site_header_title?: string;
288
+ site_external_link_label?: string;
289
+ site_external_link_url?: string;
290
+ site_external_link_icon_url?: string;
291
+ site_external_link_icon_svg?: string;
292
+ };
293
+
294
  export type UserKey = {
295
  id: string;
296
  name: string;
 
813
  }
814
 
815
  export async function fetchWechatLoginStatus() {
816
+ return httpRequest<{ enabled: boolean; auto_register_enabled?: boolean; login_session_duration_hours?: number }>("/api/auth/wechat/status", {
817
+ redirectOnUnauthorized: false,
818
+ });
819
+ }
820
+
821
+ export async function fetchBranding() {
822
+ return httpRequest<{ branding: BrandingConfig }>("/api/branding", {
823
  redirectOnUnauthorized: false,
824
  });
825
  }
web/src/lib/auth-session.ts CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
  import { login } from "@/lib/api";
4
- import { clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
5
 
6
  const VALIDATION_CACHE_MS = 10_000;
7
  let cachedSession: StoredAuthSession | null = null;
@@ -52,6 +52,7 @@ async function validateStoredSession(storedSession: StoredAuthSession): Promise<
52
  name: data.name,
53
  accountPoolEnabled: Boolean(data.account_pool_enabled),
54
  wechatOpenid: storedSession.wechatOpenid,
 
55
  };
56
  await setStoredAuthSession(nextSession);
57
  cachedSession = nextSession;
 
1
  "use client";
2
 
3
  import { login } from "@/lib/api";
4
+ import { buildSessionExpiresAt, clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
5
 
6
  const VALIDATION_CACHE_MS = 10_000;
7
  let cachedSession: StoredAuthSession | null = null;
 
52
  name: data.name,
53
  accountPoolEnabled: Boolean(data.account_pool_enabled),
54
  wechatOpenid: storedSession.wechatOpenid,
55
+ expiresAt: storedSession.expiresAt || buildSessionExpiresAt(data.login_session_duration_hours),
56
  };
57
  await setStoredAuthSession(nextSession);
58
  cachedSession = nextSession;
web/src/lib/branding.ts ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ import { fetchBranding, type BrandingConfig } from "@/lib/api";
6
+
7
+ export const DEFAULT_BRANDING: Required<BrandingConfig> = {
8
+ site_title: "ChatGPT 号池管理",
9
+ site_logo_url: "",
10
+ site_header_title: "chatgpt2api",
11
+ site_external_link_label: "GitHub",
12
+ site_external_link_url: "https://github.com/basketikun/chatgpt2api",
13
+ site_external_link_icon_url: "",
14
+ site_external_link_icon_svg: "",
15
+ };
16
+
17
+ function normalizeText(value: unknown, fallback: string) {
18
+ const normalized = String(value ?? "").trim();
19
+ return normalized || fallback;
20
+ }
21
+
22
+ export function normalizeBranding(value: BrandingConfig | null | undefined): Required<BrandingConfig> {
23
+ return {
24
+ site_title: normalizeText(value?.site_title, DEFAULT_BRANDING.site_title),
25
+ site_logo_url: String(value?.site_logo_url ?? "").trim(),
26
+ site_header_title: normalizeText(value?.site_header_title, DEFAULT_BRANDING.site_header_title),
27
+ site_external_link_label: String(value?.site_external_link_label ?? DEFAULT_BRANDING.site_external_link_label).trim(),
28
+ site_external_link_url: String(value?.site_external_link_url ?? DEFAULT_BRANDING.site_external_link_url).trim(),
29
+ site_external_link_icon_url: String(value?.site_external_link_icon_url ?? "").trim(),
30
+ site_external_link_icon_svg: String(value?.site_external_link_icon_svg ?? "").trim(),
31
+ };
32
+ }
33
+
34
+ function updateFavicon(href: string) {
35
+ const targetHref = href || "/favicon.ico";
36
+ let icon = document.querySelector<HTMLLinkElement>("link[data-branding-icon='favicon']");
37
+ if (!icon) {
38
+ icon = document.createElement("link");
39
+ icon.rel = "icon";
40
+ icon.setAttribute("data-branding-icon", "favicon");
41
+ document.head.appendChild(icon);
42
+ }
43
+ icon.rel = "icon";
44
+ icon.href = targetHref;
45
+ icon.type = guessIconType(targetHref);
46
+
47
+ let appleIcon = document.querySelector<HTMLLinkElement>("link[data-branding-icon='apple-touch-icon']");
48
+ if (!appleIcon) {
49
+ appleIcon = document.createElement("link");
50
+ appleIcon.rel = "apple-touch-icon";
51
+ appleIcon.setAttribute("data-branding-icon", "apple-touch-icon");
52
+ document.head.appendChild(appleIcon);
53
+ }
54
+ appleIcon.rel = "apple-touch-icon";
55
+ appleIcon.href = targetHref;
56
+ }
57
+
58
+ function guessIconType(href: string) {
59
+ const cleanHref = href.split(/[?#]/, 1)[0]?.toLowerCase() || "";
60
+ if (cleanHref.endsWith(".svg")) {
61
+ return "image/svg+xml";
62
+ }
63
+ if (cleanHref.endsWith(".jpg") || cleanHref.endsWith(".jpeg")) {
64
+ return "image/jpeg";
65
+ }
66
+ if (cleanHref.endsWith(".webp")) {
67
+ return "image/webp";
68
+ }
69
+ if (cleanHref.endsWith(".ico")) {
70
+ return "image/x-icon";
71
+ }
72
+ return "image/png";
73
+ }
74
+
75
+ function applyBrandingToDocument(branding: Required<BrandingConfig>) {
76
+ document.title = branding.site_title;
77
+ updateFavicon(branding.site_logo_url);
78
+ }
79
+
80
+ export function useBranding(options: { applyDocument?: boolean } = {}) {
81
+ const [branding, setBranding] = useState(() => normalizeBranding(null));
82
+
83
+ useEffect(() => {
84
+ let active = true;
85
+ void fetchBranding()
86
+ .then((data) => {
87
+ if (active) {
88
+ setBranding(normalizeBranding(data.branding));
89
+ }
90
+ })
91
+ .catch(() => {
92
+ if (active) {
93
+ setBranding(normalizeBranding(null));
94
+ }
95
+ });
96
+ return () => {
97
+ active = false;
98
+ };
99
+ }, []);
100
+
101
+ useEffect(() => {
102
+ if (options.applyDocument) {
103
+ applyBrandingToDocument(branding);
104
+ }
105
+ }, [branding, options.applyDocument]);
106
+
107
+ return branding;
108
+ }
web/src/store/auth.ts CHANGED
@@ -11,6 +11,7 @@ export type StoredAuthSession = {
11
  name: string;
12
  accountPoolEnabled?: boolean;
13
  wechatOpenid?: string;
 
14
  };
15
 
16
  export const AUTH_KEY_STORAGE_KEY = "chatgpt2api_auth_key";
@@ -49,9 +50,26 @@ function normalizeSession(value: unknown, fallbackKey = ""): StoredAuthSession |
49
  name: String(candidate.name || "").trim(),
50
  accountPoolEnabled: candidate.accountPoolEnabled === true,
51
  wechatOpenid: String(candidate.wechatOpenid || "").trim(),
 
52
  };
53
  }
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  export function getDefaultRouteForRole(role: AuthRole) {
56
  return role === "admin" ? "/accounts" : "/image";
57
  }
@@ -63,8 +81,16 @@ export async function getStoredAuthKey() {
63
  if (authSessionClearedInMemory) {
64
  return "";
65
  }
66
- const value = await authStorage.getItem<string>(AUTH_KEY_STORAGE_KEY);
67
- return String(value || "").trim();
 
 
 
 
 
 
 
 
68
  }
69
 
70
  export async function getStoredAuthSession() {
@@ -82,6 +108,10 @@ export async function getStoredAuthSession() {
82
 
83
  const normalizedSession = normalizeSession(storedSession, String(storedKey || ""));
84
  if (normalizedSession) {
 
 
 
 
85
  if (normalizedSession.key !== String(storedKey || "").trim()) {
86
  await authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedSession.key);
87
  }
 
11
  name: string;
12
  accountPoolEnabled?: boolean;
13
  wechatOpenid?: string;
14
+ expiresAt?: string;
15
  };
16
 
17
  export const AUTH_KEY_STORAGE_KEY = "chatgpt2api_auth_key";
 
50
  name: String(candidate.name || "").trim(),
51
  accountPoolEnabled: candidate.accountPoolEnabled === true,
52
  wechatOpenid: String(candidate.wechatOpenid || "").trim(),
53
+ expiresAt: String(candidate.expiresAt || "").trim(),
54
  };
55
  }
56
 
57
+ function isSessionExpired(session: StoredAuthSession | null) {
58
+ if (!session?.expiresAt) {
59
+ return false;
60
+ }
61
+ const expiresAt = new Date(session.expiresAt).getTime();
62
+ return Number.isFinite(expiresAt) && expiresAt <= Date.now();
63
+ }
64
+
65
+ export function buildSessionExpiresAt(durationHours: unknown) {
66
+ const hours = Number(durationHours);
67
+ if (!Number.isFinite(hours) || hours <= 0) {
68
+ return "";
69
+ }
70
+ return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
71
+ }
72
+
73
  export function getDefaultRouteForRole(role: AuthRole) {
74
  return role === "admin" ? "/accounts" : "/image";
75
  }
 
81
  if (authSessionClearedInMemory) {
82
  return "";
83
  }
84
+ const [storedKey, storedSession] = await Promise.all([
85
+ authStorage.getItem<string>(AUTH_KEY_STORAGE_KEY),
86
+ authStorage.getItem<StoredAuthSession>(AUTH_SESSION_STORAGE_KEY),
87
+ ]);
88
+ const normalizedSession = normalizeSession(storedSession, String(storedKey || ""));
89
+ if (isSessionExpired(normalizedSession)) {
90
+ await clearStoredAuthSession();
91
+ return "";
92
+ }
93
+ return String(normalizedSession?.key || storedKey || "").trim();
94
  }
95
 
96
  export async function getStoredAuthSession() {
 
108
 
109
  const normalizedSession = normalizeSession(storedSession, String(storedKey || ""));
110
  if (normalizedSession) {
111
+ if (isSessionExpired(normalizedSession)) {
112
+ await clearStoredAuthSession();
113
+ return null;
114
+ }
115
  if (normalizedSession.key !== String(storedKey || "").trim()) {
116
  await authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedSession.key);
117
  }