feat: 添加微信授权登录和修改昵称功能
This commit is contained in:
4
.idea/misc.xml
generated
4
.idea/misc.xml
generated
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
257
client/js/scenes/LoginScene.js
Normal file
257
client/js/scenes/LoginScene.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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("/")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
108
server/app/routers/upload.py
Normal file
108
server/app/routers/upload.py
Normal 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": "上传成功"
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
Binary file not shown.
6
server/app/utils/__init__.py
Normal file
6
server/app/utils/__init__.py
Normal 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"]
|
||||||
BIN
server/app/utils/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
server/app/utils/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
server/app/utils/__pycache__/jwt_utils.cpython-312.pyc
Normal file
BIN
server/app/utils/__pycache__/jwt_utils.cpython-312.pyc
Normal file
Binary file not shown.
78
server/app/utils/jwt_utils.py
Normal file
78
server/app/utils/jwt_utils.py
Normal 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
|
||||||
Reference in New Issue
Block a user