fix: persist onboarding prompt status and bump android app to 1.0.20
This commit is contained in:
parent
9a2bae04b5
commit
d798d39da3
@ -1,5 +1,11 @@
|
|||||||
# App 更新说明
|
# App 更新说明
|
||||||
|
|
||||||
|
# 1.0.20
|
||||||
|
- 新增“新用户欢迎弹窗 + 新手教程”持久化机制:普通账号首次进入会提示是否开始教程,选择“开始吧”或“不再提示”后将状态写入 `data/users.json`
|
||||||
|
- 新增后端教程状态接口(查询/更新),并排除宿主机模式用户,避免 host 模式触发教程弹窗
|
||||||
|
- 修复宿主机模式切回普通账号登录时可能遗留 `host_mode` 状态,导致教程提示判定异常的问题
|
||||||
|
- 优化新手教程欢迎弹窗视觉风格:按钮与系统现有主题风格统一,支持多主题配色
|
||||||
|
|
||||||
# 1.0.19
|
# 1.0.19
|
||||||
- 修复 Android APK 内「个人空间-新手教程」期间无法上下滚动的问题:教程遮罩层现在会正确转发触摸滑动到可滚动容器
|
- 修复 Android APK 内「个人空间-新手教程」期间无法上下滚动的问题:教程遮罩层现在会正确转发触摸滑动到可滚动容器
|
||||||
- 优化教程滚动目标识别:优先命中当前可滚动页面区域,减少 WebView 场景下滑动失效
|
- 优化教程滚动目标识别:优先命中当前可滚动页面区域,减少 WebView 场景下滑动失效
|
||||||
|
|||||||
@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.cyjai.agent"
|
applicationId = "com.cyjai.agent"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 21
|
versionCode = 22
|
||||||
versionName = "1.0.19"
|
versionName = "1.0.20"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class UserRecord:
|
|||||||
created_at: str
|
created_at: str
|
||||||
invite_code: Optional[str] = None
|
invite_code: Optional[str] = None
|
||||||
role: str = "user"
|
role: str = "user"
|
||||||
|
tutorial_completed: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -93,6 +94,7 @@ class UserManager:
|
|||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
invite_code=invite_entry["code"],
|
invite_code=invite_entry["code"],
|
||||||
|
tutorial_completed=False,
|
||||||
)
|
)
|
||||||
self._users[username] = record
|
self._users[username] = record
|
||||||
self._index_user(record)
|
self._index_user(record)
|
||||||
@ -189,6 +191,15 @@ class UserManager:
|
|||||||
"""返回当前注册用户的浅拷贝字典,键为用户名。"""
|
"""返回当前注册用户的浅拷贝字典,键为用户名。"""
|
||||||
return dict(self._users)
|
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
|
# Internal helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -215,6 +226,7 @@ class UserManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
migrated = False
|
||||||
with open(self.users_file, "r", encoding="utf-8") as f:
|
with open(self.users_file, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
@ -231,9 +243,14 @@ class UserManager:
|
|||||||
created_at=payload.get("created_at", ""),
|
created_at=payload.get("created_at", ""),
|
||||||
invite_code=payload.get("invite_code"),
|
invite_code=payload.get("invite_code"),
|
||||||
role=payload.get("role", "user"),
|
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._users[username] = record
|
||||||
self._index_user(record)
|
self._index_user(record)
|
||||||
|
if migrated:
|
||||||
|
self._save_users()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
raise RuntimeError(f"无法解析用户数据文件: {self.users_file}")
|
raise RuntimeError(f"无法解析用户数据文件: {self.users_file}")
|
||||||
|
|
||||||
@ -246,6 +263,7 @@ class UserManager:
|
|||||||
"created_at": record.created_at,
|
"created_at": record.created_at,
|
||||||
"invite_code": record.invite_code,
|
"invite_code": record.invite_code,
|
||||||
"role": record.role,
|
"role": record.role,
|
||||||
|
"tutorial_completed": bool(record.tutorial_completed),
|
||||||
}
|
}
|
||||||
for username, record in self._users.items()
|
for username, record in self._users.items()
|
||||||
}
|
}
|
||||||
@ -312,6 +330,7 @@ class UserManager:
|
|||||||
created_at=datetime.utcnow().isoformat(),
|
created_at=datetime.utcnow().isoformat(),
|
||||||
invite_code=None,
|
invite_code=None,
|
||||||
role="admin",
|
role="admin",
|
||||||
|
tutorial_completed=False,
|
||||||
)
|
)
|
||||||
self._users[admin_name] = record
|
self._users[admin_name] = record
|
||||||
self._index_user(record)
|
self._index_user(record)
|
||||||
|
|||||||
183
server/auth.py
183
server/auth.py
@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
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.personalization_manager import load_personalization_config
|
||||||
from modules.user_manager import UserWorkspace
|
from modules.user_manager import UserWorkspace
|
||||||
@ -12,6 +14,7 @@ from config import (
|
|||||||
LOGS_DIR,
|
LOGS_DIR,
|
||||||
UPLOAD_QUARANTINE_SUBDIR,
|
UPLOAD_QUARANTINE_SUBDIR,
|
||||||
LINUX_SAFETY,
|
LINUX_SAFETY,
|
||||||
|
LOGS_DIR,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .auth_helpers import login_required, api_login_required, get_current_user_record, get_current_username
|
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
|
from .utils_common import debug_log
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__)
|
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'])
|
@auth_bp.route('/api/csrf-token', methods=['GET'])
|
||||||
@ -46,6 +122,7 @@ def host_mode_enabled():
|
|||||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
|
auth_debug_log(f"[auth_debug] GET /login session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}")
|
||||||
if session.get('username'):
|
if session.get('username'):
|
||||||
return redirect('/new')
|
return redirect('/new')
|
||||||
if not state.container_manager.has_capacity():
|
if not state.container_manager.has_capacity():
|
||||||
@ -87,9 +164,16 @@ def login():
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
debug_log(f"加载个性化偏好失败: {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['logged_in'] = True
|
||||||
session['username'] = record.username
|
session['username'] = record.username
|
||||||
session['role'] = record.role or 'user'
|
session['role'] = record.role or 'user'
|
||||||
|
session['host_mode'] = False
|
||||||
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
|
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
|
||||||
session['thinking_mode'] = default_thinking
|
session['thinking_mode'] = default_thinking
|
||||||
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
|
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['run_mode'] = preferred_run_mode
|
||||||
session['thinking_mode'] = preferred_run_mode != 'fast'
|
session['thinking_mode'] = preferred_run_mode != 'fast'
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
|
_issue_login_nonce(record.username)
|
||||||
clear_failures("login", identifier=client_ip)
|
clear_failures("login", identifier=client_ip)
|
||||||
try:
|
try:
|
||||||
state.container_manager.ensure_container(record.username, str(workspace.project_path), preferred_mode="docker")
|
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['thinking_mode'] = default_thinking
|
||||||
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
|
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
|
_issue_login_nonce('host')
|
||||||
|
|
||||||
# 预先创建宿主机模式的终端/容器句柄(host 模式不会启动 Docker)
|
# 预先创建宿主机模式的终端/容器句柄(host 模式不会启动 Docker)
|
||||||
try:
|
try:
|
||||||
@ -155,7 +241,9 @@ def host_login():
|
|||||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||||
def register():
|
def register():
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
|
auth_debug_log(f"[auth_debug] GET /register session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}")
|
||||||
if session.get('username'):
|
if session.get('username'):
|
||||||
|
auth_debug_log("[auth_debug] GET /register redirected to /new because session.username exists")
|
||||||
return redirect('/new')
|
return redirect('/new')
|
||||||
return current_app.send_static_file('register.html')
|
return current_app.send_static_file('register.html')
|
||||||
|
|
||||||
@ -171,6 +259,7 @@ def register():
|
|||||||
return jsonify({"success": False, "error": "注册请求过于频繁,请稍后再试。", "retry_after": retry_after}), 429
|
return jsonify({"success": False, "error": "注册请求过于频繁,请稍后再试。", "retry_after": retry_after}), 429
|
||||||
try:
|
try:
|
||||||
state.user_manager.register_user(username, email, password, invite_code)
|
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})
|
return jsonify({"success": True})
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"success": False, "error": str(exc)}), 400
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
@ -178,10 +267,16 @@ def register():
|
|||||||
return jsonify({"success": False, "error": str(exc)}), 500
|
return jsonify({"success": False, "error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/logout', methods=['POST'])
|
@auth_bp.route('/logout', methods=['GET', 'POST'])
|
||||||
def logout():
|
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')
|
username = session.get('username')
|
||||||
|
login_nonce = session.get('login_nonce')
|
||||||
session.clear()
|
session.clear()
|
||||||
|
_revoke_login_nonce(username, login_nonce)
|
||||||
if username:
|
if username:
|
||||||
# 清理该用户相关的所有终端/容器(包含 API 多工作区)
|
# 清理该用户相关的所有终端/容器(包含 API 多工作区)
|
||||||
term_keys = [k for k in list(state.user_terminals.keys()) if k == username or k.startswith(f"{username}::")]
|
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()):
|
for token_value, meta in list(state.pending_socket_tokens.items()):
|
||||||
if meta.get("username") == username:
|
if meta.get("username") == username:
|
||||||
state.pending_socket_tokens.pop(token_value, None)
|
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('/')
|
@auth_bp.route('/')
|
||||||
|
|||||||
@ -10,7 +10,14 @@ from . import state
|
|||||||
|
|
||||||
|
|
||||||
def is_logged_in() -> bool:
|
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):
|
def login_required(view_func):
|
||||||
|
|||||||
@ -36,6 +36,7 @@ RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque)
|
|||||||
FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {}
|
FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {}
|
||||||
pending_socket_tokens: Dict[str, Dict[str, Any]] = {}
|
pending_socket_tokens: Dict[str, Dict[str, Any]] = {}
|
||||||
usage_trackers: Dict[str, UsageTracker] = {}
|
usage_trackers: Dict[str, UsageTracker] = {}
|
||||||
|
active_login_nonces: Dict[str, set] = defaultdict(set)
|
||||||
|
|
||||||
MONITOR_SNAPSHOT_CACHE: Dict[str, Dict[str, Any]] = {}
|
MONITOR_SNAPSHOT_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||||
MONITOR_SNAPSHOT_CACHE_LIMIT = 120
|
MONITOR_SNAPSHOT_CACHE_LIMIT = 120
|
||||||
@ -86,6 +87,7 @@ __all__ = [
|
|||||||
"FAILURE_TRACKERS",
|
"FAILURE_TRACKERS",
|
||||||
"pending_socket_tokens",
|
"pending_socket_tokens",
|
||||||
"usage_trackers",
|
"usage_trackers",
|
||||||
|
"active_login_nonces",
|
||||||
"MONITOR_SNAPSHOT_CACHE",
|
"MONITOR_SNAPSHOT_CACHE",
|
||||||
"MONITOR_SNAPSHOT_CACHE_LIMIT",
|
"MONITOR_SNAPSHOT_CACHE_LIMIT",
|
||||||
"PROJECT_STORAGE_CACHE",
|
"PROJECT_STORAGE_CACHE",
|
||||||
|
|||||||
@ -352,6 +352,13 @@
|
|||||||
</transition>
|
</transition>
|
||||||
<SubAgentActivityDialog />
|
<SubAgentActivityDialog />
|
||||||
<TutorialOverlay />
|
<TutorialOverlay />
|
||||||
|
<NewUserTutorialPrompt
|
||||||
|
:visible="tutorialPromptVisible"
|
||||||
|
:username="tutorialPromptUsername"
|
||||||
|
:loading="tutorialPromptLoading"
|
||||||
|
@start="handleNewUserTutorialStart"
|
||||||
|
@skip="handleNewUserTutorialSkip"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isMobileViewport"
|
v-if="isMobileViewport"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import ImagePicker from '../components/overlay/ImagePicker.vue';
|
|||||||
import ConversationReviewDialog from '../components/overlay/ConversationReviewDialog.vue';
|
import ConversationReviewDialog from '../components/overlay/ConversationReviewDialog.vue';
|
||||||
import SubAgentActivityDialog from '../components/overlay/SubAgentActivityDialog.vue';
|
import SubAgentActivityDialog from '../components/overlay/SubAgentActivityDialog.vue';
|
||||||
import TutorialOverlay from '../components/overlay/TutorialOverlay.vue';
|
import TutorialOverlay from '../components/overlay/TutorialOverlay.vue';
|
||||||
|
import NewUserTutorialPrompt from '../components/overlay/NewUserTutorialPrompt.vue';
|
||||||
|
|
||||||
export const appComponents = {
|
export const appComponents = {
|
||||||
ChatArea,
|
ChatArea,
|
||||||
@ -23,5 +24,6 @@ export const appComponents = {
|
|||||||
ImagePicker,
|
ImagePicker,
|
||||||
ConversationReviewDialog,
|
ConversationReviewDialog,
|
||||||
SubAgentActivityDialog,
|
SubAgentActivityDialog,
|
||||||
TutorialOverlay
|
TutorialOverlay,
|
||||||
|
NewUserTutorialPrompt
|
||||||
};
|
};
|
||||||
|
|||||||
@ -40,8 +40,17 @@ export async function mounted() {
|
|||||||
setupShowImageObserver();
|
setupShowImageObserver();
|
||||||
|
|
||||||
// 立即加载初始数据(并行获取状态,优先同步运行模式)
|
// 立即加载初始数据(并行获取状态,优先同步运行模式)
|
||||||
this.loadInitialData();
|
const initialDataPromise = this.loadInitialData();
|
||||||
this.startConnectionHeartbeat();
|
this.startConnectionHeartbeat();
|
||||||
|
this.checkTutorialPrompt();
|
||||||
|
if (initialDataPromise && typeof initialDataPromise.then === 'function') {
|
||||||
|
initialDataPromise
|
||||||
|
.then(() => {
|
||||||
|
// 避免在初始加载阶段被覆盖,加载完成后再兜底检查一次
|
||||||
|
this.checkTutorialPrompt();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// 注册全局事件处理器(用于任务轮询)
|
// 注册全局事件处理器(用于任务轮询)
|
||||||
(window as any).__taskEventHandler = (event: any) => {
|
(window as any).__taskEventHandler = (event: any) => {
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { usePolicyStore } from '../../stores/policy';
|
import { usePolicyStore } from '../../stores/policy';
|
||||||
import { useModelStore } from '../../stores/model';
|
import { useModelStore } from '../../stores/model';
|
||||||
import { usePersonalizationStore } from '../../stores/personalization';
|
import { usePersonalizationStore } from '../../stores/personalization';
|
||||||
|
import { useTutorialStore } from '../../stores/tutorial';
|
||||||
import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer';
|
import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer';
|
||||||
import {
|
import {
|
||||||
scrollToBottom as scrollToBottomHelper,
|
scrollToBottom as scrollToBottomHelper,
|
||||||
@ -257,6 +258,74 @@ export const uiMethods = {
|
|||||||
this.personalizationOpenDrawer();
|
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() {
|
fetchTodoList() {
|
||||||
return this.fileFetchTodoList();
|
return this.fileFetchTodoList();
|
||||||
},
|
},
|
||||||
|
|||||||
@ -100,6 +100,11 @@ export function dataState() {
|
|||||||
reviewPreviewError: null,
|
reviewPreviewError: null,
|
||||||
reviewPreviewLimit: 20,
|
reviewPreviewLimit: 20,
|
||||||
reviewSendToModel: true,
|
reviewSendToModel: true,
|
||||||
reviewGeneratedPath: null
|
reviewGeneratedPath: null,
|
||||||
|
|
||||||
|
// 新手教程首次引导弹窗
|
||||||
|
tutorialPromptVisible: false,
|
||||||
|
tutorialPromptLoading: false,
|
||||||
|
tutorialPromptUsername: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,5 +139,13 @@ onMounted(async () => {
|
|||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
hostModeEnabled.value = false;
|
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>
|
</script>
|
||||||
|
|||||||
@ -121,5 +121,13 @@ onMounted(async () => {
|
|||||||
// ignore
|
// 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>
|
</script>
|
||||||
|
|||||||
113
static/src/components/overlay/NewUserTutorialPrompt.vue
Normal file
113
static/src/components/overlay/NewUserTutorialPrompt.vue
Normal 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>
|
||||||
@ -1300,6 +1300,13 @@ const setActiveTab = (tab: PersonalTab) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startTutorial = () => {
|
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();
|
personalization.closeDrawer();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
tutorialStore.startTutorial();
|
tutorialStore.startTutorial();
|
||||||
|
|||||||
@ -534,21 +534,31 @@ export const usePersonalizationStore = defineStore('personalization', {
|
|||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/logout', { method: 'POST' });
|
console.info('[auth-debug] logout clicked, sending POST /logout');
|
||||||
let result: any = {};
|
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 {
|
try {
|
||||||
result = await resp.json();
|
payload = await resp.json();
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
result = {};
|
payload = null;
|
||||||
}
|
}
|
||||||
if (!resp.ok || (result && result.success === false)) {
|
console.info('[auth-debug] logout POST payload:', payload);
|
||||||
const message = (result && (result.error || result.message)) || '退出失败';
|
if (resp.ok && (!payload || payload.success !== false)) {
|
||||||
throw new Error(message);
|
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) {
|
} catch (error: any) {
|
||||||
console.error('退出登录失败:', error);
|
console.error('退出登录失败:', error);
|
||||||
this.error = error?.message || '退出登录失败,请稍后重试';
|
this.error = error?.message || '退出登录失败,请稍后重试';
|
||||||
|
// 兜底:直接访问 GET /logout
|
||||||
|
window.location.replace(`/logout?ts=${Date.now()}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
persistExperiments() {
|
persistExperiments() {
|
||||||
|
|||||||
@ -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));
|
const registerEntry = fileURLToPath(new URL('./static/src/auth/registerMain.ts', import.meta.url));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// 统一静态资源基路径,避免动态 import 走到 /assets/* 导致 404
|
||||||
|
base: '/static/dist/',
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
build: {
|
build: {
|
||||||
outDir: 'static/dist',
|
outDir: 'static/dist',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user