🐛 修复角色卡导出失败的bug

This commit is contained in:
2026-02-11 14:34:34 +08:00
parent cf3197929e
commit 1757b92b5f
8 changed files with 198 additions and 37 deletions

View File

@@ -12,8 +12,8 @@
| 阶段 | 进度 | 状态 | 开始日期 | 完成日期 | | 阶段 | 进度 | 状态 | 开始日期 | 完成日期 |
|------|------|------|----------|----------| |------|------|------|----------|----------|
| 阶段一:数据库设计 | 100% | 🟢 已完成 | 2026-02-10 | 2026-02-10 | | 阶段一:数据库设计 | 100% | 🟢 已完成 | 2026-02-10 | 2026-02-10 |
| 阶段二Go后端API开发 | 35% | 🔵 进行中 | 2026-02-10 | - | | 阶段二Go后端API开发 | 40% | 🔵 进行中 | 2026-02-10 | - |
| 阶段三Vue3前台开发 | 40% | 🔵 进行中 | 2026-02-10 | - | | 阶段三Vue3前台开发 | 45% | 🔵 进行中 | 2026-02-10 | - |
| 阶段四:前端改造(旧版) | 0% | ⚪ 暂停 | - | - | | 阶段四:前端改造(旧版) | 0% | ⚪ 暂停 | - | - |
| 阶段五:数据迁移 | 0% | ⚪ 未开始 | - | - | | 阶段五:数据迁移 | 0% | ⚪ 未开始 | - | - |
| 阶段六:测试与优化 | 0% | ⚪ 未开始 | - | - | | 阶段六:测试与优化 | 0% | ⚪ 未开始 | - | - |
@@ -1008,6 +1008,34 @@
- 完成日期2026-02-10 - 完成日期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 对话功能模块(待开发) ### 3.4 Vue 对话功能模块(待开发)
- [ ] **T3.4.1** 创建对话类型定义 - [ ] **T3.4.1** 创建对话类型定义

View File

