🐛 修复角色卡导出失败的bug
This commit is contained in:
@@ -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** 创建对话类型定义
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 或文件系统加载头像
|
||||
// 这里暂时创建一个默认图片
|
||||
// 尝试从文件系统或 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 // 完全不透明
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,8 +84,11 @@ 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' })
|
||||
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
|
||||
@@ -94,14 +97,20 @@ export async function downloadCharacterJSON(id: number, filename?: string) {
|
||||
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' })
|
||||
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
|
||||
@@ -110,6 +119,10 @@ export async function downloadCharacterPNG(id: number, filename?: string) {
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('下载 PNG 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,8 +30,18 @@ service.interceptors.request.use(
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
// 对于 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 || '请求失败')
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
<!-- 角色头部 -->
|
||||
<div class="character-header">
|
||||
<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 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 () => {
|
||||
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;
|
||||
|
||||
@@ -40,9 +40,14 @@
|
||||
<!-- 头像 -->
|
||||
<div class="character-avatar" @click="goToDetail(character.id)">
|
||||
<img
|
||||
:src="character.avatar || '/default-avatar.png'"
|
||||
v-if="character.avatar"
|
||||
:src="character.avatar"
|
||||
: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
|
||||
@@ -263,10 +268,26 @@ 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;
|
||||
}
|
||||
|
||||
.public-tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,14 @@
|
||||
<!-- 角色头像 -->
|
||||
<div class="character-avatar">
|
||||
<img
|
||||
:src="character.avatar || '/default-avatar.png'"
|
||||
v-if="character.avatar"
|
||||
:src="character.avatar"
|
||||
:alt="character.name"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="avatar-placeholder">
|
||||
{{ character.name.charAt(0) }}
|
||||
</div>
|
||||
|
||||
<!-- 悬浮操作按钮 -->
|
||||
<div class="card-actions">
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user