feat: 添加微信授权登录和修改昵称功能

This commit is contained in:
wangwuww111
2026-03-11 12:10:19 +08:00
parent 906b5649f7
commit eac6b2fd1f
20 changed files with 1021 additions and 67 deletions

4
.idea/misc.xml generated
View File

@@ -1,9 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="MavenRunner">
<option name="jreName" value="1.8" />
<option name="vmOptions" value="-DarchetypeCatalog=internal" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="corretto-17" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />
</component> </component>

View File

@@ -9,50 +9,125 @@ export default class UserManager {
this.openid = null; this.openid = null;
this.nickname = ''; this.nickname = '';
this.avatarUrl = ''; this.avatarUrl = '';
this.token = '';
this.isLoggedIn = false; 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() { async init() {
try { // 只检查本地缓存,不自动登录
// 尝试从本地存储恢复用户信息 this.checkLogin();
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;
}
/**
* 执行登录(供登录按钮调用)
* @param {Object} userInfo - 微信用户信息(头像、昵称等),可选
*/
async doLogin(userInfo = null) {
try {
// 获取登录code // 获取登录code
const { code } = await this.wxLogin(); 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.userId = result.userId;
this.openid = result.openid; 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; this.isLoggedIn = true;
// 缓存用户信息 // 缓存用户信息(包含 token
wx.setStorageSync('userInfo', { wx.setStorageSync('userInfo', {
userId: this.userId, userId: this.userId,
openid: this.openid, openid: this.openid,
nickname: this.nickname, nickname: this.nickname,
avatarUrl: this.avatarUrl avatarUrl: this.avatarUrl,
token: this.token
}); });
return {
userId: this.userId,
nickname: this.nickname,
avatarUrl: this.avatarUrl
};
} catch (error) { } catch (error) {
console.error('用户初始化失败:', error); console.error('登录失败:', error);
// 使用临时身份 throw error;
this.userId = 0; }
this.nickname = '游客'; }
this.isLoggedIn = false;
/**
* 退出登录
*/
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) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
reject(new Error('登录超时')); reject(new Error('登录超时'));
}, 3000); }, 5000);
wx.login({ wx.login({
success: (res) => { success: (res) => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(res); if (res.code) {
resolve(res);
} else {
reject(new Error('获取code失败'));
}
}, },
fail: (err) => { fail: (err) => {
clearTimeout(timeout); clearTimeout(timeout);

View File

@@ -46,13 +46,37 @@ export default class Main {
}); });
console.log('[Main] 云环境初始化完成'); console.log('[Main] 云环境初始化完成');
// 用户初始化(失败不阻塞 // 检查用户是否已登录(只检查缓存,不自动登录
console.log('[Main] 初始化用户...'); console.log('[Main] 检查登录状态...');
await this.userManager.init().catch(e => { const isLoggedIn = this.userManager.checkLogin();
console.warn('[Main] 用户初始化失败,使用游客模式:', e); console.log('[Main] 登录状态:', isLoggedIn ? '已登录' : '未登录');
});
console.log('[Main] 用户初始化完成');
// 隐藏加载界面
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] 加载故事列表...'); console.log('[Main] 加载故事列表...');
await this.storyManager.loadStoryList(); await this.storyManager.loadStoryList();
@@ -65,17 +89,15 @@ export default class Main {
this.sceneManager.switchScene('home'); this.sceneManager.switchScene('home');
console.log('[Main] 初始化完成,进入首页'); console.log('[Main] 初始化完成,进入首页');
// 设置分享
this.setupShare();
// 启动草稿检查(仅登录用户) // 启动草稿检查(仅登录用户)
if (this.userManager.isLoggedIn) { if (this.userManager.isLoggedIn) {
this.startDraftChecker(); this.startDraftChecker();
} }
} catch (error) { } catch (error) {
console.error('[Main] 初始化失败:', error);
this.hideLoading(); this.hideLoading();
this.showError('初始化失败,请重试'); console.error('[Main] 加载失败:', error);
// 加载失败也进入首页,让用户可以重试
this.sceneManager.switchScene('home');
} }
} }

View File

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

View File

