feat: enable automatic web backend sessions
This commit is contained in:
@@ -9,6 +9,10 @@ COLLECTOR_N8N_BASE_URL=http://n8n:5678
|
||||
BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
|
||||
BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__
|
||||
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin
|
||||
WEB_AUTOLOGIN_ENABLED=0
|
||||
WEB_AUTOLOGIN_ACCOUNT_USERNAME=
|
||||
WEB_AUTOLOGIN_USERNAME=
|
||||
WEB_AUTOLOGIN_PASSWORD=
|
||||
N8N_ANALYSIS_WEBHOOK_PATH=/webhook/storyforge-analysis
|
||||
N8N_REAL_CUT_WEBHOOK_PATH=/webhook/storyforge-real-cut
|
||||
N8N_AI_VIDEO_WEBHOOK_PATH=/webhook/storyforge-ai-video
|
||||
|
||||
18
README.md
18
README.md
@@ -113,6 +113,22 @@ BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
|
||||
BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password
|
||||
```
|
||||
|
||||
如果希望 Web 端打开后直接自动建会话,不让用户手动输入账号密码,再额外打开:
|
||||
|
||||
```bash
|
||||
WEB_AUTOLOGIN_ENABLED=1
|
||||
WEB_AUTOLOGIN_ACCOUNT_USERNAME=your_existing_approved_username
|
||||
```
|
||||
|
||||
推荐直接指定一个已经存在且已审批通过的账号用户名,服务端会直接为该账号签发自动会话,不需要额外保存该账号密码。
|
||||
|
||||
如果你更希望复用 bootstrap 超级管理员口令,或者切到专门账号,也可以继续走密码模式:
|
||||
|
||||
```bash
|
||||
WEB_AUTOLOGIN_USERNAME=your_autologin_username
|
||||
WEB_AUTOLOGIN_PASSWORD=your_autologin_password
|
||||
```
|
||||
|
||||
如果要让本机模型网关 `cli-proxy-api` 自动提供 `GLM-5`,建议在启动前确保本机环境里存在:
|
||||
|
||||
```bash
|
||||
@@ -151,6 +167,8 @@ N8N_BASE_URL=http://127.0.0.1:5670
|
||||
`BOOTSTRAP_SUPERADMIN_USERNAME / BOOTSTRAP_SUPERADMIN_PASSWORD / BOOTSTRAP_SUPERADMIN_DISPLAY_NAME`
|
||||
创建最高权限账号。未配置时不会再自动写入默认口令账号。
|
||||
|
||||
如果开启了 `WEB_AUTOLOGIN_ENABLED=1`,前端会在启动时直接请求 `/v2/auth/auto-session` 自动建会话,不再显示用户名 / 密码 / token 输入流程。推荐优先使用 `WEB_AUTOLOGIN_ACCOUNT_USERNAME`,只在必须时才使用 `WEB_AUTOLOGIN_USERNAME / WEB_AUTOLOGIN_PASSWORD`。
|
||||
|
||||
## 当前架构
|
||||
|
||||
- `collector-service` 负责:
|
||||
|
||||
@@ -53,6 +53,10 @@ ORCHESTRATOR_SHARED_SECRET = os.getenv("ORCHESTRATOR_SHARED_SECRET", "")
|
||||
BOOTSTRAP_SUPERADMIN_USERNAME = os.getenv("BOOTSTRAP_SUPERADMIN_USERNAME", "")
|
||||
BOOTSTRAP_SUPERADMIN_PASSWORD = os.getenv("BOOTSTRAP_SUPERADMIN_PASSWORD", "")
|
||||
BOOTSTRAP_SUPERADMIN_DISPLAY_NAME = os.getenv("BOOTSTRAP_SUPERADMIN_DISPLAY_NAME", "StoryForge Admin")
|
||||
WEB_AUTOLOGIN_ENABLED = os.getenv("WEB_AUTOLOGIN_ENABLED", "0")
|
||||
WEB_AUTOLOGIN_ACCOUNT_USERNAME = os.getenv("WEB_AUTOLOGIN_ACCOUNT_USERNAME", "")
|
||||
WEB_AUTOLOGIN_USERNAME = os.getenv("WEB_AUTOLOGIN_USERNAME", "")
|
||||
WEB_AUTOLOGIN_PASSWORD = os.getenv("WEB_AUTOLOGIN_PASSWORD", "")
|
||||
CUTVIDEO_BASE_URL = os.getenv("CUTVIDEO_BASE_URL", "http://192.168.31.18:7860")
|
||||
CUTVIDEO_API_KEY = os.getenv("CUTVIDEO_API_KEY", "")
|
||||
HUOBAO_BASE_URL = os.getenv("HUOBAO_BASE_URL", "http://127.0.0.1:5678")
|
||||
@@ -408,6 +412,32 @@ def bootstrap_superadmin_configured() -> bool:
|
||||
return bool(username) and not is_placeholder_config(password)
|
||||
|
||||
|
||||
def env_flag(value: str | None) -> bool:
|
||||
return normalize_config_value(value).lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def web_autologin_credentials() -> tuple[str, str]:
|
||||
username = normalize_config_value(WEB_AUTOLOGIN_USERNAME)
|
||||
password = normalize_config_value(WEB_AUTOLOGIN_PASSWORD)
|
||||
if username or password:
|
||||
return username, password
|
||||
bootstrap_username, bootstrap_password, _ = bootstrap_superadmin_credentials()
|
||||
return bootstrap_username, bootstrap_password
|
||||
|
||||
|
||||
def web_autologin_account_username() -> str:
|
||||
return normalize_config_value(WEB_AUTOLOGIN_ACCOUNT_USERNAME)
|
||||
|
||||
|
||||
def web_autologin_configured() -> bool:
|
||||
if not env_flag(WEB_AUTOLOGIN_ENABLED):
|
||||
return False
|
||||
if web_autologin_account_username():
|
||||
return True
|
||||
username, password = web_autologin_credentials()
|
||||
return bool(username) and not is_placeholder_config(password)
|
||||
|
||||
|
||||
def normalize_model_profile(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
@@ -439,6 +469,20 @@ def normalize_account(row: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def issue_auth_token(account: dict[str, Any], *, mode: str = "password") -> dict[str, Any]:
|
||||
token = secrets.token_urlsafe(32)
|
||||
db.execute(
|
||||
"INSERT INTO auth_tokens (token, account_id, created_at) VALUES (?, ?, ?)",
|
||||
(token, account["id"], utc_now()),
|
||||
)
|
||||
return {
|
||||
"token": token,
|
||||
"account": normalize_account(account),
|
||||
"default_external_base_url": DEFAULT_EXTERNAL_BASE_URL,
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
|
||||
def model_profile_for_account(account_id: str, requested_id: str | None) -> dict[str, Any]:
|
||||
if requested_id:
|
||||
row = db.fetch_one(
|
||||
@@ -3168,6 +3212,7 @@ def healthz() -> dict[str, Any]:
|
||||
"liveRecorderBaseUrl": LIVE_RECORDER_BASE_URL,
|
||||
"orchestratorSecretConfigured": orchestrator_secret_configured(),
|
||||
"bootstrapSuperadminConfigured": bootstrap_superadmin_configured(),
|
||||
"webAutoLoginConfigured": web_autologin_configured(),
|
||||
}
|
||||
|
||||
|
||||
@@ -3630,16 +3675,26 @@ def login(request: LoginRequest) -> dict[str, Any]:
|
||||
account = db.fetch_one("SELECT * FROM accounts WHERE username = ?", (request.username.strip(),))
|
||||
if not account or not verify_password(request.password, account["password_hash"], account["password_salt"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
token = secrets.token_urlsafe(32)
|
||||
db.execute(
|
||||
"INSERT INTO auth_tokens (token, account_id, created_at) VALUES (?, ?, ?)",
|
||||
(token, account["id"], utc_now()),
|
||||
)
|
||||
return {
|
||||
"token": token,
|
||||
"account": normalize_account(account),
|
||||
"default_external_base_url": DEFAULT_EXTERNAL_BASE_URL,
|
||||
}
|
||||
return issue_auth_token(account, mode="password")
|
||||
|
||||
|
||||
@app.post("/v2/auth/auto-session")
|
||||
def create_auto_session() -> dict[str, Any]:
|
||||
if not web_autologin_configured():
|
||||
raise HTTPException(status_code=503, detail="Auto session is not configured on this deployment")
|
||||
account_username = web_autologin_account_username()
|
||||
if account_username:
|
||||
account = db.fetch_one("SELECT * FROM accounts WHERE username = ?", (account_username,))
|
||||
if not account:
|
||||
raise HTTPException(status_code=503, detail="Auto session account is missing on this deployment")
|
||||
else:
|
||||
username, password = web_autologin_credentials()
|
||||
account = db.fetch_one("SELECT * FROM accounts WHERE username = ?", (username,))
|
||||
if not account or not verify_password(password, account["password_hash"], account["password_salt"]):
|
||||
raise HTTPException(status_code=503, detail="Auto session credentials are invalid on this deployment")
|
||||
if account["approval_status"] != "approved" and account["role"] != "super_admin":
|
||||
raise HTTPException(status_code=403, detail="Auto session account is not approved")
|
||||
return issue_auth_token(account, mode="auto")
|
||||
|
||||
|
||||
@app.post("/v2/auth/logout")
|
||||
|
||||
@@ -19,6 +19,10 @@ Environment=ORCHESTRATOR_SHARED_SECRET=__set_a_strong_shared_secret__
|
||||
Environment=BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin
|
||||
Environment=BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__
|
||||
Environment=BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin
|
||||
Environment=WEB_AUTOLOGIN_ENABLED=1
|
||||
Environment=WEB_AUTOLOGIN_ACCOUNT_USERNAME=
|
||||
Environment=WEB_AUTOLOGIN_USERNAME=
|
||||
Environment=WEB_AUTOLOGIN_PASSWORD=
|
||||
Environment=HUOBAO_BASE_URL=http://127.0.0.1:15678
|
||||
Environment=CUTVIDEO_BASE_URL=http://127.0.0.1:17860
|
||||
Environment=LIVE_RECORDER_BASE_URL=http://127.0.0.1:19106
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
- 生产中心
|
||||
- 复盘
|
||||
- 额度与运维面板
|
||||
- 自动建会话连接
|
||||
|
||||
## 当前量产基线
|
||||
|
||||
@@ -46,6 +47,7 @@
|
||||
- `tenant_quota_profiles` 与 `tenant_usage_ledger` 已接入核心生产链,`explore/*`、`content-source-sync`、`reviews`、`real-cut`、`ai-video`、`assistants/{id}/generate`、`live-recorder create` 都会先做额度硬拦截,再记账。
|
||||
- `jobs` 已补 `retry / requeue` 单任务入口,以及管理员批量重试失败任务入口,便于失败链路恢复。
|
||||
- 仓库内已新增 SQLite 备份脚本,可在发布或故障前快速生成一致性快照。
|
||||
- Web 前端已改成固定后端自动建会话模式,不再要求用户手动输入账号密码;是否启用由服务端 `WEB_AUTOLOGIN_*` 环境变量控制,推荐直接用 `WEB_AUTOLOGIN_ACCOUNT_USERNAME` 绑定现有已审批账号。
|
||||
|
||||
## 当前支持的平台
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
- `POST /v2/explore/jobs/{job_id}/retry`
|
||||
- `POST /v2/explore/jobs/{job_id}/requeue`
|
||||
- `POST /v2/admin/jobs/retry-failed`
|
||||
- Web 已支持固定后端自动建会话:
|
||||
- `POST /v2/auth/auto-session`
|
||||
- 开关由 `WEB_AUTOLOGIN_ENABLED` 控制
|
||||
- 推荐使用 `WEB_AUTOLOGIN_ACCOUNT_USERNAME` 直接绑定现有已审批账号
|
||||
- 兼容 `WEB_AUTOLOGIN_USERNAME / WEB_AUTOLOGIN_PASSWORD` 或 bootstrap 超级管理员口令回退
|
||||
- 仓库内已新增 SQLite 备份脚本:
|
||||
- `scripts/backup_storyforge_sqlite.sh`
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ class ProductionBaselineTests(unittest.TestCase):
|
||||
os.environ["JOBS_DIR"] = str(temp_root / "jobs")
|
||||
os.environ["MODELS_DIR"] = str(temp_root / "models")
|
||||
os.environ["ORCHESTRATOR_SHARED_SECRET"] = "test-secret"
|
||||
os.environ["WEB_AUTOLOGIN_ENABLED"] = "1"
|
||||
os.environ["WEB_AUTOLOGIN_ACCOUNT_USERNAME"] = ""
|
||||
os.environ["WEB_AUTOLOGIN_USERNAME"] = ""
|
||||
os.environ["WEB_AUTOLOGIN_PASSWORD"] = ""
|
||||
os.environ.setdefault("BOOTSTRAP_SUPERADMIN_USERNAME", "")
|
||||
os.environ.setdefault("BOOTSTRAP_SUPERADMIN_PASSWORD", "")
|
||||
|
||||
@@ -97,6 +101,8 @@ class ProductionBaselineTests(unittest.TestCase):
|
||||
assistant_id = f"assistant_{tag}"
|
||||
token = f"token_{tag}"
|
||||
username = f"user_{tag}"
|
||||
login_password = f"pass_{tag}"
|
||||
password_hash, password_salt = self.core.create_password_hash(login_password)
|
||||
|
||||
self.core.db.execute(
|
||||
"""
|
||||
@@ -108,8 +114,8 @@ class ProductionBaselineTests(unittest.TestCase):
|
||||
(
|
||||
account_id,
|
||||
username,
|
||||
"hash",
|
||||
"salt",
|
||||
password_hash,
|
||||
password_salt,
|
||||
f"User {tag}",
|
||||
"super_admin",
|
||||
"approved",
|
||||
@@ -206,8 +212,22 @@ class ProductionBaselineTests(unittest.TestCase):
|
||||
"kb_id": kb_id,
|
||||
"assistant_id": assistant_id,
|
||||
"token": token,
|
||||
"username": username,
|
||||
"password": login_password,
|
||||
}
|
||||
|
||||
def test_auto_session_issues_token_without_manual_credentials(self) -> None:
|
||||
ctx = self._seed_context("auto", exhausted=False)
|
||||
self.core.WEB_AUTOLOGIN_ENABLED = "1"
|
||||
self.core.WEB_AUTOLOGIN_ACCOUNT_USERNAME = ctx["username"]
|
||||
self.core.WEB_AUTOLOGIN_USERNAME = ""
|
||||
self.core.WEB_AUTOLOGIN_PASSWORD = ""
|
||||
response = self.client.post("/v2/auth/auto-session")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["account"]["username"], ctx["username"])
|
||||
self.assertEqual(payload["mode"], "auto")
|
||||
|
||||
def test_database_uses_wal_and_busy_timeout(self) -> None:
|
||||
conn = self.core.db.connect()
|
||||
try:
|
||||
|
||||
@@ -52,6 +52,9 @@ const appState = {
|
||||
adminOpsOverview: null,
|
||||
adminFixRuns: [],
|
||||
recoveryRecords: [],
|
||||
autoConnectAttempted: false,
|
||||
autoConnectSuppressed: false,
|
||||
autoConnectError: "",
|
||||
busy: false,
|
||||
message: "",
|
||||
lastAction: null,
|
||||
@@ -646,7 +649,7 @@ function ensureAuthUi() {
|
||||
const inline = document.createElement("div");
|
||||
inline.className = "auth-inline";
|
||||
inline.innerHTML = `
|
||||
<button class="btn btn-secondary" type="button" data-action="open-auth">连接后端</button>
|
||||
<button class="btn btn-secondary" type="button" data-action="open-auth">连接状态</button>
|
||||
<button class="btn btn-secondary" type="button" data-action="logout-session">退出</button>
|
||||
<span class="auth-status"></span>
|
||||
`;
|
||||
@@ -662,30 +665,22 @@ function ensureAuthUi() {
|
||||
<div class="auth-head">
|
||||
<div>
|
||||
<h3>连接 StoryForge</h3>
|
||||
<p>先登录后端,再加载项目、对标、Agent 和生产数据。</p>
|
||||
<p>当前站点会直接向后端请求自动会话,不再要求用户输入账号密码。</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="button" data-action="close-auth">关闭</button>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label>后端地址</label>
|
||||
<input type="text" data-auth-field="backendUrl" placeholder="${DEFAULT_BACKEND_URL}" autocomplete="url" />
|
||||
<input type="text" data-auth-field="backendUrl" placeholder="${DEFAULT_BACKEND_URL}" autocomplete="url" readonly />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label>用户名</label>
|
||||
<input type="text" data-auth-field="username" placeholder="kris" autocomplete="username" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label>密码</label>
|
||||
<input type="password" data-auth-field="password" placeholder="输入密码" autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label>已有 Token</label>
|
||||
<textarea data-auth-field="token" rows="3" placeholder="可选:直接粘贴 token,跳过账号密码"></textarea>
|
||||
<div class="task-item compact">
|
||||
<h4>自动连接说明</h4>
|
||||
<p>前端只会请求固定后端的自动会话接口;如果当前部署没有开启自动建会话,这里会直接显示服务端返回的原因。</p>
|
||||
</div>
|
||||
<div class="helper-text" data-role="auth-message"></div>
|
||||
<div class="auth-actions">
|
||||
<button class="btn btn-secondary" type="button" data-action="auth-refresh">刷新数据</button>
|
||||
<button class="btn btn-primary" type="submit" data-action="submit-auth">登录并加载</button>
|
||||
<button class="btn btn-secondary" type="button" data-action="auth-refresh">重新加载</button>
|
||||
<button class="btn btn-primary" type="submit" data-action="submit-auth">自动连接</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -701,17 +696,17 @@ function renderAuthUi() {
|
||||
const logoutButton = document.querySelector('[data-action="logout-session"]');
|
||||
const status = document.querySelector(".auth-status");
|
||||
const message = document.querySelector('[data-role="auth-message"]');
|
||||
if (openButton) openButton.textContent = session ? "切换连接" : "连接后端";
|
||||
if (openButton) openButton.textContent = session ? "连接状态" : "自动连接";
|
||||
if (logoutButton) logoutButton.hidden = !session;
|
||||
if (status) {
|
||||
status.textContent = appState.busy
|
||||
? appState.message || "正在加载..."
|
||||
: session
|
||||
? `${session.account?.display_name || session.account?.username || "已连接"} · ${session.backendUrl}`
|
||||
: "未连接";
|
||||
: appState.autoConnectError || "等待自动连接";
|
||||
}
|
||||
if (message) {
|
||||
message.textContent = appState.busy ? appState.message : "";
|
||||
message.textContent = appState.busy ? appState.message : (appState.autoConnectError || "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,9 +717,6 @@ function openAuthModal() {
|
||||
const session = appState.session;
|
||||
modal.classList.remove("hidden");
|
||||
setAuthField("backendUrl", session?.backendUrl || DEFAULT_BACKEND_URL);
|
||||
setAuthField("username", session?.account?.username || "");
|
||||
setAuthField("password", "");
|
||||
setAuthField("token", "");
|
||||
}
|
||||
|
||||
function closeAuthModal() {
|
||||
@@ -737,12 +729,8 @@ function setAuthField(name, value) {
|
||||
}
|
||||
|
||||
function readAuthForm() {
|
||||
const pick = (name) => document.querySelector(`[data-auth-field="${name}"]`)?.value?.trim() || "";
|
||||
return {
|
||||
backendUrl: pick("backendUrl") || DEFAULT_BACKEND_URL,
|
||||
username: pick("username"),
|
||||
password: document.querySelector('[data-auth-field="password"]')?.value || "",
|
||||
token: pick("token")
|
||||
backendUrl: document.querySelector('[data-auth-field="backendUrl"]')?.value?.trim() || DEFAULT_BACKEND_URL
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1114,36 +1102,44 @@ function backendSupports(path) {
|
||||
return API_CLIENT.backendSupports(path);
|
||||
}
|
||||
|
||||
async function loginWithForm() {
|
||||
const auth = readAuthForm();
|
||||
if (!auth.backendUrl) {
|
||||
throw new Error("请先填写后端地址");
|
||||
}
|
||||
if (auth.token) {
|
||||
const account = await storyforgeFetch("/v2/me", {
|
||||
backendUrl: auth.backendUrl,
|
||||
token: auth.token
|
||||
});
|
||||
persistSession({ backendUrl: auth.backendUrl, token: auth.token, account });
|
||||
return;
|
||||
}
|
||||
if (!auth.username || !auth.password) {
|
||||
throw new Error("请填写账号密码,或者直接填 Token");
|
||||
}
|
||||
const payload = await storyforgeFetch("/v2/auth/login", {
|
||||
backendUrl: auth.backendUrl,
|
||||
async function loginWithAutoSession(backendUrl = DEFAULT_BACKEND_URL) {
|
||||
const payload = await storyforgeFetch("/v2/auth/auto-session", {
|
||||
backendUrl,
|
||||
auth: false,
|
||||
method: "POST",
|
||||
body: {
|
||||
username: auth.username,
|
||||
password: auth.password
|
||||
}
|
||||
body: {}
|
||||
});
|
||||
persistSession({
|
||||
backendUrl: auth.backendUrl,
|
||||
backendUrl,
|
||||
token: payload.token,
|
||||
account: payload.account
|
||||
});
|
||||
appState.autoConnectError = "";
|
||||
appState.autoConnectAttempted = true;
|
||||
appState.autoConnectSuppressed = false;
|
||||
}
|
||||
|
||||
async function ensureAutoSession(options = {}) {
|
||||
const backendUrl = options.backendUrl || readAuthForm().backendUrl || DEFAULT_BACKEND_URL;
|
||||
const force = Boolean(options.force);
|
||||
if (appState.session && !force) {
|
||||
return true;
|
||||
}
|
||||
if (appState.autoConnectSuppressed && !force) {
|
||||
return false;
|
||||
}
|
||||
if (appState.autoConnectAttempted && !force) {
|
||||
return Boolean(appState.session);
|
||||
}
|
||||
appState.autoConnectAttempted = true;
|
||||
try {
|
||||
await loginWithAutoSession(backendUrl);
|
||||
return true;
|
||||
} catch (error) {
|
||||
appState.autoConnectError = formatActionErrorMessage(error, "自动连接失败");
|
||||
persistSession(null);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFromAuthModal() {
|
||||
@@ -1153,16 +1149,10 @@ async function refreshFromAuthModal() {
|
||||
await bootstrap();
|
||||
return;
|
||||
}
|
||||
const auth = readAuthForm();
|
||||
const hasAnyInlineAuth = Boolean(auth.token || auth.username || auth.password);
|
||||
const hasInlineAuth = Boolean(auth.token || (auth.username && auth.password));
|
||||
if (hasAnyInlineAuth && !hasInlineAuth) {
|
||||
throw new Error("请填写账号密码,或者直接填 Token");
|
||||
}
|
||||
if (hasInlineAuth) {
|
||||
await loginWithForm();
|
||||
closeAuthModal();
|
||||
}
|
||||
appState.autoConnectSuppressed = false;
|
||||
appState.autoConnectAttempted = false;
|
||||
await ensureAutoSession({ force: true });
|
||||
if (appState.session) closeAuthModal();
|
||||
await bootstrap();
|
||||
}
|
||||
|
||||
@@ -1173,6 +1163,9 @@ async function logoutSession() {
|
||||
}
|
||||
} catch {}
|
||||
persistSession(null);
|
||||
appState.autoConnectAttempted = true;
|
||||
appState.autoConnectSuppressed = true;
|
||||
appState.autoConnectError = "当前会话已退出。需要时可以点右上角重新自动连接。";
|
||||
appState.me = null;
|
||||
appState.dashboard = null;
|
||||
appState.contentSources = [];
|
||||
@@ -1659,6 +1652,14 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
|
||||
|
||||
async function bootstrap() {
|
||||
renderAll();
|
||||
if (!appState.session) {
|
||||
setBusy(true, "正在自动连接后端...");
|
||||
try {
|
||||
await ensureAutoSession();
|
||||
} finally {
|
||||
setBusy(false, "");
|
||||
}
|
||||
}
|
||||
if (!appState.session) {
|
||||
renderAuthUi();
|
||||
return;
|
||||
@@ -1810,8 +1811,13 @@ async function bootstrap() {
|
||||
}
|
||||
} catch (error) {
|
||||
appState.message = error.message;
|
||||
if (String(error.message || "").includes("401") || String(error.message || "").includes("Not authenticated")) {
|
||||
if (
|
||||
String(error.message || "").includes("401")
|
||||
|| String(error.message || "").includes("Not authenticated")
|
||||
|| String(error.message || "").includes("Invalid token")
|
||||
) {
|
||||
persistSession(null);
|
||||
appState.autoConnectAttempted = false;
|
||||
}
|
||||
} finally {
|
||||
appState.recoveryRecords = getRecoveryRecords();
|
||||
@@ -3460,9 +3466,9 @@ function renderDashboardScreen() {
|
||||
if (!appState.session) {
|
||||
return screenShell(
|
||||
"项目总台",
|
||||
"先连接后端,再加载项目、对标、Agent 和生产状态。",
|
||||
`${button("连接后端", "open-auth", "primary")}`,
|
||||
renderEmptyState("还没有连接 StoryForge", "登录后就能把项目总台替换成真实数据。")
|
||||
"先自动连接工作区,再加载项目、对标、Agent 和生产状态。",
|
||||
`${button("自动连接", "open-auth", "primary")}`,
|
||||
renderEmptyState("还没有连接 StoryForge", "自动连接成功后,这里会替换成真实项目总台。")
|
||||
);
|
||||
}
|
||||
if (!appState.dashboard) {
|
||||
@@ -3600,7 +3606,7 @@ function renderDashboardScreen() {
|
||||
|
||||
function renderProjectsScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("我的项目", "先连接工作区,再加载项目。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("项目未加载", "登录成功后,这里会显示真实项目和导入队列。"));
|
||||
return screenShell("我的项目", "先完成工作区自动连接,再加载项目。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("项目未加载", "自动连接成功后,这里会显示真实项目和导入队列。"));
|
||||
}
|
||||
const projects = safeArray(appState.dashboard.projects);
|
||||
const selectedProject = getSelectedProject();
|
||||
@@ -3662,7 +3668,7 @@ function renderProjectsScreen() {
|
||||
|
||||
function renderDiscoveryScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("找对标", "连接后端后才能加载真实对标账号。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("对标库未加载", "登录后这里会显示当前平台的账号列表和详情。"));
|
||||
return screenShell("找对标", "完成工作区自动连接后才能加载真实对标账号。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("对标库未加载", "自动连接成功后,这里会显示当前平台的账号列表和详情。"));
|
||||
}
|
||||
const query = appState.discoveryQuery.toLowerCase();
|
||||
const currentPlatform = getCurrentPlatformValue();
|
||||
@@ -3904,7 +3910,7 @@ function renderDiscoveryScreen() {
|
||||
|
||||
function renderTrackingScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("跟踪账号", "登录后才能生成真实日报。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。"));
|
||||
return screenShell("跟踪账号", "完成工作区自动连接后才能生成真实日报。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。"));
|
||||
}
|
||||
const currentPlatform = getCurrentPlatformValue();
|
||||
const trackingAccountsPath = getWorkbenchRoute(currentPlatform, "trackingAccounts");
|
||||
@@ -4043,7 +4049,7 @@ function renderAutomationScreen() {
|
||||
|
||||
function renderOwnedScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("我的账号", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("我的账号未加载", "登录后这里会展示当前账号和建议动作。"));
|
||||
return screenShell("我的账号", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("我的账号未加载", "自动连接成功后,这里会展示当前账号和建议动作。"));
|
||||
}
|
||||
const me = appState.me || appState.session?.account || {};
|
||||
const firstAssistant = safeArray(appState.dashboard.assistants)[0];
|
||||
@@ -4073,7 +4079,7 @@ function renderOwnedScreen() {
|
||||
|
||||
function renderPlaybookScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("Agent", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("Agent 未加载", "登录后这里会展示真实 Agent 列表和模型。"));
|
||||
return screenShell("Agent", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("Agent 未加载", "自动连接成功后,这里会展示真实 Agent 列表和模型。"));
|
||||
}
|
||||
const assistants = safeArray(appState.dashboard.assistants);
|
||||
const models = safeArray(appState.dashboard.model_profiles);
|
||||
@@ -4226,7 +4232,7 @@ function renderPlaybookScreen() {
|
||||
|
||||
function renderProductionScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("生产中心", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("生产中心未加载", "登录后这里会展示真实任务和作品。"));
|
||||
return screenShell("生产中心", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("生产中心未加载", "自动连接成功后,这里会展示真实任务和作品。"));
|
||||
}
|
||||
const jobs = safeArray(appState.dashboard.recent_jobs);
|
||||
const activeJobs = jobs.filter((item) => item.status !== "completed").slice(0, 4);
|
||||
@@ -4340,7 +4346,7 @@ function renderProductionScreen() {
|
||||
|
||||
function renderReviewScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("发布与复盘", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "登录后这里会先用最近任务生成一版复盘入口。"));
|
||||
return screenShell("发布与复盘", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "自动连接成功后,这里会先用最近任务生成一版复盘入口。"));
|
||||
}
|
||||
if (!backendSupports("/v2/reviews")) {
|
||||
return screenShell(
|
||||
@@ -4407,7 +4413,7 @@ function renderReviewScreen() {
|
||||
|
||||
function renderCreditsScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("额度", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("额度未加载", "后续接真实计费前,先用任务量做运营看板。"));
|
||||
return screenShell("额度", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("额度未加载", "自动连接成功后,这里会展示真实额度和运营看板。"));
|
||||
}
|
||||
const jobs = safeArray(appState.dashboard.recent_jobs);
|
||||
return screenShell(
|
||||
@@ -7088,16 +7094,18 @@ document.addEventListener("click", async (event) => {
|
||||
return;
|
||||
}
|
||||
if (name === "submit-auth") {
|
||||
setBusy(true, "正在登录并加载...");
|
||||
setBusy(true, "正在自动连接并加载...");
|
||||
try {
|
||||
const message = document.querySelector('[data-role="auth-message"]');
|
||||
if (message) message.textContent = "";
|
||||
await loginWithForm();
|
||||
appState.autoConnectSuppressed = false;
|
||||
appState.autoConnectAttempted = false;
|
||||
await ensureAutoSession({ force: true });
|
||||
closeAuthModal();
|
||||
await bootstrap();
|
||||
} catch (error) {
|
||||
const message = document.querySelector('[data-role="auth-message"]');
|
||||
if (message) message.textContent = error.message;
|
||||
if (message) message.textContent = formatActionErrorMessage(error, "自动连接失败");
|
||||
} finally {
|
||||
setBusy(false, "");
|
||||
}
|
||||
@@ -7111,7 +7119,7 @@ document.addEventListener("click", async (event) => {
|
||||
return;
|
||||
}
|
||||
if (name === "auth-refresh" || name === "refresh-data") {
|
||||
setBusy(true, name === "auth-refresh" ? "正在连接并刷新..." : "正在刷新数据...");
|
||||
setBusy(true, name === "auth-refresh" ? "正在重新自动连接..." : "正在刷新数据...");
|
||||
try {
|
||||
if (name === "auth-refresh") {
|
||||
const message = document.querySelector('[data-role="auth-message"]');
|
||||
@@ -7123,7 +7131,7 @@ document.addEventListener("click", async (event) => {
|
||||
} catch (error) {
|
||||
const message = document.querySelector('[data-role="auth-message"]');
|
||||
if (name === "auth-refresh" && message) {
|
||||
message.textContent = error.message;
|
||||
message.textContent = formatActionErrorMessage(error, "自动连接失败");
|
||||
} else {
|
||||
alert("刷新数据失败: " + error.message);
|
||||
}
|
||||
@@ -7515,14 +7523,16 @@ document.addEventListener("submit", async (event) => {
|
||||
if (!(form instanceof HTMLFormElement)) return;
|
||||
if (form.dataset.role === "auth-form") {
|
||||
event.preventDefault();
|
||||
setBusy(true, "正在登录并加载...");
|
||||
setBusy(true, "正在自动连接并加载...");
|
||||
try {
|
||||
await loginWithForm();
|
||||
appState.autoConnectSuppressed = false;
|
||||
appState.autoConnectAttempted = false;
|
||||
await ensureAutoSession({ force: true });
|
||||
closeAuthModal();
|
||||
await bootstrap();
|
||||
} catch (error) {
|
||||
const message = document.querySelector('[data-role="auth-message"]');
|
||||
if (message) message.textContent = error.message;
|
||||
if (message) message.textContent = formatActionErrorMessage(error, "自动连接失败");
|
||||
} finally {
|
||||
setBusy(false, "");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user