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 更新说明
|
||||
|
||||
# 1.0.20
|
||||
- 新增“新用户欢迎弹窗 + 新手教程”持久化机制:普通账号首次进入会提示是否开始教程,选择“开始吧”或“不再提示”后将状态写入 `data/users.json`
|
||||
- 新增后端教程状态接口(查询/更新),并排除宿主机模式用户,避免 host 模式触发教程弹窗
|
||||
- 修复宿主机模式切回普通账号登录时可能遗留 `host_mode` 状态,导致教程提示判定异常的问题
|
||||
- 优化新手教程欢迎弹窗视觉风格:按钮与系统现有主题风格统一,支持多主题配色
|
||||
|
||||
# 1.0.19
|
||||
- 修复 Android APK 内「个人空间-新手教程」期间无法上下滚动的问题:教程遮罩层现在会正确转发触摸滑动到可滚动容器
|
||||
- 优化教程滚动目标识别:优先命中当前可滚动页面区域,减少 WebView 场景下滑动失效
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
183
server/auth.py
183
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('/')
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -352,6 +352,13 @@
|
||||
</transition>
|
||||
<SubAgentActivityDialog />
|
||||
<TutorialOverlay />
|
||||
<NewUserTutorialPrompt
|
||||
:visible="tutorialPromptVisible"
|
||||
:username="tutorialPromptUsername"
|
||||
:loading="tutorialPromptLoading"
|
||||
@start="handleNewUserTutorialStart"
|
||||
@skip="handleNewUserTutorialSkip"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isMobileViewport"
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -100,6 +100,11 @@ export function dataState() {
|
||||
reviewPreviewError: null,
|
||||
reviewPreviewLimit: 20,
|
||||
reviewSendToModel: true,
|
||||
reviewGeneratedPath: null
|
||||
reviewGeneratedPath: null,
|
||||
|
||||
// 新手教程首次引导弹窗
|
||||
tutorialPromptVisible: false,
|
||||
tutorialPromptLoading: false,
|
||||
tutorialPromptUsername: ''
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
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 = () => {
|
||||
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();
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user