@@ -89,7 +89,7 @@ jwt:
buffer-time: 1d buffer-time: 1d
issuer: qmPlus issuer: qmPlus
local: local:
path: uploads/file path: http://localhost:8888/uploads/file
store-path: uploads/file store-path: uploads/file
mcp: mcp:
name: GVA_MCP name: GVA_MCP

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"image" "image"
_ "image/jpeg" _ "image/jpeg"
_ "image/png"
"os" "os"
"strings" "strings"
"time" "time"
@@ -624,10 +625,17 @@ func (cs *CharacterService) ExportCharacterAsPNG(characterID uint, userID *uint)
// 获取角色头像 // 获取角色头像
var img image.Image var img image.Image
var loadErr error
if character.Avatar != "" { if character.Avatar != "" {
// TODO: 从 URL 或文件系统加载头像 // 尝试从文件系统或 URL 加载头像
// 这里暂时创建一个默认图片 img, loadErr = loadAvatarImage(character.Avatar)
img = createDefaultAvatar() if loadErr != nil {
global.GVA_LOG.Warn("加载角色头像失败,使用默认头像",
zap.String("avatar", character.Avatar),
zap.Error(loadErr))
img = createDefaultAvatar()
}
} else { } else {
img = createDefaultAvatar() img = createDefaultAvatar()
} }
@@ -635,9 +643,14 @@ func (cs *CharacterService) ExportCharacterAsPNG(characterID uint, userID *uint)
// 将角色卡数据嵌入到 PNG // 将角色卡数据嵌入到 PNG
pngData, err := utils.EmbedCharacterToPNG(img, card) pngData, err := utils.EmbedCharacterToPNG(img, card)
if err != nil { if err != nil {
global.GVA_LOG.Error("生成 PNG 失败", zap.Error(err))
return nil, errors.New("生成 PNG 失败: " + err.Error()) return nil, errors.New("生成 PNG 失败: " + err.Error())
} }
global.GVA_LOG.Info("PNG 导出成功",
zap.Uint("characterID", characterID),
zap.Int("size", len(pngData)))
return pngData, nil 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 创建默认头像 // createDefaultAvatar 创建默认头像
func createDefaultAvatar() image.Image { func createDefaultAvatar() image.Image {
// 创建一个 400x533 的默认图片3:4 比例) // 创建一个 400x533 的默认图片3:4 比例)
width, height := 400, 533 width, height := 400, 533
img := image.NewRGBA(image.Rect(0, 0, width, height)) img := image.NewRGBA(image.Rect(0, 0, width, height))
// 填充渐变色 // 填充渐变色(从紫色到蓝色)
for y := 0; y < height; y++ { for y := 0; y < height; y++ {
for x := 0; x < width; x++ { for x := 0; x < width; x++ {
// 简单的渐变效果 // 计算渐变颜色
r := uint8(102 + y*155/height) r := uint8(102 + y*155/height)
g := uint8(126 + y*138/height) g := uint8(126 + y*138/height)
b := uint8(234 - y*72/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 := img.Pix[y*img.Stride+x*4:]
pix[0] = r pix[0] = r
pix[1] = g pix[1] = g
pix[2] = b pix[2] = b
pix[3] = 255 pix[3] = 255 // 完全不透明
} }
} }

View File

@@ -84,32 +84,45 @@ export function exportCharacterAsPNG(id: number) {
* 下载角色卡为 JSON 文件 * 下载角色卡为 JSON 文件
*/ */
export async function downloadCharacterJSON(id: number, filename?: string) { export async function downloadCharacterJSON(id: number, filename?: string) {
const data = await exportCharacter(id) try {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) const response = await exportCharacter(id)
const url = URL.createObjectURL(blob) // 导出接口返回的是原始响应,需要访问 response.data
const link = document.createElement('a') const cardData = response.data
link.href = url const blob = new Blob([JSON.stringify(cardData, null, 2)], { type: 'application/json' })
link.download = filename || `character_${id}.json` const url = URL.createObjectURL(blob)
document.body.appendChild(link) const link = document.createElement('a')
link.click() link.href = url
document.body.removeChild(link) link.download = filename || `character_${id}.json`
URL.revokeObjectURL(url) document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (error) {
console.error('下载 JSON 失败:', error)
throw error
}
} }
/** /**
* 下载角色卡为 PNG 文件 * 下载角色卡为 PNG 文件
*/ */
export async function downloadCharacterPNG(id: number, filename?: string) { export async function downloadCharacterPNG(id: number, filename?: string) {
const { data } = await exportCharacterAsPNG(id) try {
const blob = new Blob([data], { type: 'image/png' }) const response = await exportCharacterAsPNG(id)
const url = URL.createObjectURL(blob) // blob 响应直接返回 response不需要 .data
const link = document.createElement('a') const blob = new Blob([response.data], { type: 'image/png' })
link.href = url const url = URL.createObjectURL(blob)
link.download = filename || `character_${id}.png` const link = document.createElement('a')
document.body.appendChild(link) link.href = url
link.click() link.download = filename || `character_${id}.png`
document.body.removeChild(link) document.body.appendChild(link)
URL.revokeObjectURL(url) link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (error) {
console.error('下载 PNG 失败:', error)
throw error
}
} }
/** /**

View File

@@ -30,8 +30,18 @@ service.interceptors.request.use(
// 响应拦截器 // 响应拦截器
service.interceptors.response.use( service.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => { (response: AxiosResponse<ApiResponse>) => {
// 对于 blob 类型的响应(如文件下载),直接返回
if (response.config.responseType === 'blob') {
return response
}
const res = response.data const res = response.data
// 对于导出接口(非标准 ApiResponse 格式),直接返回
if (response.config.url?.includes('/export')) {
return response
}
// code 不为 0 表示业务错误 // code 不为 0 表示业务错误
if (res.code !== 0) { if (res.code !== 0) {
ElMessage.error(res.msg || '请求失败') ElMessage.error(res.msg || '请求失败')

View File

@@ -4,7 +4,15 @@
<!-- 角色头部 --> <!-- 角色头部 -->
<div class="character-header"> <div class="character-header">
<div class="character-avatar-large"> <div class="character-avatar-large">
<img :src="character.avatar || '/default-avatar.png'" :alt="character.name" /> <img
v-if="character.avatar"
:src="character.avatar"
:alt="character.name"
@error="handleImageError"
/>
<div v-else class="avatar-placeholder">
<span>{{ character.name.charAt(0) }}</span>
</div>
</div> </div>
<div class="character-header-info"> <div class="character-header-info">
@@ -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 () => { onMounted(async () => {
const characterId = Number(route.params.id) const characterId = Number(route.params.id)
@@ -349,6 +367,7 @@ onMounted(async () => {
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
img { img {
width: 100%; width: 100%;
@@ -356,6 +375,18 @@ onMounted(async () => {
object-fit: cover; 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) { @media (max-width: 768px) {
width: 100%; width: 100%;
height: 400px; height: 400px;

View File

@@ -40,9 +40,14 @@
<!-- 头像 --> <!-- 头像 -->
<div class="character-avatar" @click="goToDetail(character.id)"> <div class="character-avatar" @click="goToDetail(character.id)">
<img <img
:src="character.avatar || '/default-avatar.png'" v-if="character.avatar"
:src="character.avatar"
:alt="character.name" :alt="character.name"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/> />
<div v-else class="avatar-placeholder">
{{ character.name.charAt(0) }}
</div>
<!-- 公开状态标识 --> <!-- 公开状态标识 -->
<el-tag <el-tag
@@ -263,10 +268,26 @@ onMounted(() => {
object-fit: cover; 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 { .public-tag {
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
z-index: 1;
} }
} }

View File

@@ -38,10 +38,14 @@
<!-- 角色头像 --> <!-- 角色头像 -->
<div class="character-avatar"> <div class="character-avatar">
<img <img
:src="character.avatar || '/default-avatar.png'" v-if="character.avatar"
:src="character.avatar"
:alt="character.name" :alt="character.name"
@error="handleImageError" @error="handleImageError"
/> />
<div v-else class="avatar-placeholder">
{{ character.name.charAt(0) }}
</div>
<!-- 悬浮操作按钮 --> <!-- 悬浮操作按钮 -->
<div class="card-actions"> <div class="card-actions">
@@ -176,7 +180,11 @@ function startChat(id: number) {
// 图片加载失败处理 // 图片加载失败处理
function handleImageError(e: Event) { function handleImageError(e: Event) {
const target = e.target as HTMLImageElement 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; 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 { .card-actions {
position: absolute; position: absolute;
top: 8px; top: 8px;
@@ -257,6 +280,7 @@ onMounted(() => {
gap: 8px; gap: 8px;
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
z-index: 2;
.el-button { .el-button {
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);