From 1757b92b5f8d5ef7d4a0ddc61cdb05e16c1be225 Mon Sep 17 00:00:00 2001 From: Eg <1711788888@qq.com> Date: Wed, 11 Feb 2026 14:34:34 +0800 Subject: [PATCH] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8D=A1=E5=AF=BC=E5=87=BA=E5=A4=B1=E8=B4=A5=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/重构进度管理.md | 32 ++++++++++- server/config.yaml | 2 +- server/service/app/character.go | 54 +++++++++++++++---- web-app-vue/src/api/character.ts | 53 +++++++++++------- web-app-vue/src/utils/request.ts | 10 ++++ web-app-vue/src/views/character/Detail.vue | 33 +++++++++++- .../src/views/character/MyCharacters.vue | 23 +++++++- web-app-vue/src/views/home/CharacterList.vue | 28 +++++++++- 8 files changed, 198 insertions(+), 37 deletions(-) diff --git a/docs/重构进度管理.md b/docs/重构进度管理.md index 2308eef..5f44baa 100644 --- a/docs/重构进度管理.md +++ b/docs/重构进度管理.md @@ -12,8 +12,8 @@ | 阶段 | 进度 | 状态 | 开始日期 | 完成日期 | |------|------|------|----------|----------| | 阶段一:数据库设计 | 100% | 🟢 已完成 | 2026-02-10 | 2026-02-10 | -| 阶段二:Go后端API开发 | 35% | 🔵 进行中 | 2026-02-10 | - | -| 阶段三:Vue3前台开发 | 40% | 🔵 进行中 | 2026-02-10 | - | +| 阶段二:Go后端API开发 | 40% | 🔵 进行中 | 2026-02-10 | - | +| 阶段三:Vue3前台开发 | 45% | 🔵 进行中 | 2026-02-10 | - | | 阶段四:前端改造(旧版) | 0% | ⚪ 暂停 | - | - | | 阶段五:数据迁移 | 0% | ⚪ 未开始 | - | - | | 阶段六:测试与优化 | 0% | ⚪ 未开始 | - | - | @@ -1008,6 +1008,34 @@ - 完成日期:2026-02-10 - 添加主导航菜单、登录/注册按钮 +- [x] **T3.3.15** 实现角色卡导入导出功能 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 功能: + - 支持 PNG 和 JSON 格式导入 + - 支持导出为 PNG(含嵌入数据)和 JSON + - 兼容 SillyTavern CharacterCard V2 规范 + - 自动从 PNG 提取 tEXt chunk + +- [x] **T3.3.16** 优化 Token 有效期 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 修改:Token 有效期从配置文件控制改为固定 7 天 + +- [x] **T3.3.17** 修复头像显示问题 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 优化:无头像时显示首字母占位符,不再请求不存在的文件 + ### 3.4 Vue 对话功能模块(待开发) - [ ] **T3.4.1** 创建对话类型定义 diff --git a/server/config.yaml b/server/config.yaml index 6eed06e..5dd81fe 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -89,7 +89,7 @@ jwt: buffer-time: 1d issuer: qmPlus local: - path: uploads/file + path: http://localhost:8888/uploads/file store-path: uploads/file mcp: name: GVA_MCP diff --git a/server/service/app/character.go b/server/service/app/character.go index f7fe941..9b1468a 100644 --- a/server/service/app/character.go +++ b/server/service/app/character.go @@ -8,6 +8,7 @@ import ( "fmt" "image" _ "image/jpeg" + _ "image/png" "os" "strings" "time" @@ -624,10 +625,17 @@ func (cs *CharacterService) ExportCharacterAsPNG(characterID uint, userID *uint) // 获取角色头像 var img image.Image + var loadErr error + if character.Avatar != "" { - // TODO: 从 URL 或文件系统加载头像 - // 这里暂时创建一个默认图片 - img = createDefaultAvatar() + // 尝试从文件系统或 URL 加载头像 + img, loadErr = loadAvatarImage(character.Avatar) + if loadErr != nil { + global.GVA_LOG.Warn("加载角色头像失败,使用默认头像", + zap.String("avatar", character.Avatar), + zap.Error(loadErr)) + img = createDefaultAvatar() + } } else { img = createDefaultAvatar() } @@ -635,9 +643,14 @@ func (cs *CharacterService) ExportCharacterAsPNG(characterID uint, userID *uint) // 将角色卡数据嵌入到 PNG pngData, err := utils.EmbedCharacterToPNG(img, card) if err != nil { + global.GVA_LOG.Error("生成 PNG 失败", zap.Error(err)) return nil, errors.New("生成 PNG 失败: " + err.Error()) } + global.GVA_LOG.Info("PNG 导出成功", + zap.Uint("characterID", characterID), + zap.Int("size", len(pngData))) + return pngData, nil } @@ -745,28 +758,49 @@ func convertCharacterToCard(character *app.AICharacter) *utils.CharacterCardV2 { } } +// loadAvatarImage 从文件系统或 URL 加载头像 +func loadAvatarImage(avatarPath string) (image.Image, error) { + // 如果是 URL,暂时不支持 + if strings.HasPrefix(avatarPath, "http://") || strings.HasPrefix(avatarPath, "https://") { + return nil, errors.New("暂不支持从 URL 加载头像") + } + + // 从文件系统加载 + file, err := os.Open(avatarPath) + if err != nil { + return nil, fmt.Errorf("打开头像文件失败: %w", err) + } + defer file.Close() + + // 解码图片(自动检测格式) + img, _, err := image.Decode(file) + if err != nil { + return nil, fmt.Errorf("解码头像图片失败: %w", err) + } + + return img, nil +} + // createDefaultAvatar 创建默认头像 func createDefaultAvatar() image.Image { // 创建一个 400x533 的默认图片(3:4 比例) width, height := 400, 533 img := image.NewRGBA(image.Rect(0, 0, width, height)) - // 填充渐变色 + // 填充渐变色(从紫色到蓝色) for y := 0; y < height; y++ { for x := 0; x < width; x++ { - // 简单的渐变效果 + // 计算渐变颜色 r := uint8(102 + y*155/height) g := uint8(126 + y*138/height) b := uint8(234 - y*72/height) - img.Set(x, y, image.NewRGBA(image.Rect(0, 0, 1, 1)).At(0, 0)) - img.SetRGBA(x, y, image.NewRGBA(image.Rect(0, 0, 1, 1)).RGBAAt(0, 0)) - // 设置颜色 - img.SetRGBA(x, y, image.NewRGBA(image.Rect(0, 0, 1, 1)).RGBAAt(0, 0)) + + // 直接设置像素颜色 pix := img.Pix[y*img.Stride+x*4:] pix[0] = r pix[1] = g pix[2] = b - pix[3] = 255 + pix[3] = 255 // 完全不透明 } } diff --git a/web-app-vue/src/api/character.ts b/web-app-vue/src/api/character.ts index 56ebc36..4f7d955 100644 --- a/web-app-vue/src/api/character.ts +++ b/web-app-vue/src/api/character.ts @@ -84,32 +84,45 @@ export function exportCharacterAsPNG(id: number) { * 下载角色卡为 JSON 文件 */ export async function downloadCharacterJSON(id: number, filename?: string) { - const data = await exportCharacter(id) - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = filename || `character_${id}.json` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) + try { + const response = await exportCharacter(id) + // 导出接口返回的是原始响应,需要访问 response.data + const cardData = response.data + const blob = new Blob([JSON.stringify(cardData, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename || `character_${id}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } catch (error) { + console.error('下载 JSON 失败:', error) + throw error + } } /** * 下载角色卡为 PNG 文件 */ export async function downloadCharacterPNG(id: number, filename?: string) { - const { data } = await exportCharacterAsPNG(id) - const blob = new Blob([data], { type: 'image/png' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = filename || `character_${id}.png` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) + try { + const response = await exportCharacterAsPNG(id) + // blob 响应直接返回 response,不需要 .data + const blob = new Blob([response.data], { type: 'image/png' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename || `character_${id}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } catch (error) { + console.error('下载 PNG 失败:', error) + throw error + } } /** diff --git a/web-app-vue/src/utils/request.ts b/web-app-vue/src/utils/request.ts index 5228605..0232b71 100644 --- a/web-app-vue/src/utils/request.ts +++ b/web-app-vue/src/utils/request.ts @@ -30,8 +30,18 @@ service.interceptors.request.use( // 响应拦截器 service.interceptors.response.use( (response: AxiosResponse) => { + // 对于 blob 类型的响应(如文件下载),直接返回 + if (response.config.responseType === 'blob') { + return response + } + const res = response.data + // 对于导出接口(非标准 ApiResponse 格式),直接返回 + if (response.config.url?.includes('/export')) { + return response + } + // code 不为 0 表示业务错误 if (res.code !== 0) { ElMessage.error(res.msg || '请求失败') diff --git a/web-app-vue/src/views/character/Detail.vue b/web-app-vue/src/views/character/Detail.vue index d777111..df85a01 100644 --- a/web-app-vue/src/views/character/Detail.vue +++ b/web-app-vue/src/views/character/Detail.vue @@ -4,7 +4,15 @@
- + +
+ {{ character.name.charAt(0) }} +
@@ -306,6 +314,16 @@ async function handleDelete() { } } +// 图片加载失败处理 +function handleImageError(e: Event) { + const target = e.target as HTMLImageElement + target.style.display = 'none' + const placeholder = target.parentElement?.querySelector('.avatar-placeholder') as HTMLElement + if (placeholder) { + placeholder.style.display = 'flex' + } +} + // 初始化 onMounted(async () => { const characterId = Number(route.params.id) @@ -349,6 +367,7 @@ onMounted(async () => { border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + position: relative; img { width: 100%; @@ -356,6 +375,18 @@ onMounted(async () => { object-fit: cover; } + .avatar-placeholder { + display: none; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + font-size: 120px; + font-weight: bold; + color: white; + text-transform: uppercase; + } + @media (max-width: 768px) { width: 100%; height: 400px; diff --git a/web-app-vue/src/views/character/MyCharacters.vue b/web-app-vue/src/views/character/MyCharacters.vue index e110634..43cb891 100644 --- a/web-app-vue/src/views/character/MyCharacters.vue +++ b/web-app-vue/src/views/character/MyCharacters.vue @@ -40,9 +40,14 @@
+
+ {{ character.name.charAt(0) }} +
{ object-fit: cover; } + .avatar-placeholder { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 80px; + font-weight: bold; + color: white; + text-transform: uppercase; + } + .public-tag { position: absolute; top: 8px; right: 8px; + z-index: 1; } } diff --git a/web-app-vue/src/views/home/CharacterList.vue b/web-app-vue/src/views/home/CharacterList.vue index 3e20993..3de574f 100644 --- a/web-app-vue/src/views/home/CharacterList.vue +++ b/web-app-vue/src/views/home/CharacterList.vue @@ -38,10 +38,14 @@
+
+ {{ character.name.charAt(0) }} +
@@ -176,7 +180,11 @@ function startChat(id: number) { // 图片加载失败处理 function handleImageError(e: Event) { const target = e.target as HTMLImageElement - target.src = '/default-avatar.png' + target.style.display = 'none' + const placeholder = target.parentElement?.querySelector('.avatar-placeholder') as HTMLElement + if (placeholder) { + placeholder.style.display = 'flex' + } } // 初始化 @@ -249,6 +257,21 @@ onMounted(() => { object-fit: cover; } + .avatar-placeholder { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 80px; + font-weight: bold; + color: white; + text-transform: uppercase; + } + .card-actions { position: absolute; top: 8px; @@ -257,6 +280,7 @@ onMounted(() => { gap: 8px; opacity: 0; transition: opacity 0.3s ease; + z-index: 2; .el-button { background: rgba(255, 255, 255, 0.9);