@@ -21,6 +21,10 @@ export default class ProfileScene extends BaseScene {
this.selectedStoryRecords = []; // 选中故事的记录列表 this.selectedStoryRecords = []; // 选中故事的记录列表
this.selectedStoryInfo = {}; // 选中故事的信息 this.selectedStoryInfo = {}; // 选中故事的信息
// 头像相关
this.avatarImage = null;
this.avatarImageLoaded = false;
// 统计 // 统计
this.stats = { this.stats = {
works: 0, works: 0,
@@ -40,6 +44,29 @@ export default class ProfileScene extends BaseScene {
async init() { async init() {
await this.loadData(); 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() { async loadData() {
@@ -155,18 +182,42 @@ export default class ProfileScene extends BaseScene {
const avatarSize = 50; const avatarSize = 50;
const avatarX = 30; const avatarX = 30;
const avatarY = cardY + 18; const avatarY = cardY + 18;
const avatarGradient = ctx.createLinearGradient(avatarX, avatarY, avatarX + avatarSize, avatarY + avatarSize);
avatarGradient.addColorStop(0, '#ff6b6b'); // 保存头像区域用于点击检测
avatarGradient.addColorStop(1, '#ffd700'); this.avatarRect = { x: avatarX, y: avatarY, width: avatarSize, height: avatarSize };
ctx.fillStyle = avatarGradient;
ctx.beginPath(); // 如果有头像图片则绘制图片,否则绘制默认渐变头像
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); if (this.avatarImage && this.avatarImageLoaded) {
ctx.fill(); 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.fillStyle = '#ffffff';
ctx.font = 'bold 20px sans-serif'; ctx.font = '10px sans-serif';
ctx.textAlign = 'center'; 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 // 昵称和ID
ctx.textAlign = 'left'; ctx.textAlign = 'left';
@@ -713,6 +764,21 @@ export default class ProfileScene extends BaseScene {
return; 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切换 // Tab切换
if (this.tabRects) { if (this.tabRects) {
for (const rect of 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' });
}
}
});
}
} }

View File

@@ -7,6 +7,7 @@ import EndingScene from './EndingScene';
import ProfileScene from './ProfileScene'; import ProfileScene from './ProfileScene';
import ChapterScene from './ChapterScene'; import ChapterScene from './ChapterScene';
import AICreateScene from './AICreateScene'; import AICreateScene from './AICreateScene';
import LoginScene from './LoginScene';
export default class SceneManager { export default class SceneManager {
constructor(main) { constructor(main) {
@@ -18,7 +19,8 @@ export default class SceneManager {
ending: EndingScene, ending: EndingScene,
profile: ProfileScene, profile: ProfileScene,
chapter: ChapterScene, chapter: ChapterScene,
aiCreate: AICreateScene aiCreate: AICreateScene,
login: LoginScene
}; };
} }

View File

@@ -9,7 +9,7 @@ const ENV = 'cloud'; // 'local' = 本地后端, 'cloud' = 微信云托管
const CONFIG = { const CONFIG = {
local: { local: {
baseUrl: 'http://localhost:8000/api' baseUrl: 'http://172.20.10.8:8000/api' // 局域网IP真机测试用
}, },
cloud: { cloud: {
env: 'prod-6gjx1rd4c40f5884', 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请求 * 发送HTTP请求
*/ */
@@ -33,16 +45,43 @@ export function request(options) {
*/ */
function requestLocal(options) { function requestLocal(options) {
return new Promise((resolve, reject) => { 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({ wx.request({
url: CONFIG.local.baseUrl + options.url, url,
method: options.method || 'GET', method: options.method || 'GET',
data: options.data || {}, data: options.data || {},
timeout: options.timeout || 30000, timeout: timeoutMs,
header: { header,
'Content-Type': 'application/json',
...options.header
},
success(res) { success(res) {
// 处理 401 未授权错误
if (res.statusCode === 401) {
// Token 过期或无效,清除本地存储
wx.removeStorageSync('userInfo');
reject(new Error('登录已过期,请重新登录'));
return;
}
if (res.data && res.data.code === 0) { if (res.data && res.data.code === 0) {
resolve(res.data.data); resolve(res.data.data);
} else { } else {
@@ -62,19 +101,43 @@ function requestLocal(options) {
*/ */
function requestCloud(options) { function requestCloud(options) {
return new Promise((resolve, reject) => { 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({ wx.cloud.callContainer({
config: { config: {
env: CONFIG.cloud.env env: CONFIG.cloud.env
}, },
path: '/api' + options.url, path,
method: options.method || 'GET', method: options.method || 'GET',
data: options.data || {}, data: options.data || {},
header: { header,
'X-WX-SERVICE': CONFIG.cloud.serviceName,
'Content-Type': 'application/json',
...options.header
},
success(res) { success(res) {
// 处理 401 未授权错误
if (res.statusCode === 401) {
wx.removeStorageSync('userInfo');
reject(new Error('登录已过期,请重新登录'));
return;
}
if (res.data && res.data.code === 0) { if (res.data && res.data.code === 0) {
resolve(res.data.data); resolve(res.data.data);
} else if (res.data) { } else if (res.data) {

View File

@@ -36,6 +36,13 @@ class Settings(BaseSettings):
wx_appid: str = "" wx_appid: str = ""
wx_secret: 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 @property
def database_url(self) -> str: def database_url(self) -> str:
return f"mysql+aiomysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" return f"mysql+aiomysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"

View File

@@ -1,12 +1,14 @@
""" """
星域故事汇 - Python后端服务 星域故事汇 - Python后端服务
""" """
import os
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import get_settings from app.config import get_settings
from app.routers import story, user, drafts from app.routers import story, user, drafts, upload
settings = get_settings() settings = get_settings()
@@ -30,6 +32,11 @@ app.add_middleware(
app.include_router(story.router, prefix="/api/stories", tags=["故事"]) app.include_router(story.router, prefix="/api/stories", tags=["故事"])
app.include_router(user.router, prefix="/api/user", tags=["用户"]) app.include_router(user.router, prefix="/api/user", tags=["用户"])
app.include_router(drafts.router, prefix="/api", 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("/") @app.get("/")

View File

@@ -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": "上传成功"
}

View File

@@ -6,10 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func, text, delete from sqlalchemy import select, update, func, text, delete
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
import httpx
from app.database import get_db from app.database import get_db
from app.models.user import User, UserProgress, UserEnding, PlayRecord from app.models.user import User, UserProgress, UserEnding, PlayRecord
from app.models.story import Story 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() router = APIRouter()
@@ -59,9 +62,28 @@ class PlayRecordRequest(BaseModel):
@router.post("/login") @router.post("/login")
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
"""微信登录""" """微信登录"""
# 实际部署时需要调用微信API获取openid settings = get_settings()
# 这里简化处理用code作为openid
openid = request.code # 调用微信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)) 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.commit()
await db.refresh(user) 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 { return {
"code": 0, "code": 0,
"data": { "data": {

View File

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

Binary file not shown.

Binary file not shown.

View File

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