From d798d39da340546a7a2ab0f3433da2051cb7daa4 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 7 Apr 2026 20:08:33 +0800 Subject: [PATCH] fix: persist onboarding prompt status and bump android app to 1.0.20 --- android-webview-app/APP_CHANGELOG.md | 6 + android-webview-app/app/build.gradle.kts | 4 +- modules/user_manager.py | 19 ++ server/auth.py | 183 +++++++++++++++++- server/auth_helpers.py | 9 +- server/state.py | 2 + static/src/App.vue | 7 + static/src/app/components.ts | 4 +- static/src/app/lifecycle.ts | 11 +- static/src/app/methods/ui.ts | 69 +++++++ static/src/app/state.ts | 7 +- static/src/auth/LoginApp.vue | 8 + static/src/auth/RegisterApp.vue | 8 + .../overlay/NewUserTutorialPrompt.vue | 113 +++++++++++ .../personalization/PersonalizationDrawer.vue | 7 + static/src/stores/personalization.ts | 28 ++- vite.config.ts | 2 + 17 files changed, 469 insertions(+), 18 deletions(-) create mode 100644 static/src/components/overlay/NewUserTutorialPrompt.vue diff --git a/android-webview-app/APP_CHANGELOG.md b/android-webview-app/APP_CHANGELOG.md index 2114846..6d5768f 100644 --- a/android-webview-app/APP_CHANGELOG.md +++ b/android-webview-app/APP_CHANGELOG.md @@ -1,5 +1,11 @@ # App 更新说明 +# 1.0.20 +- 新增“新用户欢迎弹窗 + 新手教程”持久化机制:普通账号首次进入会提示是否开始教程,选择“开始吧”或“不再提示”后将状态写入 `data/users.json` +- 新增后端教程状态接口(查询/更新),并排除宿主机模式用户,避免 host 模式触发教程弹窗 +- 修复宿主机模式切回普通账号登录时可能遗留 `host_mode` 状态,导致教程提示判定异常的问题 +- 优化新手教程欢迎弹窗视觉风格:按钮与系统现有主题风格统一,支持多主题配色 + # 1.0.19 - 修复 Android APK 内「个人空间-新手教程」期间无法上下滚动的问题:教程遮罩层现在会正确转发触摸滑动到可滚动容器 - 优化教程滚动目标识别:优先命中当前可滚动页面区域,减少 WebView 场景下滑动失效 diff --git a/android-webview-app/app/build.gradle.kts b/android-webview-app/app/build.gradle.kts index 1489eb8..6b1568d 100644 --- a/android-webview-app/app/build.gradle.kts +++ b/android-webview-app/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.cyjai.agent" minSdk = 24 targetSdk = 35 - versionCode = 21 - versionName = "1.0.19" + versionCode = 22 + versionName = "1.0.20" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/modules/user_manager.py b/modules/user_manager.py index 8b2f588..b7e04c7 100644 --- a/modules/user_manager.py +++ b/modules/user_manager.py @@ -28,6 +28,7 @@ class UserRecord: created_at: str invite_code: Optional[str] = None role: str = "user" + tutorial_completed: bool = False @dataclass @@ -93,6 +94,7 @@ class UserManager: password_hash=password_hash, created_at=created_at, invite_code=invite_entry["code"], + tutorial_completed=False, ) self._users[username] = record self._index_user(record) @@ -189,6 +191,15 @@ class UserManager: """返回当前注册用户的浅拷贝字典,键为用户名。""" return dict(self._users) + def set_tutorial_completed(self, username: str, completed: bool = True) -> Optional[UserRecord]: + key = (username or "").strip().lower() + record = self._users.get(key) + if not record: + return None + record.tutorial_completed = bool(completed) + self._save_users() + return record + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @@ -215,6 +226,7 @@ class UserManager: return try: + migrated = False with open(self.users_file, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): @@ -231,9 +243,14 @@ class UserManager: created_at=payload.get("created_at", ""), invite_code=payload.get("invite_code"), role=payload.get("role", "user"), + tutorial_completed=bool(payload.get("tutorial_completed", False)), ) + if "tutorial_completed" not in payload: + migrated = True self._users[username] = record self._index_user(record) + if migrated: + self._save_users() except json.JSONDecodeError: raise RuntimeError(f"无法解析用户数据文件: {self.users_file}") @@ -246,6 +263,7 @@ class UserManager: "created_at": record.created_at, "invite_code": record.invite_code, "role": record.role, + "tutorial_completed": bool(record.tutorial_completed), } for username, record in self._users.items() } @@ -312,6 +330,7 @@ class UserManager: created_at=datetime.utcnow().isoformat(), invite_code=None, role="admin", + tutorial_completed=False, ) self._users[admin_name] = record self._index_user(record) diff --git a/server/auth.py b/server/auth.py index 3fc34e6..8a16ab4 100644 --- a/server/auth.py +++ b/server/auth.py @@ -1,7 +1,9 @@ from __future__ import annotations import mimetypes +import secrets from pathlib import Path -from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app +from datetime import datetime +from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app, make_response from modules.personalization_manager import load_personalization_config from modules.user_manager import UserWorkspace @@ -12,6 +14,7 @@ from config import ( LOGS_DIR, UPLOAD_QUARANTINE_SUBDIR, LINUX_SAFETY, + LOGS_DIR, ) from .auth_helpers import login_required, api_login_required, get_current_user_record, get_current_username @@ -27,6 +30,79 @@ from . import state from .utils_common import debug_log auth_bp = Blueprint("auth", __name__) +AUTH_DEBUG_FILE = Path(LOGS_DIR).expanduser().resolve() / "auth_debug.log" + + +def auth_debug_log(message: str): + AUTH_DEBUG_FILE.parent.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {message}" + try: + with AUTH_DEBUG_FILE.open("a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + try: + debug_log(line) + except Exception: + pass + + +def _session_debug_snapshot(): + return { + "logged_in": bool(session.get("logged_in")), + "username": session.get("username"), + "role": session.get("role"), + "run_mode": session.get("run_mode"), + "thinking_mode": session.get("thinking_mode"), + "host_mode": bool(session.get("host_mode")), + } + + +def _issue_login_nonce(username: str) -> str: + nonce = secrets.token_urlsafe(16) + user = (username or "").strip().lower() + if user: + state.active_login_nonces[user].add(nonce) + session["login_nonce"] = nonce + return nonce + + +def _revoke_login_nonce(username: str, nonce: str | None): + user = (username or "").strip().lower() + if not user or not nonce: + return + pool = state.active_login_nonces.get(user) + if not pool: + return + pool.discard(nonce) + if not pool: + state.active_login_nonces.pop(user, None) + + +def _cookie_debug_snapshot(): + raw = request.headers.get("Cookie", "") or "" + return raw[:300] + + +def _expire_session_cookie(response): + """尽可能清理不同 domain/path 组合下的 session cookie(线上反向代理场景兜底)。""" + cookie_name = current_app.config.get("SESSION_COOKIE_NAME", "session") + configured_domain = current_app.config.get("SESSION_COOKIE_DOMAIN") + host = (request.host or "").split(":")[0] + candidate_domains = {None, configured_domain, host} + if host.count(".") >= 2: + # 兼容 .example.com / example.com 两种历史写法 + parent = ".".join(host.split(".")[1:]) + candidate_domains.add(parent) + candidate_domains.add(f".{parent}") + for domain in candidate_domains: + try: + response.delete_cookie(cookie_name, path="/", domain=domain) + except Exception: + continue + response.headers["Cache-Control"] = "no-store" + return response @auth_bp.route('/api/csrf-token', methods=['GET']) @@ -46,6 +122,7 @@ def host_mode_enabled(): @auth_bp.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'GET': + auth_debug_log(f"[auth_debug] GET /login session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}") if session.get('username'): return redirect('/new') if not state.container_manager.has_capacity(): @@ -87,9 +164,16 @@ def login(): except Exception as exc: debug_log(f"加载个性化偏好失败: {exc}") + # 清理旧会话(尤其是从 host-login 切换到普通登录时遗留的 host_mode) + prev_username = session.get('username') + prev_nonce = session.get('login_nonce') + _revoke_login_nonce(prev_username, prev_nonce) + session.clear() + session['logged_in'] = True session['username'] = record.username session['role'] = record.role or 'user' + session['host_mode'] = False default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False) session['thinking_mode'] = default_thinking session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast") @@ -97,6 +181,7 @@ def login(): session['run_mode'] = preferred_run_mode session['thinking_mode'] = preferred_run_mode != 'fast' session.permanent = True + _issue_login_nonce(record.username) clear_failures("login", identifier=client_ip) try: state.container_manager.ensure_container(record.username, str(workspace.project_path), preferred_mode="docker") @@ -140,6 +225,7 @@ def host_login(): session['thinking_mode'] = default_thinking session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast") session.permanent = True + _issue_login_nonce('host') # 预先创建宿主机模式的终端/容器句柄(host 模式不会启动 Docker) try: @@ -155,7 +241,9 @@ def host_login(): @auth_bp.route('/register', methods=['GET', 'POST']) def register(): if request.method == 'GET': + auth_debug_log(f"[auth_debug] GET /register session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}") if session.get('username'): + auth_debug_log("[auth_debug] GET /register redirected to /new because session.username exists") return redirect('/new') return current_app.send_static_file('register.html') @@ -171,6 +259,7 @@ def register(): return jsonify({"success": False, "error": "注册请求过于频繁,请稍后再试。", "retry_after": retry_after}), 429 try: state.user_manager.register_user(username, email, password, invite_code) + auth_debug_log(f"[auth_debug] POST /register success username={username}") return jsonify({"success": True}) except ValueError as exc: return jsonify({"success": False, "error": str(exc)}), 400 @@ -178,10 +267,16 @@ def register(): return jsonify({"success": False, "error": str(exc)}), 500 -@auth_bp.route('/logout', methods=['POST']) +@auth_bp.route('/logout', methods=['GET', 'POST']) def logout(): + auth_debug_log( + f"[auth_debug] {request.method} /logout before_clear " + f"session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}" + ) username = session.get('username') + login_nonce = session.get('login_nonce') session.clear() + _revoke_login_nonce(username, login_nonce) if username: # 清理该用户相关的所有终端/容器(包含 API 多工作区) term_keys = [k for k in list(state.user_terminals.keys()) if k == username or k.startswith(f"{username}::")] @@ -194,7 +289,89 @@ def logout(): for token_value, meta in list(state.pending_socket_tokens.items()): if meta.get("username") == username: state.pending_socket_tokens.pop(token_value, None) - return jsonify({"success": True}) + auth_debug_log(f"[auth_debug] {request.method} /logout after_clear session={_session_debug_snapshot()}") + if request.method == 'GET': + resp = make_response(redirect('/login?logged_out=1')) + resp = _expire_session_cookie(resp) + auth_debug_log(f"[auth_debug] GET /logout set-cookie={resp.headers.get('Set-Cookie', '')[:300]}") + return resp + resp = make_response(jsonify({"success": True})) + resp = _expire_session_cookie(resp) + auth_debug_log(f"[auth_debug] POST /logout set-cookie={resp.headers.get('Set-Cookie', '')[:300]}") + return resp + + +@auth_bp.route('/api/session-status', methods=['GET']) +def session_status(): + """前端调试用:查看当前会话是否已清理。""" + snapshot = _session_debug_snapshot() + auth_debug_log(f"[auth_debug] GET /api/session-status session={snapshot} cookie={_cookie_debug_snapshot()}") + return jsonify({"success": True, "session": snapshot}) + + +@auth_bp.route('/api/tutorial-status', methods=['GET']) +@api_login_required +def get_tutorial_status(): + username = (get_current_username() or "").strip().lower() + if not username: + return jsonify({"success": False, "error": "未登录"}), 401 + + if bool(session.get("host_mode")) or username == "host": + return jsonify({ + "success": True, + "data": { + "username": username, + "applicable": False, + "tutorial_completed": True, + "should_prompt": False, + } + }) + + record = state.user_manager.get_user(username) + if not record: + return jsonify({ + "success": True, + "data": { + "username": username, + "applicable": False, + "tutorial_completed": True, + "should_prompt": False, + } + }) + + completed = bool(getattr(record, "tutorial_completed", False)) + return jsonify({ + "success": True, + "data": { + "username": record.username, + "applicable": True, + "tutorial_completed": completed, + "should_prompt": not completed, + } + }) + + +@auth_bp.route('/api/tutorial-status', methods=['POST']) +@api_login_required +def set_tutorial_status(): + username = (get_current_username() or "").strip().lower() + if not username: + return jsonify({"success": False, "error": "未登录"}), 401 + if bool(session.get("host_mode")) or username == "host": + return jsonify({"success": False, "error": "宿主机模式无需设置新手教程状态"}), 400 + + payload = request.get_json(silent=True) or {} + completed = payload.get("tutorial_completed", True) + record = state.user_manager.set_tutorial_completed(username, bool(completed)) + if not record: + return jsonify({"success": False, "error": "用户不存在"}), 404 + return jsonify({ + "success": True, + "data": { + "username": record.username, + "tutorial_completed": bool(record.tutorial_completed), + } + }) @auth_bp.route('/') diff --git a/server/auth_helpers.py b/server/auth_helpers.py index 1e04c5a..ecd3e04 100644 --- a/server/auth_helpers.py +++ b/server/auth_helpers.py @@ -10,7 +10,14 @@ from . import state def is_logged_in() -> bool: - return session.get('username') is not None + username = (session.get('username') or '').strip().lower() + if not username: + return False + nonce = session.get('login_nonce') + if not nonce: + return False + pool = state.active_login_nonces.get(username) or set() + return nonce in pool def login_required(view_func): diff --git a/server/state.py b/server/state.py index 0ac7877..bb0c9a9 100644 --- a/server/state.py +++ b/server/state.py @@ -36,6 +36,7 @@ RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque) FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {} pending_socket_tokens: Dict[str, Dict[str, Any]] = {} usage_trackers: Dict[str, UsageTracker] = {} +active_login_nonces: Dict[str, set] = defaultdict(set) MONITOR_SNAPSHOT_CACHE: Dict[str, Dict[str, Any]] = {} MONITOR_SNAPSHOT_CACHE_LIMIT = 120 @@ -86,6 +87,7 @@ __all__ = [ "FAILURE_TRACKERS", "pending_socket_tokens", "usage_trackers", + "active_login_nonces", "MONITOR_SNAPSHOT_CACHE", "MONITOR_SNAPSHOT_CACHE_LIMIT", "PROJECT_STORAGE_CACHE", diff --git a/static/src/App.vue b/static/src/App.vue index 4ade2c0..720f47a 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -352,6 +352,13 @@ +
{ + // 避免在初始加载阶段被覆盖,加载完成后再兜底检查一次 + this.checkTutorialPrompt(); + }) + .catch(() => {}); + } // 注册全局事件处理器(用于任务轮询) (window as any).__taskEventHandler = (event: any) => { diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 4cae7d5..c067cc0 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -2,6 +2,7 @@ import { usePolicyStore } from '../../stores/policy'; import { useModelStore } from '../../stores/model'; import { usePersonalizationStore } from '../../stores/personalization'; +import { useTutorialStore } from '../../stores/tutorial'; import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer'; import { scrollToBottom as scrollToBottomHelper, @@ -257,6 +258,74 @@ export const uiMethods = { this.personalizationOpenDrawer(); }, + async checkTutorialPrompt() { + this.tutorialPromptVisible = false; + this.tutorialPromptUsername = ''; + try { + const resp = await fetch('/api/tutorial-status', { credentials: 'same-origin' }); + const data = await resp.json().catch(() => ({})); + console.info('[tutorial-prompt] status response:', { ok: resp.ok, status: resp.status, data }); + if (!resp.ok || !data?.success) { + return; + } + const payload = data.data || {}; + if (!payload.applicable || !payload.should_prompt) { + return; + } + this.tutorialPromptUsername = payload.username || ''; + this.tutorialPromptVisible = true; + } catch (error) { + console.warn('获取新手教程提示状态失败:', error); + } + }, + + async updateTutorialPromptStatus(completed = true) { + this.tutorialPromptLoading = true; + try { + const resp = await fetch('/api/tutorial-status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ tutorial_completed: !!completed }) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || '更新新手教程状态失败'); + } + const tutorialStore = useTutorialStore(); + tutorialStore.markCompleted(); + this.tutorialPromptVisible = false; + return true; + } catch (error) { + console.warn('更新新手教程提示状态失败:', error); + this.uiPushToast({ + title: '提示', + message: '保存新手教程状态失败,请稍后重试', + type: 'warning' + }); + return false; + } finally { + this.tutorialPromptLoading = false; + } + }, + + async handleNewUserTutorialStart() { + const ok = await this.updateTutorialPromptStatus(true); + if (!ok) { + return; + } + const personalStore = usePersonalizationStore(); + personalStore.closeDrawer(); + const tutorialStore = useTutorialStore(); + window.setTimeout(() => { + tutorialStore.startTutorial(); + }, 220); + }, + + async handleNewUserTutorialSkip() { + await this.updateTutorialPromptStatus(true); + }, + fetchTodoList() { return this.fileFetchTodoList(); }, diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 8236f59..93f8c09 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -100,6 +100,11 @@ export function dataState() { reviewPreviewError: null, reviewPreviewLimit: 20, reviewSendToModel: true, - reviewGeneratedPath: null + reviewGeneratedPath: null, + + // 新手教程首次引导弹窗 + tutorialPromptVisible: false, + tutorialPromptLoading: false, + tutorialPromptUsername: '' }; } diff --git a/static/src/auth/LoginApp.vue b/static/src/auth/LoginApp.vue index 05c68d2..cdb3b2d 100644 --- a/static/src/auth/LoginApp.vue +++ b/static/src/auth/LoginApp.vue @@ -139,5 +139,13 @@ onMounted(async () => { } catch (_err) { hostModeEnabled.value = false; } + + try { + const resp = await fetch('/api/session-status', { credentials: 'same-origin' }); + const data = await resp.json(); + console.info('[auth-debug] login page session-status:', data); + } catch (err) { + console.warn('[auth-debug] login page session-status failed:', err); + } }); diff --git a/static/src/auth/RegisterApp.vue b/static/src/auth/RegisterApp.vue index 5a95e43..412b2f8 100644 --- a/static/src/auth/RegisterApp.vue +++ b/static/src/auth/RegisterApp.vue @@ -121,5 +121,13 @@ onMounted(async () => { // ignore } } + + try { + const resp = await fetch('/api/session-status', { credentials: 'same-origin' }); + const data = await resp.json(); + console.info('[auth-debug] register page session-status:', data); + } catch (err) { + console.warn('[auth-debug] register page session-status failed:', err); + } }); diff --git a/static/src/components/overlay/NewUserTutorialPrompt.vue b/static/src/components/overlay/NewUserTutorialPrompt.vue new file mode 100644 index 0000000..6f0a284 --- /dev/null +++ b/static/src/components/overlay/NewUserTutorialPrompt.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index de8cd3b..4459096 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -1300,6 +1300,13 @@ const setActiveTab = (tab: PersonalTab) => { }; const startTutorial = () => { + fetch('/api/tutorial-status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ tutorial_completed: true }) + }).catch(() => {}); + tutorialStore.markCompleted(); personalization.closeDrawer(); window.setTimeout(() => { tutorialStore.startTutorial(); diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index 844a3dc..cd7878b 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -534,21 +534,31 @@ export const usePersonalizationStore = defineStore('personalization', { }, async logout() { try { - const resp = await fetch('/logout', { method: 'POST' }); - let result: any = {}; + console.info('[auth-debug] logout clicked, sending POST /logout'); + const resp = await fetch('/logout', { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + }); + console.info('[auth-debug] logout POST status:', resp.status); + let payload: any = null; try { - result = await resp.json(); - } catch (err) { - result = {}; + payload = await resp.json(); + } catch (_err) { + payload = null; } - if (!resp.ok || (result && result.success === false)) { - const message = (result && (result.error || result.message)) || '退出失败'; - throw new Error(message); + console.info('[auth-debug] logout POST payload:', payload); + if (resp.ok && (!payload || payload.success !== false)) { + window.location.replace(`/login?logged_out=1&ts=${Date.now()}`); + return; } - window.location.href = '/login'; + // 接口失败时,走 GET /logout 做兜底清理 + window.location.replace(`/logout?ts=${Date.now()}`); } catch (error: any) { console.error('退出登录失败:', error); this.error = error?.message || '退出登录失败,请稍后重试'; + // 兜底:直接访问 GET /logout + window.location.replace(`/logout?ts=${Date.now()}`); } }, persistExperiments() { diff --git a/vite.config.ts b/vite.config.ts index 44138b6..3761c63 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,6 +18,8 @@ const loginEntry = fileURLToPath(new URL('./static/src/auth/loginMain.ts', impor const registerEntry = fileURLToPath(new URL('./static/src/auth/registerMain.ts', import.meta.url)); export default defineConfig({ + // 统一静态资源基路径,避免动态 import 走到 /assets/* 导致 404 + base: '/static/dist/', plugins: [vue()], build: { outDir: 'static/dist',