diff --git a/.idea/misc.xml b/.idea/misc.xml index 1a8319f..e6be3f1 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,9 +1,5 @@ - - diff --git a/client/js/data/UserManager.js b/client/js/data/UserManager.js index ba9ce14..e3f3cd3 100644 --- a/client/js/data/UserManager.js +++ b/client/js/data/UserManager.js @@ -9,50 +9,125 @@ export default class UserManager { this.openid = null; this.nickname = ''; this.avatarUrl = ''; + this.token = ''; this.isLoggedIn = false; } /** - * 初始化用户 + * 检查是否已登录(只检查本地缓存) + * @returns {boolean} 是否已登录 + */ + checkLogin() { + const cached = wx.getStorageSync('userInfo'); + if (cached && cached.userId && cached.token) { + this.userId = cached.userId; + this.openid = cached.openid; + this.nickname = cached.nickname || '游客'; + this.avatarUrl = cached.avatarUrl || ''; + this.token = cached.token; + this.isLoggedIn = true; + return true; + } + return false; + } + + /** + * 初始化用户(只恢复缓存,不自动登录) */ async init() { - try { - // 尝试从本地存储恢复用户信息 - const cached = wx.getStorageSync('userInfo'); - if (cached) { - this.userId = cached.userId; - this.openid = cached.openid; - this.nickname = cached.nickname; - this.avatarUrl = cached.avatarUrl; - this.isLoggedIn = true; - return; - } + // 只检查本地缓存,不自动登录 + this.checkLogin(); + } + /** + * 执行登录(供登录按钮调用) + * @param {Object} userInfo - 微信用户信息(头像、昵称等),可选 + */ + async doLogin(userInfo = null) { + try { // 获取登录code const { code } = await this.wxLogin(); - // 调用后端登录接口 - const result = await post('/user/login', { code }); + // 调用后端登录接口,传入用户信息 + const result = await post('/user/login', { + code, + userInfo: userInfo ? { + nickname: userInfo.nickName, + avatarUrl: userInfo.avatarUrl, + gender: userInfo.gender || 0 + } : null + }); this.userId = result.userId; this.openid = result.openid; - this.nickname = result.nickname || '游客'; - this.avatarUrl = result.avatarUrl || ''; + // 优先使用后端返回的,其次用授权获取的 + this.nickname = result.nickname || (userInfo?.nickName) || '游客'; + this.avatarUrl = result.avatarUrl || (userInfo?.avatarUrl) || ''; + this.token = result.token || ''; this.isLoggedIn = true; - // 缓存用户信息 + // 缓存用户信息(包含 token) wx.setStorageSync('userInfo', { userId: this.userId, openid: this.openid, nickname: this.nickname, - avatarUrl: this.avatarUrl + avatarUrl: this.avatarUrl, + token: this.token }); + + return { + userId: this.userId, + nickname: this.nickname, + avatarUrl: this.avatarUrl + }; } catch (error) { - console.error('用户初始化失败:', error); - // 使用临时身份 - this.userId = 0; - this.nickname = '游客'; - this.isLoggedIn = false; + console.error('登录失败:', error); + throw error; + } + } + + /** + * 退出登录 + */ + logout() { + this.userId = null; + this.openid = null; + this.nickname = ''; + this.avatarUrl = ''; + this.token = ''; + this.isLoggedIn = false; + wx.removeStorageSync('userInfo'); + } + + /** + * 更新用户资料 + */ + async updateProfile(nickname, avatarUrl) { + if (!this.isLoggedIn) return false; + try { + await post('/user/profile', { + nickname, + avatarUrl, + gender: 0 + }, { params: { userId: this.userId } }); + + // 更新本地数据 + this.nickname = nickname; + this.avatarUrl = avatarUrl; + + // 更新缓存(保留 token) + wx.setStorageSync('userInfo', { + userId: this.userId, + openid: this.openid, + nickname: this.nickname, + avatarUrl: this.avatarUrl, + token: this.token + }); + + return true; + } catch (e) { + console.error('更新资料失败:', e); + return false; } } @@ -63,12 +138,16 @@ export default class UserManager { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('登录超时')); - }, 3000); + }, 5000); wx.login({ success: (res) => { clearTimeout(timeout); - resolve(res); + if (res.code) { + resolve(res); + } else { + reject(new Error('获取code失败')); + } }, fail: (err) => { clearTimeout(timeout); diff --git a/client/js/main.js b/client/js/main.js index 2e1b594..62a03f2 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -46,13 +46,37 @@ export default class Main { }); console.log('[Main] 云环境初始化完成'); - // 用户初始化(失败不阻塞) - console.log('[Main] 初始化用户...'); - await this.userManager.init().catch(e => { - console.warn('[Main] 用户初始化失败,使用游客模式:', e); - }); - console.log('[Main] 用户初始化完成'); + // 检查用户是否已登录(只检查缓存,不自动登录) + console.log('[Main] 检查登录状态...'); + const isLoggedIn = this.userManager.checkLogin(); + console.log('[Main] 登录状态:', isLoggedIn ? '已登录' : '未登录'); + // 隐藏加载界面 + this.hideLoading(); + + if (!isLoggedIn) { + // 未登录,显示登录场景 + console.log('[Main] 未登录,显示登录页面'); + this.sceneManager.switchScene('login'); + } else { + // 已登录,加载数据并进入首页 + await this.loadAndEnterHome(); + } + + // 设置分享 + this.setupShare(); + } catch (error) { + console.error('[Main] 初始化失败:', error); + this.hideLoading(); + this.showError('初始化失败,请重试'); + } + } + + // 加载数据并进入首页 + async loadAndEnterHome() { + this.showLoading('正在加载...'); + + try { // 加载故事列表 console.log('[Main] 加载故事列表...'); await this.storyManager.loadStoryList(); @@ -65,17 +89,15 @@ export default class Main { this.sceneManager.switchScene('home'); console.log('[Main] 初始化完成,进入首页'); - // 设置分享 - this.setupShare(); - // 启动草稿检查(仅登录用户) if (this.userManager.isLoggedIn) { this.startDraftChecker(); } } catch (error) { - console.error('[Main] 初始化失败:', error); this.hideLoading(); - this.showError('初始化失败,请重试'); + console.error('[Main] 加载失败:', error); + // 加载失败也进入首页,让用户可以重试 + this.sceneManager.switchScene('home'); } } diff --git a/client/js/scenes/LoginScene.js b/client/js/scenes/LoginScene.js new file mode 100644 index 0000000..4828165 --- /dev/null +++ b/client/js/scenes/LoginScene.js @@ -0,0 +1,257 @@ +/** + * 登录场景 - 微信授权登录 + */ +import BaseScene from './BaseScene'; + +export default class LoginScene extends BaseScene { + constructor(main, params = {}) { + super(main, params); + this.isLoading = false; + this.userInfoButton = null; + } + + loadAssets() { + // 不加载外部图片,使用 Canvas 绘制 + } + + init() { + console.log('[LoginScene] 初始化登录场景'); + // 创建微信授权按钮 + this.createUserInfoButton(); + } + + // 创建微信用户信息授权按钮 + createUserInfoButton() { + const { screenWidth, screenHeight } = this; + const btnWidth = 280; + const btnHeight = 50; + const btnX = (screenWidth - btnWidth) / 2; + const btnY = screenHeight * 0.55; + + // 创建透明的用户信息按钮,覆盖在登录按钮上 + this.userInfoButton = wx.createUserInfoButton({ + type: 'text', + text: '', + style: { + left: btnX, + top: btnY, + width: btnWidth, + height: btnHeight, + backgroundColor: 'transparent', + borderColor: 'transparent', + borderWidth: 0, + borderRadius: 25, + color: 'transparent', + textAlign: 'center', + fontSize: 18, + lineHeight: 50 + } + }); + + // 监听点击事件 + this.userInfoButton.onTap(async (res) => { + console.log('[LoginScene] 用户信息授权回调:', res); + + if (res.userInfo) { + // 用户同意授权,获取到了头像昵称 + await this.doLoginWithUserInfo(res.userInfo); + } else { + // 未获取到用户信息,静默登录 + console.log('[LoginScene] 未获取到用户信息,使用静默登录'); + await this.doLoginWithUserInfo(null); + } + }); + } + + render(ctx) { + const { screenWidth, screenHeight } = this; + + // 绘制背景渐变 + const gradient = ctx.createLinearGradient(0, 0, 0, screenHeight); + gradient.addColorStop(0, '#1a1a2e'); + gradient.addColorStop(0.5, '#16213e'); + gradient.addColorStop(1, '#0f0f23'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, screenWidth, screenHeight); + + // 绘制装饰性光效 + this.renderGlow(ctx); + + // 绘制Logo + this.renderLogo(ctx); + + // 绘制应用名称 + this.renderTitle(ctx); + + // 绘制登录按钮 + this.renderLoginButton(ctx); + + // 绘制底部提示 + this.renderFooter(ctx); + } + + renderGlow(ctx) { + const { screenWidth, screenHeight } = this; + + // 顶部光晕 + const topGlow = ctx.createRadialGradient( + screenWidth / 2, -100, 0, + screenWidth / 2, -100, 400 + ); + topGlow.addColorStop(0, 'rgba(106, 90, 205, 0.3)'); + topGlow.addColorStop(1, 'rgba(106, 90, 205, 0)'); + ctx.fillStyle = topGlow; + ctx.fillRect(0, 0, screenWidth, 400); + } + + renderLogo(ctx) { + const { screenWidth, screenHeight } = this; + const logoSize = 120; + const logoX = (screenWidth - logoSize) / 2; + const logoY = screenHeight * 0.2; + + // 绘制Logo背景圆 + ctx.beginPath(); + ctx.arc(screenWidth / 2, logoY + logoSize / 2, logoSize / 2 + 10, 0, Math.PI * 2); + const logoGradient = ctx.createRadialGradient( + screenWidth / 2, logoY + logoSize / 2, 0, + screenWidth / 2, logoY + logoSize / 2, logoSize / 2 + 10 + ); + logoGradient.addColorStop(0, 'rgba(106, 90, 205, 0.4)'); + logoGradient.addColorStop(1, 'rgba(106, 90, 205, 0.1)'); + ctx.fillStyle = logoGradient; + ctx.fill(); + + // 绘制内圆 + ctx.fillStyle = '#6a5acd'; + ctx.beginPath(); + ctx.arc(screenWidth / 2, logoY + logoSize / 2, logoSize / 2 - 10, 0, Math.PI * 2); + ctx.fill(); + + // 绘制书本图标 + ctx.fillStyle = '#fff'; + ctx.font = `${logoSize * 0.5}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('📖', screenWidth / 2, logoY + logoSize / 2); + } + + renderTitle(ctx) { + const { screenWidth, screenHeight } = this; + const titleY = screenHeight * 0.2 + 160; + + // 应用名称 + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 32px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('AI互动故事', screenWidth / 2, titleY); + + // 副标题 + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.font = '16px sans-serif'; + ctx.fillText('探索无限可能的剧情世界', screenWidth / 2, titleY + 40); + } + + renderLoginButton(ctx) { + const { screenWidth, screenHeight } = this; + + // 按钮位置和尺寸 + const btnWidth = 280; + const btnHeight = 50; + const btnX = (screenWidth - btnWidth) / 2; + const btnY = screenHeight * 0.55; + + // 绘制按钮背景 + const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY); + if (this.isLoading) { + btnGradient.addColorStop(0, '#666666'); + btnGradient.addColorStop(1, '#888888'); + } else { + btnGradient.addColorStop(0, '#07c160'); + btnGradient.addColorStop(1, '#06ae56'); + } + + this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 25); + ctx.fillStyle = btnGradient; + ctx.fill(); + + // 绘制按钮阴影效果 + if (!this.isLoading) { + ctx.shadowColor = 'rgba(7, 193, 96, 0.4)'; + ctx.shadowBlur = 20; + ctx.shadowOffsetY = 5; + this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 25); + ctx.fill(); + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetY = 0; + } + + // 绘制按钮文字 + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 18px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + if (this.isLoading) { + ctx.fillText('登录中...', screenWidth / 2, btnY + btnHeight / 2); + } else { + ctx.fillText('微信一键登录', screenWidth / 2, btnY + btnHeight / 2); + } + } + + renderFooter(ctx) { + const { screenWidth, screenHeight } = this; + const footerY = screenHeight - 60; + + // 用户协议提示 + ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('登录即表示同意《用户协议》和《隐私政策》', screenWidth / 2, footerY); + } + + // 不再需要手动处理点击事件,由 userInfoButton 处理 + onTouchEnd(e) {} + + async doLoginWithUserInfo(userInfo) { + if (this.isLoading) return; + + this.isLoading = true; + console.log('[LoginScene] 开始登录,用户信息:', userInfo); + + try { + // 调用 UserManager 的登录方法,传入用户信息 + await this.main.userManager.doLogin(userInfo); + + console.log('[LoginScene] 登录成功,加载数据并进入首页'); + + // 隐藏授权按钮 + if (this.userInfoButton) { + this.userInfoButton.hide(); + } + + // 登录成功,加载数据并进入首页 + await this.main.loadAndEnterHome(); + } catch (error) { + console.error('[LoginScene] 登录失败:', error); + this.isLoading = false; + + wx.showToast({ + title: error.message || '登录失败', + icon: 'none' + }); + } + } + + destroy() { + console.log('[LoginScene] 销毁登录场景'); + // 销毁授权按钮 + if (this.userInfoButton) { + this.userInfoButton.destroy(); + this.userInfoButton = null; + } + } +} diff --git a/client/js/scenes/ProfileScene.js b/client/js/scenes/ProfileScene.js index 926f105..1b5ef3b 100644 --- a/client/js/scenes/ProfileScene.js +++ b/client/js/scenes/ProfileScene.js @@ -21,6 +21,10 @@ export default class ProfileScene extends BaseScene { this.selectedStoryRecords = []; // 选中故事的记录列表 this.selectedStoryInfo = {}; // 选中故事的信息 + // 头像相关 + this.avatarImage = null; + this.avatarImageLoaded = false; + // 统计 this.stats = { works: 0, @@ -40,6 +44,29 @@ export default class ProfileScene extends BaseScene { async init() { await this.loadData(); + this.loadAvatarImage(); + } + + // 加载头像图片 + loadAvatarImage() { + let avatarUrl = this.main.userManager.avatarUrl; + if (!avatarUrl) return; + + // 如果是相对路径,拼接完整 URL + if (avatarUrl.startsWith('/uploads')) { + avatarUrl = 'http://172.20.10.8:8000' + avatarUrl; + } + + if (avatarUrl.startsWith('http')) { + this.avatarImage = wx.createImage(); + this.avatarImage.onload = () => { + this.avatarImageLoaded = true; + }; + this.avatarImage.onerror = () => { + this.avatarImageLoaded = false; + }; + this.avatarImage.src = avatarUrl; + } } async loadData() { @@ -155,18 +182,42 @@ export default class ProfileScene extends BaseScene { const avatarSize = 50; const avatarX = 30; const avatarY = cardY + 18; - const avatarGradient = ctx.createLinearGradient(avatarX, avatarY, avatarX + avatarSize, avatarY + avatarSize); - avatarGradient.addColorStop(0, '#ff6b6b'); - avatarGradient.addColorStop(1, '#ffd700'); - ctx.fillStyle = avatarGradient; - ctx.beginPath(); - ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); - ctx.fill(); + + // 保存头像区域用于点击检测 + this.avatarRect = { x: avatarX, y: avatarY, width: avatarSize, height: avatarSize }; + + // 如果有头像图片则绘制图片,否则绘制默认渐变头像 + if (this.avatarImage && this.avatarImageLoaded) { + ctx.save(); + ctx.beginPath(); + ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(this.avatarImage, avatarX, avatarY, avatarSize, avatarSize); + ctx.restore(); + } else { + const avatarGradient = ctx.createLinearGradient(avatarX, avatarY, avatarX + avatarSize, avatarY + avatarSize); + avatarGradient.addColorStop(0, '#ff6b6b'); + avatarGradient.addColorStop(1, '#ffd700'); + ctx.fillStyle = avatarGradient; + ctx.beginPath(); + ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 20px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(user.nickname ? user.nickname[0] : '游', avatarX + avatarSize / 2, avatarY + avatarSize / 2 + 7); + } + + // 编辑图标(头像右下角) + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.beginPath(); + ctx.arc(avatarX + avatarSize - 8, avatarY + avatarSize - 8, 10, 0, Math.PI * 2); + ctx.fill(); ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 20px sans-serif'; + ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(user.nickname ? user.nickname[0] : '游', avatarX + avatarSize / 2, avatarY + avatarSize / 2 + 7); + ctx.fillText('✎', avatarX + avatarSize - 8, avatarY + avatarSize - 5); // 昵称和ID ctx.textAlign = 'left'; @@ -713,6 +764,21 @@ export default class ProfileScene extends BaseScene { return; } + // 设置按钮(右上角) + if (y < 50 && x > this.screenWidth - 50) { + this.showSettingsMenu(); + return; + } + + // 头像点击 + if (this.avatarRect) { + const rect = this.avatarRect; + if (x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height) { + this.showAvatarOptions(); + return; + } + } + // Tab切换 if (this.tabRects) { for (const rect of this.tabRects) { @@ -946,4 +1012,196 @@ export default class ProfileScene extends BaseScene { } }); } + + // 显示设置菜单 + showSettingsMenu() { + wx.showActionSheet({ + itemList: ['修改头像', '修改昵称', '退出登录'], + success: (res) => { + switch (res.tapIndex) { + case 0: + this.chooseAndUploadAvatar(); + break; + case 1: + this.showEditNicknameDialog(); + break; + case 2: + this.confirmLogout(); + break; + } + } + }); + } + + // 显示头像选项 + showAvatarOptions() { + wx.showActionSheet({ + itemList: ['从相册选择', '取消'], + success: (res) => { + if (res.tapIndex === 0) { + this.chooseAndUploadAvatar(); + } + } + }); + } + + // 选择并上传头像 + chooseAndUploadAvatar() { + console.log('[ProfileScene] 开始选择头像'); + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + sizeType: ['compressed'], + success: async (res) => { + console.log('[ProfileScene] 选择图片成功:', res); + const tempFilePath = res.tempFiles[0].tempFilePath; + + wx.showLoading({ title: '上传中...' }); + + try { + // 上传图片到服务器 + const uploadRes = await this.uploadAvatar(tempFilePath); + console.log('[ProfileScene] 上传结果:', uploadRes); + + if (uploadRes && uploadRes.url) { + // 更新用户头像 + const success = await this.main.userManager.updateProfile( + this.main.userManager.nickname, + uploadRes.url + ); + + wx.hideLoading(); + + if (success) { + // 重新加载头像 + this.loadAvatarImage(); + wx.showToast({ title: '头像更新成功', icon: 'success' }); + } else { + wx.showToast({ title: '更新失败', icon: 'none' }); + } + } else { + wx.hideLoading(); + wx.showToast({ title: '上传失败', icon: 'none' }); + } + } catch (error) { + wx.hideLoading(); + console.error('[ProfileScene] 上传头像失败:', error); + wx.showToast({ title: '上传失败', icon: 'none' }); + } + }, + fail: (err) => { + console.error('[ProfileScene] 选择图片失败:', err); + if (err.errMsg && err.errMsg.indexOf('cancel') === -1) { + wx.showToast({ title: '选择图片失败', icon: 'none' }); + } + } + }); + } + + // 上传头像到服务器 + uploadAvatar(filePath) { + return new Promise((resolve, reject) => { + const token = this.main.userManager.token || ''; + const baseUrl = 'http://172.20.10.8:8000'; // 与 http.js 保持一致 + + wx.uploadFile({ + url: `${baseUrl}/api/upload/avatar`, + filePath: filePath, + name: 'file', + header: { + 'Authorization': token ? `Bearer ${token}` : '' + }, + success: (res) => { + try { + const data = JSON.parse(res.data); + if (data.code === 0) { + resolve(data.data); + } else { + reject(new Error(data.message || '上传失败')); + } + } catch (e) { + reject(e); + } + }, + fail: reject + }); + }); + } + + // 修改昵称弹窗 + showEditNicknameDialog() { + console.log('[ProfileScene] 显示修改昵称弹窗'); + const currentNickname = this.main.userManager.nickname || ''; + + // 微信小游戏使用 wx.showModal 的 editable 参数 + wx.showModal({ + title: '修改昵称', + editable: true, + placeholderText: '请输入新昵称', + success: async (res) => { + console.log('[ProfileScene] showModal 回调:', res); + if (res.confirm) { + const newNickname = (res.content || '').trim(); + console.log('[ProfileScene] 新昵称:', newNickname); + + if (!newNickname) { + wx.showToast({ title: '昵称不能为空', icon: 'none' }); + return; + } + + if (newNickname === currentNickname) { + wx.showToast({ title: '昵称未变更', icon: 'none' }); + return; + } + + wx.showLoading({ title: '保存中...' }); + try { + const success = await this.main.userManager.updateProfile( + newNickname, + this.main.userManager.avatarUrl || '' + ); + wx.hideLoading(); + + if (success) { + wx.showToast({ title: '修改成功', icon: 'success' }); + } else { + wx.showToast({ title: '修改失败', icon: 'none' }); + } + } catch (e) { + wx.hideLoading(); + console.error('[ProfileScene] 修改昵称失败:', e); + wx.showToast({ title: '修改失败', icon: 'none' }); + } + } + }, + fail: (err) => { + console.error('[ProfileScene] showModal 失败:', err); + } + }); + } + + // 确认退出登录 + confirmLogout() { + wx.showModal({ + title: '确认退出', + content: '退出登录后需要重新授权登录,确定退出吗?', + confirmText: '退出', + confirmColor: '#e74c3c', + success: (res) => { + if (res.confirm) { + // 执行退出登录 + this.main.userManager.logout(); + + // 停止草稿检查 + this.main.stopDraftChecker(); + + // 跳转到登录页 + this.main.sceneManager.switchScene('login'); + + wx.showToast({ title: '已退出登录', icon: 'success' }); + } + } + }); + } } diff --git a/client/js/scenes/SceneManager.js b/client/js/scenes/SceneManager.js index 9edb636..e16cda1 100644 --- a/client/js/scenes/SceneManager.js +++ b/client/js/scenes/SceneManager.js @@ -7,6 +7,7 @@ import EndingScene from './EndingScene'; import ProfileScene from './ProfileScene'; import ChapterScene from './ChapterScene'; import AICreateScene from './AICreateScene'; +import LoginScene from './LoginScene'; export default class SceneManager { constructor(main) { @@ -18,7 +19,8 @@ export default class SceneManager { ending: EndingScene, profile: ProfileScene, chapter: ChapterScene, - aiCreate: AICreateScene + aiCreate: AICreateScene, + login: LoginScene }; } diff --git a/client/js/utils/http.js b/client/js/utils/http.js index 1bce1d3..27c4cd1 100644 --- a/client/js/utils/http.js +++ b/client/js/utils/http.js @@ -9,7 +9,7 @@ const ENV = 'cloud'; // 'local' = 本地后端, 'cloud' = 微信云托管 const CONFIG = { local: { - baseUrl: 'http://localhost:8000/api' + baseUrl: 'http://172.20.10.8:8000/api' // 局域网IP,真机测试用 }, cloud: { env: 'prod-6gjx1rd4c40f5884', @@ -17,6 +17,18 @@ const CONFIG = { } }; +/** + * 获取存储的 Token + */ +function getToken() { + try { + const userInfo = wx.getStorageSync('userInfo'); + return userInfo?.token || ''; + } catch (e) { + return ''; + } +} + /** * 发送HTTP请求 */ @@ -33,16 +45,43 @@ export function request(options) { */ function requestLocal(options) { return new Promise((resolve, reject) => { + const timeoutMs = options.timeout || 30000; + + // 自动添加 Token 到请求头 + const token = getToken(); + const header = { + 'Content-Type': 'application/json', + ...options.header + }; + + if (token) { + header['Authorization'] = `Bearer ${token}`; + } + + // 处理 URL 查询参数 + let url = CONFIG.local.baseUrl + options.url; + if (options.params) { + const queryString = Object.entries(options.params) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + url += (url.includes('?') ? '&' : '?') + queryString; + } + wx.request({ - url: CONFIG.local.baseUrl + options.url, + url, method: options.method || 'GET', data: options.data || {}, - timeout: options.timeout || 30000, - header: { - 'Content-Type': 'application/json', - ...options.header - }, + timeout: timeoutMs, + header, success(res) { + // 处理 401 未授权错误 + if (res.statusCode === 401) { + // Token 过期或无效,清除本地存储 + wx.removeStorageSync('userInfo'); + reject(new Error('登录已过期,请重新登录')); + return; + } + if (res.data && res.data.code === 0) { resolve(res.data.data); } else { @@ -62,19 +101,43 @@ function requestLocal(options) { */ function requestCloud(options) { return new Promise((resolve, reject) => { + // 自动添加 Token 到请求头 + const token = getToken(); + const header = { + 'X-WX-SERVICE': CONFIG.cloud.serviceName, + 'Content-Type': 'application/json', + ...options.header + }; + + if (token) { + header['Authorization'] = `Bearer ${token}`; + } + + // 处理 URL 查询参数 + let path = '/api' + options.url; + if (options.params) { + const queryString = Object.entries(options.params) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + path += (path.includes('?') ? '&' : '?') + queryString; + } + wx.cloud.callContainer({ config: { env: CONFIG.cloud.env }, - path: '/api' + options.url, + path, method: options.method || 'GET', data: options.data || {}, - header: { - 'X-WX-SERVICE': CONFIG.cloud.serviceName, - 'Content-Type': 'application/json', - ...options.header - }, + header, success(res) { + // 处理 401 未授权错误 + if (res.statusCode === 401) { + wx.removeStorageSync('userInfo'); + reject(new Error('登录已过期,请重新登录')); + return; + } + if (res.data && res.data.code === 0) { resolve(res.data.data); } else if (res.data) { diff --git a/server/app/config.py b/server/app/config.py index 752c75f..66184a3 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -36,6 +36,13 @@ class Settings(BaseSettings): wx_appid: str = "" wx_secret: str = "" + # JWT 配置 + jwt_secret_key: str = "your-super-secret-key-change-in-production" + jwt_expire_hours: int = 168 # 7天 + + # 文件上传配置 + upload_path: str = "./uploads" + @property def database_url(self) -> str: return f"mysql+aiomysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" diff --git a/server/app/main.py b/server/app/main.py index f3c95ea..c237ab6 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -1,12 +1,14 @@ """ 星域故事汇 - Python后端服务 """ +import os import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from app.config import get_settings -from app.routers import story, user, drafts +from app.routers import story, user, drafts, upload settings = get_settings() @@ -30,6 +32,11 @@ app.add_middleware( app.include_router(story.router, prefix="/api/stories", tags=["故事"]) app.include_router(user.router, prefix="/api/user", tags=["用户"]) app.include_router(drafts.router, prefix="/api", tags=["草稿箱"]) +app.include_router(upload.router, prefix="/api", tags=["上传"]) + +# 静态文件服务(用于访问上传的图片) +os.makedirs(settings.upload_path, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=settings.upload_path), name="uploads") @app.get("/") diff --git a/server/app/models/__pycache__/story.cpython-310.pyc b/server/app/models/__pycache__/story.cpython-310.pyc index ef618f1..b1f2f37 100644 Binary files a/server/app/models/__pycache__/story.cpython-310.pyc and b/server/app/models/__pycache__/story.cpython-310.pyc differ diff --git a/server/app/models/__pycache__/user.cpython-310.pyc b/server/app/models/__pycache__/user.cpython-310.pyc index 3e9a231..6eef585 100644 Binary files a/server/app/models/__pycache__/user.cpython-310.pyc and b/server/app/models/__pycache__/user.cpython-310.pyc differ diff --git a/server/app/routers/__pycache__/drafts.cpython-310.pyc b/server/app/routers/__pycache__/drafts.cpython-310.pyc index ce96bfa..013d0ff 100644 Binary files a/server/app/routers/__pycache__/drafts.cpython-310.pyc and b/server/app/routers/__pycache__/drafts.cpython-310.pyc differ diff --git a/server/app/routers/__pycache__/user.cpython-310.pyc b/server/app/routers/__pycache__/user.cpython-310.pyc index 695bb8f..270b6d6 100644 Binary files a/server/app/routers/__pycache__/user.cpython-310.pyc and b/server/app/routers/__pycache__/user.cpython-310.pyc differ diff --git a/server/app/routers/upload.py b/server/app/routers/upload.py new file mode 100644 index 0000000..25ca7ca --- /dev/null +++ b/server/app/routers/upload.py @@ -0,0 +1,108 @@ +""" +文件上传路由 +""" +import os +import uuid +from datetime import datetime +from fastapi import APIRouter, UploadFile, File, Depends, HTTPException +from ..config import get_settings +from ..utils.jwt_utils import get_current_user_id + +router = APIRouter(prefix="/upload", tags=["上传"]) + +# 允许的图片格式 +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} +# 最大文件大小 (5MB) +MAX_FILE_SIZE = 5 * 1024 * 1024 + +def allowed_file(filename: str) -> bool: + """检查文件扩展名是否允许""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +@router.post("/avatar") +async def upload_avatar( + file: UploadFile = File(...), + user_id: int = Depends(get_current_user_id) +): + """上传用户头像""" + # 检查文件类型 + if not file.filename or not allowed_file(file.filename): + raise HTTPException(status_code=400, detail="不支持的文件格式") + + # 读取文件内容 + content = await file.read() + + # 检查文件大小 + if len(content) > MAX_FILE_SIZE: + raise HTTPException(status_code=400, detail="文件大小超过限制(5MB)") + + # 生成唯一文件名 + ext = file.filename.rsplit('.', 1)[1].lower() + filename = f"{user_id}_{uuid.uuid4().hex[:8]}.{ext}" + + # 创建上传目录 + settings = get_settings() + upload_dir = os.path.join(settings.upload_path, "avatars") + os.makedirs(upload_dir, exist_ok=True) + + # 保存文件 + file_path = os.path.join(upload_dir, filename) + with open(file_path, "wb") as f: + f.write(content) + + # 返回访问URL + # 使用相对路径,前端拼接 baseUrl + avatar_url = f"/uploads/avatars/{filename}" + + return { + "code": 0, + "data": { + "url": avatar_url, + "filename": filename + }, + "message": "上传成功" + } + +@router.post("/image") +async def upload_image( + file: UploadFile = File(...), + user_id: int = Depends(get_current_user_id) +): + """上传通用图片""" + # 检查文件类型 + if not file.filename or not allowed_file(file.filename): + raise HTTPException(status_code=400, detail="不支持的文件格式") + + # 读取文件内容 + content = await file.read() + + # 检查文件大小 + if len(content) > MAX_FILE_SIZE: + raise HTTPException(status_code=400, detail="文件大小超过限制(5MB)") + + # 生成唯一文件名 + ext = file.filename.rsplit('.', 1)[1].lower() + date_str = datetime.now().strftime("%Y%m%d") + filename = f"{date_str}_{uuid.uuid4().hex[:8]}.{ext}" + + # 创建上传目录 + settings = get_settings() + upload_dir = os.path.join(settings.upload_path, "images") + os.makedirs(upload_dir, exist_ok=True) + + # 保存文件 + file_path = os.path.join(upload_dir, filename) + with open(file_path, "wb") as f: + f.write(content) + + # 返回访问URL + image_url = f"/uploads/images/{filename}" + + return { + "code": 0, + "data": { + "url": image_url, + "filename": filename + }, + "message": "上传成功" + } diff --git a/server/app/routers/user.py b/server/app/routers/user.py index da8ce0c..d1b206f 100644 --- a/server/app/routers/user.py +++ b/server/app/routers/user.py @@ -6,10 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update, func, text, delete from typing import Optional from pydantic import BaseModel +import httpx from app.database import get_db from app.models.user import User, UserProgress, UserEnding, PlayRecord from app.models.story import Story +from app.config import get_settings +from app.utils.jwt_utils import create_token, get_current_user_id, get_optional_user_id router = APIRouter() @@ -59,9 +62,28 @@ class PlayRecordRequest(BaseModel): @router.post("/login") async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): """微信登录""" - # 实际部署时需要调用微信API获取openid - # 这里简化处理:用code作为openid - openid = request.code + settings = get_settings() + + # 调用微信API获取openid + if settings.wx_appid and settings.wx_secret: + try: + url = f"https://api.weixin.qq.com/sns/jscode2session?appid={settings.wx_appid}&secret={settings.wx_secret}&js_code={request.code}&grant_type=authorization_code" + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=10.0) + data = resp.json() + + if "errcode" in data and data["errcode"] != 0: + # 微信API返回错误,使用code作为openid(开发模式) + print(f"[Login] 微信API错误: {data}") + openid = request.code + else: + openid = data.get("openid", request.code) + except Exception as e: + print(f"[Login] 调用微信API失败: {e}") + openid = request.code + else: + # 未配置微信密钥,开发模式:用code作为openid + openid = request.code # 查找或创建用户 result = await db.execute(select(User).where(User.openid == openid)) @@ -79,6 +101,55 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): await db.commit() await db.refresh(user) + # 生成 JWT Token + token = create_token(user.id, user.openid) + + return { + "code": 0, + "data": { + "userId": user.id, + "openid": user.openid, + "nickname": user.nickname, + "avatarUrl": user.avatar_url, + "gender": user.gender, + "total_play_count": user.total_play_count, + "total_endings": user.total_endings, + "token": token + } + } + + +@router.post("/refresh-token") +async def refresh_token(user_id: int = Depends(get_current_user_id), db: AsyncSession = Depends(get_db)): + """刷新 Token""" + # 查找用户 + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # 生成新 Token + new_token = create_token(user.id, user.openid) + + return { + "code": 0, + "data": { + "token": new_token, + "userId": user.id + } + } + + +@router.get("/me") +async def get_current_user(user_id: int = Depends(get_current_user_id), db: AsyncSession = Depends(get_db)): + """获取当前用户信息(通过 Token 验证)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + return { "code": 0, "data": { diff --git a/server/app/services/__pycache__/ai.cpython-310.pyc b/server/app/services/__pycache__/ai.cpython-310.pyc index 800729b..b4ea878 100644 Binary files a/server/app/services/__pycache__/ai.cpython-310.pyc and b/server/app/services/__pycache__/ai.cpython-310.pyc differ diff --git a/server/app/utils/__init__.py b/server/app/utils/__init__.py new file mode 100644 index 0000000..732203f --- /dev/null +++ b/server/app/utils/__init__.py @@ -0,0 +1,6 @@ +""" +工具模块 +""" +from .jwt_utils import create_token, verify_token, get_current_user_id, get_optional_user_id + +__all__ = ["create_token", "verify_token", "get_current_user_id", "get_optional_user_id"] diff --git a/server/app/utils/__pycache__/__init__.cpython-312.pyc b/server/app/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..5ea2ad0 Binary files /dev/null and b/server/app/utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/server/app/utils/__pycache__/jwt_utils.cpython-312.pyc b/server/app/utils/__pycache__/jwt_utils.cpython-312.pyc new file mode 100644 index 0000000..7f501a0 Binary files /dev/null and b/server/app/utils/__pycache__/jwt_utils.cpython-312.pyc differ diff --git a/server/app/utils/jwt_utils.py b/server/app/utils/jwt_utils.py new file mode 100644 index 0000000..2bff5ce --- /dev/null +++ b/server/app/utils/jwt_utils.py @@ -0,0 +1,78 @@ +""" +JWT 工具函数 +""" +import jwt +from datetime import datetime, timedelta +from typing import Optional +from fastapi import HTTPException, Depends, Header +from app.config import get_settings + + +def create_token(user_id: int, openid: str) -> str: + """ + 创建 JWT Token + """ + settings = get_settings() + expire = datetime.utcnow() + timedelta(hours=settings.jwt_expire_hours) + + payload = { + "user_id": user_id, + "openid": openid, + "exp": expire, + "iat": datetime.utcnow() + } + + token = jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256") + return token + + +def verify_token(token: str) -> dict: + """ + 验证 JWT Token + 返回 payload 或抛出异常 + """ + settings = get_settings() + + try: + payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token已过期,请重新登录") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="无效的Token") + + +def get_current_user_id(authorization: Optional[str] = Header(None, alias="Authorization")) -> int: + """ + 从 Header 中获取并验证 Token,返回 user_id + 用作 FastAPI 依赖注入 + """ + if not authorization: + raise HTTPException(status_code=401, detail="未提供身份令牌") + + # 支持 "Bearer xxx" 格式 + token = authorization + if authorization.startswith("Bearer "): + token = authorization[7:] + + payload = verify_token(token) + return payload.get("user_id") + + +def get_optional_user_id(authorization: Optional[str] = Header(None, alias="Authorization")) -> Optional[int]: + """ + 可选的用户验证,未提供 Token 时返回 None + 用于不强制要求登录的接口 + """ + if not authorization: + return None + + try: + token = authorization + if authorization.startswith("Bearer "): + token = authorization[7:] + + payload = verify_token(token) + return payload.get("user_id") + except HTTPException: + return None