🐛 修复角色卡导出失败的bug
This commit is contained in:
@@ -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** 创建对话类型定义
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 // 完全不透明
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 || '请求失败')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user