From 5f941292368e931d60ed81ec9a770d032dd1b739 Mon Sep 17 00:00:00 2001 From: liangguodong Date: Fri, 13 Mar 2026 17:48:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=89=8D=E7=AB=AF=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E5=92=8CHTTP=E5=B7=A5=E5=85=B7=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/js/scenes/AICreateScene.js | 5 + client/js/scenes/HomeScene.js | 57 +++++++++-- client/js/scenes/StoryScene.js | 162 ++++++++++++++++++++++++++---- client/js/utils/http.js | 160 ++++++++++++++--------------- 4 files changed, 272 insertions(+), 112 deletions(-) diff --git a/client/js/scenes/AICreateScene.js b/client/js/scenes/AICreateScene.js index b72a2f2..19ac0e8 100644 --- a/client/js/scenes/AICreateScene.js +++ b/client/js/scenes/AICreateScene.js @@ -253,16 +253,21 @@ export default class AICreateScene extends BaseScene { // 关键词输入 let currentY = tagEndY + 25; + ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '13px sans-serif'; ctx.fillText('故事关键词:', padding, currentY); this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.keywords || '例如:霸总、契约婚姻、追妻火葬场', 'keywords'); // 主角设定 currentY += 80; + ctx.textAlign = 'left'; ctx.fillText('主角设定:', padding, currentY); this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.protagonist || '例如:独立女性设计师', 'protagonist'); // 核心冲突 currentY += 80; + ctx.textAlign = 'left'; ctx.fillText('核心冲突:', padding, currentY); this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.conflict || '例如:假结婚变真爱', 'conflict'); diff --git a/client/js/scenes/HomeScene.js b/client/js/scenes/HomeScene.js index 9536c4c..5a9a136 100644 --- a/client/js/scenes/HomeScene.js +++ b/client/js/scenes/HomeScene.js @@ -2,6 +2,7 @@ * 首页场景 - 支持UGC */ import BaseScene from './BaseScene'; +import { getStaticUrl } from '../utils/http'; export default class HomeScene extends BaseScene { constructor(main, params) { @@ -13,6 +14,9 @@ export default class HomeScene extends BaseScene { this.lastTouchY = 0; this.scrollVelocity = 0; + // 封面图片缓存 + this.coverImages = {}; + // 底部Tab: 首页/发现/创作/我的 this.bottomTab = 0; @@ -35,6 +39,23 @@ export default class HomeScene extends BaseScene { async init() { this.storyList = this.main.storyManager.storyList; this.calculateMaxScroll(); + // 预加载封面图片 + this.preloadCoverImages(); + } + + /** + * 预加载故事封面图片 + */ + preloadCoverImages() { + this.storyList.forEach(story => { + if (story.cover_url && !this.coverImages[story.id]) { + const img = wx.createImage(); + img.onload = () => { + this.coverImages[story.id] = img; + }; + img.src = getStaticUrl(story.cover_url); + } + }); } getFilteredStories() { @@ -248,18 +269,32 @@ export default class HomeScene extends BaseScene { // 封面 const coverW = 80, coverH = height - 20; - const coverGradient = ctx.createLinearGradient(x + 10, y + 10, x + 10 + coverW, y + 10 + coverH); - const colors = this.getCategoryGradient(story.category); - coverGradient.addColorStop(0, colors[0]); - coverGradient.addColorStop(1, colors[1]); - ctx.fillStyle = coverGradient; - this.roundRect(ctx, x + 10, y + 10, coverW, coverH, 10); - ctx.fill(); + const coverX = x + 10, coverY = y + 10; + + // 尝试显示封面图片 + const coverImg = this.coverImages[story.id]; + if (coverImg) { + // 有图片,绘制图片 + ctx.save(); + this.roundRect(ctx, coverX, coverY, coverW, coverH, 10); + ctx.clip(); + ctx.drawImage(coverImg, coverX, coverY, coverW, coverH); + ctx.restore(); + } else { + // 无图片,显示渐变占位 + const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH); + const colors = this.getCategoryGradient(story.category); + coverGradient.addColorStop(0, colors[0]); + coverGradient.addColorStop(1, colors[1]); + ctx.fillStyle = coverGradient; + this.roundRect(ctx, coverX, coverY, coverW, coverH, 10); + ctx.fill(); - ctx.fillStyle = 'rgba(255,255,255,0.85)'; - ctx.font = 'bold 10px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(story.category || '故事', x + 10 + coverW / 2, y + 10 + coverH / 2 + 4); + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(story.category || '故事', coverX + coverW / 2, coverY + coverH / 2 + 4); + } const textX = x + 100; const maxW = width - 115; diff --git a/client/js/scenes/StoryScene.js b/client/js/scenes/StoryScene.js index 46cc9cd..07c6236 100644 --- a/client/js/scenes/StoryScene.js +++ b/client/js/scenes/StoryScene.js @@ -2,6 +2,7 @@ * 故事播放场景 - 视觉小说风格 */ import BaseScene from './BaseScene'; +import { getNodeBackground, getNodeCharacter, getDraftNodeBackground, getStaticUrl } from '../utils/http'; export default class StoryScene extends BaseScene { constructor(main, params) { @@ -31,6 +32,11 @@ export default class StoryScene extends BaseScene { // 场景图相关 this.sceneImage = null; this.sceneColors = this.generateSceneColors(); + // 节点图片相关 + this.nodeBackgroundImages = {}; // 缓存背景图 {nodeKey: Image} + this.nodeCharacterImages = {}; // 缓存立绘 {nodeKey: Image} + this.currentBackgroundImg = null; + this.currentCharacterImg = null; // AI改写相关 this.isAIRewriting = false; // 剧情回顾模式 @@ -638,6 +644,77 @@ export default class StoryScene extends BaseScene { // 重置滚动 this.textScrollY = 0; this.maxScrollY = 0; + + // 加载当前节点的背景图和立绘 + this.loadNodeImages(); + } + + // 加载当前节点的背景图和立绘 + loadNodeImages() { + if (!this.currentNode || !this.storyId) return; + + const nodeKey = this.currentNode.nodeKey || this.main.storyManager.currentNodeKey; + if (!nodeKey) return; + + // 判断是否是草稿模式 + const isDraftMode = !!this.draftId; + + // 获取背景图 URL + let bgUrl; + if (isDraftMode) { + // 草稿模式:优先使用节点中的 background_url(需要转成完整URL),否则用草稿路径 + if (this.currentNode.background_url) { + bgUrl = getStaticUrl(this.currentNode.background_url); + } else { + bgUrl = getDraftNodeBackground(this.storyId, this.draftId, nodeKey); + } + } else { + // 普通模式:使用故事节点路径 + bgUrl = getNodeBackground(this.storyId, nodeKey); + } + + console.log('[loadNodeImages] nodeKey:', nodeKey, ', isDraftMode:', isDraftMode, ', bgUrl:', bgUrl); + + // 加载背景图 + if (!this.nodeBackgroundImages[nodeKey]) { + const bgImg = wx.createImage(); + bgImg.onload = () => { + this.nodeBackgroundImages[nodeKey] = bgImg; + if (this.main.storyManager.currentNodeKey === nodeKey || isDraftMode) { + this.currentBackgroundImg = bgImg; + } + }; + bgImg.onerror = () => { + // 图片加载失败,使用默认渐变 + this.nodeBackgroundImages[nodeKey] = null; + }; + bgImg.src = bgUrl; + } else { + this.currentBackgroundImg = this.nodeBackgroundImages[nodeKey]; + } + + // 加载角色立绘(只在后端返回了 character_image 时才加载) + if (!isDraftMode && this.currentNode.character_image) { + if (!this.nodeCharacterImages[nodeKey]) { + const charImg = wx.createImage(); + charImg.onload = () => { + this.nodeCharacterImages[nodeKey] = charImg; + if (this.main.storyManager.currentNodeKey === nodeKey) { + this.currentCharacterImg = charImg; + } + }; + charImg.onerror = () => { + // 图片加载失败,不显示立绘 + this.nodeCharacterImages[nodeKey] = null; + }; + // 优先使用后端返回的路径,否则用默认路径 + charImg.src = this.currentNode.character_image.startsWith('http') + ? this.currentNode.character_image + : getStaticUrl(this.currentNode.character_image); + } else { + this.currentCharacterImg = this.nodeCharacterImages[nodeKey]; + } + } } update() { @@ -695,15 +772,45 @@ export default class StoryScene extends BaseScene { } renderSceneBackground(ctx) { - // 场景区域(上方45%) + // 场景区域(上方42%) const sceneHeight = this.screenHeight * 0.42; - // 渐变背景 - const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight); - gradient.addColorStop(0, this.sceneColors.bg1); - gradient.addColorStop(1, this.sceneColors.bg2); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, this.screenWidth, sceneHeight); + // 优先显示背景图 + if (this.currentBackgroundImg) { + // 绘制背景图(等比例覆盖) + const img = this.currentBackgroundImg; + const imgRatio = img.width / img.height; + const areaRatio = this.screenWidth / sceneHeight; + + let drawW, drawH, drawX, drawY; + if (imgRatio > areaRatio) { + // 图片更宽,按高度适配 + drawH = sceneHeight; + drawW = drawH * imgRatio; + drawX = (this.screenWidth - drawW) / 2; + drawY = 0; + } else { + // 图片更高,按宽度适配 + drawW = this.screenWidth; + drawH = drawW / imgRatio; + drawX = 0; + drawY = (sceneHeight - drawH) / 2; + } + + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 0, this.screenWidth, sceneHeight); + ctx.clip(); + ctx.drawImage(img, drawX, drawY, drawW, drawH); + ctx.restore(); + } else { + // 无背景图,使用渐变背景 + const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight); + gradient.addColorStop(0, this.sceneColors.bg1); + gradient.addColorStop(1, this.sceneColors.bg2); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, this.screenWidth, sceneHeight); + } // 底部渐变过渡到对话框 const fadeGradient = ctx.createLinearGradient(0, sceneHeight - 60, 0, sceneHeight); @@ -721,21 +828,34 @@ export default class StoryScene extends BaseScene { const sceneHeight = this.screenHeight * 0.42; const centerX = this.screenWidth / 2; - // 场景氛围光效 - const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200); - glowGradient.addColorStop(0, this.sceneColors.accent + '30'); - glowGradient.addColorStop(1, 'transparent'); - ctx.fillStyle = glowGradient; - ctx.fillRect(0, 0, this.screenWidth, sceneHeight); + // 绘制角色立绘(如果有) + if (this.currentCharacterImg) { + const img = this.currentCharacterImg; + // 立绘高度占场景区域80%,保持比例 + const charH = sceneHeight * 0.8; + const charW = charH * (img.width / img.height); + const charX = centerX - charW / 2; + const charY = sceneHeight - charH; + + ctx.drawImage(img, charX, charY, charW, charH); + } else { + // 无立绘时显示装饰效果 + // 场景氛围光效 + const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200); + glowGradient.addColorStop(0, this.sceneColors.accent + '30'); + glowGradient.addColorStop(1, 'transparent'); + ctx.fillStyle = glowGradient; + ctx.fillRect(0, 0, this.screenWidth, sceneHeight); - // 装饰粒子 - ctx.fillStyle = this.sceneColors.accent + '40'; - const particles = [[50, 100], [120, 180], [200, 80], [280, 150], [320, 60], [80, 250], [250, 220]]; - particles.forEach(([x, y]) => { - ctx.beginPath(); - ctx.arc(x, y, 2, 0, Math.PI * 2); - ctx.fill(); - }); + // 装饰粒子 + ctx.fillStyle = this.sceneColors.accent + '40'; + const particles = [[50, 100], [120, 180], [200, 80], [280, 150], [320, 60], [80, 250], [250, 220]]; + particles.forEach(([x, y]) => { + ctx.beginPath(); + ctx.arc(x, y, 2, 0, Math.PI * 2); + ctx.fill(); + }); + } // 场景提示文字(中央) ctx.fillStyle = 'rgba(255,255,255,0.15)'; diff --git a/client/js/utils/http.js b/client/js/utils/http.js index d10d1df..da9bc6c 100644 --- a/client/js/utils/http.js +++ b/client/js/utils/http.js @@ -5,30 +5,20 @@ // ============================================ // 环境配置(切换这里即可) // ============================================ -const ENV = 'local'; // 'local' = 本地后端, 'cloud' = 微信云托管 +const ENV = 'cloud'; // 'local' = 本地后端, 'cloud' = 微信云托管 const CONFIG = { local: { - baseUrl: 'http://localhost:8001/api' + baseUrl: 'http://localhost:8000/api', + staticUrl: 'http://localhost:8000' }, cloud: { env: 'prod-6gjx1rd4c40f5884', - serviceName: 'express-fuvd' + serviceName: 'express-fuvd', + staticUrl: 'https://7072-prod-6gjx1rd4c40f5884-1409819450.tcb.qcloud.la' } }; -/** - * 获取存储的 Token - */ -function getToken() { - try { - const userInfo = wx.getStorageSync('userInfo'); - return userInfo?.token || ''; - } catch (e) { - return ''; - } -} - /** * 发送HTTP请求 */ @@ -45,43 +35,21 @@ export function request(options) { */ function requestLocal(options) { return new Promise((resolve, reject) => { - // 自动添加 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, + url: CONFIG.local.baseUrl + options.url, method: options.method || 'GET', data: options.data || {}, timeout: options.timeout || 30000, - header, + header: { + 'Content-Type': 'application/json', + ...options.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 { - reject(new Error(res.data?.message || '请求失败')); + console.error('[HTTP-Local] 响应异常:', res.statusCode, res.data); + reject(new Error(res.data?.message || res.data?.detail || `请求失败(${res.statusCode})`)); } }, fail(err) { @@ -97,47 +65,26 @@ 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, + path: '/api' + options.url, method: options.method || 'GET', data: options.data || {}, - header, + header: { + 'X-WX-SERVICE': CONFIG.cloud.serviceName, + 'Content-Type': 'application/json', + ...options.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) { - reject(new Error(res.data.message || '请求失败')); + console.error('[HTTP-Cloud] 响应异常:', res.statusCode, res.data); + reject(new Error(res.data.message || res.data.detail || `请求失败(${res.statusCode})`)); } else { + console.error('[HTTP-Cloud] 响应数据异常:', res); reject(new Error('响应数据异常')); } }, @@ -152,8 +99,8 @@ function requestCloud(options) { /** * GET请求 */ -export function get(url, params) { - return request({ url, method: 'GET', params }); +export function get(url, data) { + return request({ url, method: 'GET', data }); } /** @@ -171,10 +118,63 @@ export function del(url, data) { } /** - * PUT请求 + * 获取静态资源完整URL(图片等) + * @param {string} path - 相对路径,如 /uploads/stories/1/characters/1.jpg + * @returns {string} 完整URL */ -export function put(url, data, options = {}) { - return request({ url, method: 'PUT', data, ...options }); +export function getStaticUrl(path) { + if (!path) return ''; + // 如果已经是完整URL,直接返回 + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + const config = ENV === 'local' ? CONFIG.local : CONFIG.cloud; + return config.staticUrl + path; } -export default { request, get, post, put, del }; +/** + * 获取角色头像URL + * @param {number} storyId - 故事ID + * @param {number} characterId - 角色ID + */ +export function getCharacterAvatar(storyId, characterId) { + return getStaticUrl(`/uploads/stories/${storyId}/characters/${characterId}.jpg`); +} + +/** + * 获取故事封面URL + * @param {number} storyId - 故事ID + */ +export function getStoryCover(storyId) { + return getStaticUrl(`/uploads/stories/${storyId}/cover/cover.jpg`); +} + +/** + * 获取节点背景图URL + * @param {number} storyId - 故事ID + * @param {string} nodeKey - 节点key + */ +export function getNodeBackground(storyId, nodeKey) { + return getStaticUrl(`/uploads/stories/${storyId}/nodes/${nodeKey}/background.jpg`); +} + +/** + * 获取节点角色立绘URL + * @param {number} storyId - 故事ID + * @param {string} nodeKey - 节点key + */ +export function getNodeCharacter(storyId, nodeKey) { + return getStaticUrl(`/uploads/stories/${storyId}/nodes/${nodeKey}/character.jpg`); +} + +/** + * 获取草稿节点背景图URL + * @param {number} storyId - 故事ID + * @param {number} draftId - 草稿ID + * @param {string} nodeKey - 节点key + */ +export function getDraftNodeBackground(storyId, draftId, nodeKey) { + return getStaticUrl(`/uploads/stories/${storyId}/drafts/${draftId}/${nodeKey}/background.jpg`); +} + +export default { request, get, post, del, getStaticUrl, getCharacterAvatar, getStoryCover, getNodeBackground, getNodeCharacter, getDraftNodeBackground };