fix: persist onboarding prompt status and bump android app to 1.0.20

This commit is contained in:
JOJO 2026-04-07 20:08:33 +08:00
parent 9a2bae04b5
commit d798d39da3
17 changed files with 469 additions and 18 deletions

View File

@ -1,5 +1,11 @@
# App 更新说明
# 1.0.20
- 新增“新用户欢迎弹窗 + 新手教程”持久化机制:普通账号首次进入会提示是否开始教程,选择“开始吧”或“不再提示”后将状态写入 `data/users.json`
- 新增后端教程状态接口(查询/更新),并排除宿主机模式用户,避免 host 模式触发教程弹窗
- 修复宿主机模式切回普通账号登录时可能遗留 `host_mode` 状态,导致教程提示判定异常的问题
- 优化新手教程欢迎弹窗视觉风格:按钮与系统现有主题风格统一,支持多主题配色
# 1.0.19
- 修复 Android APK 内「个人空间-新手教程」期间无法上下滚动的问题:教程遮罩层现在会正确转发触摸滑动到可滚动容器
- 优化教程滚动目标识别:优先命中当前可滚动页面区域,减少 WebView 场景下滑动失效

View File

@ -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"
}

View File

@ -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)

View File

@ -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('/')

View File

@ -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):

View File

@ -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",

View File

@ -352,6 +352,13 @@
</transition>
<SubAgentActivityDialog />
<TutorialOverlay />
<NewUserTutorialPrompt
:visible="tutorialPromptVisible"
:username="tutorialPromptUsername"
:loading="tutorialPromptLoading"
@start="handleNewUserTutorialStart"
@skip="handleNewUserTutorialSkip"
/>
<div
v-if="isMobileViewport"

View File

@ -10,6 +10,7 @@ import ImagePicker from '../components/overlay/ImagePicker.vue';
import ConversationReviewDialog from '../components/overlay/ConversationReviewDialog.vue';
import SubAgentActivityDialog from '../components/overlay/SubAgentActivityDialog.vue';
import TutorialOverlay from '../components/overlay/TutorialOverlay.vue';
import NewUserTutorialPrompt from '../components/overlay/NewUserTutorialPrompt.vue';
export const appComponents = {
ChatArea,
@ -23,5 +24,6 @@ export const appComponents = {
ImagePicker,
ConversationReviewDialog,
SubAgentActivityDialog,
TutorialOverlay
TutorialOverlay,
NewUserTutorialPrompt
};

View File

@ -40,8 +40,17 @@ export async function mounted() {
setupShowImageObserver();
// 立即加载初始数据(并行获取状态,优先同步运行模式)
this.loadInitialData();
const initialDataPromise = this.loadInitialData();
this.startConnectionHeartbeat();
this.checkTutorialPrompt();
if (initialDataPromise && typeof initialDataPromise.then === 'function') {
initialDataPromise
.then(() => {
// 避免在初始加载阶段被覆盖,加载完成后再兜底检查一次
this.checkTutorialPrompt();
})
.catch(() => {});
}
// 注册全局事件处理器(用于任务轮询)
(window as any).__taskEventHandler = (event: any) => {

View File

@ -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();
},

View File

@ -100,6 +100,11 @@ export function dataState() {
reviewPreviewError: null,
reviewPreviewLimit: 20,
reviewSendToModel: true,
reviewGeneratedPath: null
reviewGeneratedPath: null,
// 新手教程首次引导弹窗
tutorialPromptVisible: false,
tutorialPromptLoading: false,
tutorialPromptUsername: ''
};
}

View File

@ -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);
}
});
</script>

View File

@ -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);
}
});
</script>

View File

@ -0,0 +1,113 @@
<template>
<transition name="overlay-fade">
<div v-if="visible" class="new-user-tutorial-mask" @click.self="emitSkip">
<div class="new-user-tutorial-dialog" role="dialog" aria-modal="true" aria-label="新手教程提示">
<p class="eyebrow">欢迎</p>
<h3>新用户 {{ username || '用户' }}</h3>
<p class="desc">是否需要通过新手教程来快速认识这个系统</p>
<div class="actions">
<button type="button" class="primary" :disabled="loading" @click="emitStart">
{{ loading ? '处理中...' : '开始吧!' }}
</button>
<button type="button" class="ghost" :disabled="loading" @click="emitSkip">
不再提示
</button>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
defineProps<{
visible: boolean;
username: string;
loading?: boolean;
}>();
const emit = defineEmits<{
(e: 'start'): void;
(e: 'skip'): void;
}>();
const emitStart = () => emit('start');
const emitSkip = () => emit('skip');
</script>
<style scoped>
.new-user-tutorial-mask {
position: fixed;
inset: 0;
z-index: 2100;
display: grid;
place-items: center;
padding: 24px;
background: var(--theme-overlay-scrim, rgba(15, 23, 42, 0.45));
backdrop-filter: blur(2px);
}
.new-user-tutorial-dialog {
width: min(520px, calc(100vw - 32px));
border-radius: 18px;
border: 1px solid var(--theme-control-border-strong, rgba(148, 163, 184, 0.35));
background: var(--theme-surface-card, #ffffff);
box-shadow: var(--theme-shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.26));
padding: 22px 22px 18px;
}
.eyebrow {
margin: 0;
font-size: 12px;
color: var(--theme-text-muted, #64748b);
}
h3 {
margin: 6px 0 8px;
font-size: 20px;
}
.desc {
margin: 0;
color: var(--theme-text-muted, #64748b);
line-height: 1.6;
}
.actions {
margin-top: 18px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
button {
min-height: 38px;
border-radius: 999px;
border: 1px solid var(--theme-control-border, rgba(148, 163, 184, 0.3));
padding: 0 15px;
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: transform 0.16s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.primary {
border: none;
background: linear-gradient(135deg, var(--claude-accent), var(--claude-accent-strong));
color: #fff;
}
.ghost {
background: var(--theme-surface-soft, rgba(248, 250, 252, 0.9));
color: var(--claude-text, inherit);
}
button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--theme-shadow-soft);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@ -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();

View File

@ -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() {

View File

@ -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',