🎨 优化前端菜单栏(修改为抽屉模式)&& 新增ai预设功能 && 优化ai对话前端渲染

This commit is contained in:
2026-02-24 20:54:26 +08:00
parent 0ebe197cc1
commit e5e33996d3
30 changed files with 3681 additions and 68 deletions

1
.gitignore vendored
View File

@@ -180,3 +180,4 @@ uploads/
# SillyTavern 核心脚本和扩展文件(从 web-app 一次性复制,不提交到 Git
server/data/st-core-scripts/
server/data/extensions/
.vite

View File

@@ -0,0 +1,256 @@
package app
import (
"git.echol.cn/loser/st/server/global"
"git.echol.cn/loser/st/server/model/app/request"
"git.echol.cn/loser/st/server/model/common/response"
"git.echol.cn/loser/st/server/service"
"git.echol.cn/loser/st/server/utils"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"strconv"
)
type AIPresetApi struct{}
var aiPresetService = service.ServiceGroupApp.AppServiceGroup.AIPresetService
// CreateAIPreset 创建预设
// @Tags AIPreset
// @Summary 创建AI预设
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.CreateAIPresetRequest true "预设信息"
// @Success 200 {object} response.Response{data=app.AIPreset,msg=string} "创建成功"
// @Router /app/preset/create [post]
func (a *AIPresetApi) CreateAIPreset(c *gin.Context) {
var req request.CreateAIPresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
// 获取用户ID
userID := utils.GetUserID(c)
if userID == 0 {
response.FailWithMessage("获取用户信息失败", c)
return
}
preset, err := aiPresetService.CreateAIPreset(userID, &req)
if err != nil {
global.GVA_LOG.Error("创建预设失败", zap.Error(err))
response.FailWithMessage("创建预设失败: "+err.Error(), c)
return
}
response.OkWithData(preset, c)
}
// UpdateAIPreset 更新预设
// @Tags AIPreset
// @Summary 更新AI预设
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "预设ID"
// @Param data body request.UpdateAIPresetRequest true "预设信息"
// @Success 200 {object} response.Response{msg=string} "更新成功"
// @Router /app/preset/update/{id} [put]
func (a *AIPresetApi) UpdateAIPreset(c *gin.Context) {
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.FailWithMessage("无效的预设ID", c)
return
}
var req request.UpdateAIPresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
// 获取用户ID
userID := utils.GetUserID(c)
if userID == 0 {
response.FailWithMessage("获取用户信息失败", c)
return
}
if err := aiPresetService.UpdateAIPreset(userID, uint(presetID), &req); err != nil {
global.GVA_LOG.Error("更新预设失败", zap.Error(err))
response.FailWithMessage("更新预设失败: "+err.Error(), c)
return
}
response.OkWithMessage("更新成功", c)
}
// DeleteAIPreset 删除预设
// @Tags AIPreset
// @Summary 删除AI预设
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "预设ID"
// @Success 200 {object} response.Response{msg=string} "删除成功"
// @Router /app/preset/delete/{id} [delete]
func (a *AIPresetApi) DeleteAIPreset(c *gin.Context) {
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.FailWithMessage("无效的预设ID", c)
return
}
// 获取用户ID
userID := utils.GetUserID(c)
if userID == 0 {
response.FailWithMessage("获取用户信息失败", c)
return
}
if err := aiPresetService.DeleteAIPreset(userID, uint(presetID)); err != nil {
global.GVA_LOG.Error("删除预设失败", zap.Error(err))
response.FailWithMessage("删除预设失败: "+err.Error(), c)
return
}
response.OkWithMessage("删除成功", c)
}
// GetAIPreset 获取预设详情
// @Tags AIPreset
// @Summary 获取AI预设详情
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "预设ID"
// @Success 200 {object} response.Response{data=response.AIPresetResponse,msg=string} "获取成功"
// @Router /app/preset/get/{id} [get]
func (a *AIPresetApi) GetAIPreset(c *gin.Context) {
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.FailWithMessage("无效的预设ID", c)
return
}
// 获取用户ID
userID := utils.GetUserID(c)
if userID == 0 {
response.FailWithMessage("获取用户信息失败", c)
return
}
preset, err := aiPresetService.GetAIPreset(userID, uint(presetID))
if err != nil {
global.GVA_LOG.Error("获取预设失败", zap.Error(err))
response.FailWithMessage("获取预设失败: "+err.Error(), c)
return
}
response.OkWithData(preset, c)
}
// GetAIPresetList 获取预设列表
// @Tags AIPreset
// @Summary 获取AI预设列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.AIPresetSearch true "搜索条件"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /app/preset/list [post]
func (a *AIPresetApi) GetAIPresetList(c *gin.Context) {
var req request.AIPresetSearch
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
// 获取用户ID
userID := utils.GetUserID(c)
if userID == 0 {
response.FailWithMessage("获取用户信息失败", c)
return
}
list, total, err := aiPresetService.GetAIPresetList(userID, &req)
if err != nil {
global.GVA_LOG.Error("获取预设列表失败", zap.Error(err))
response.FailWithMessage("获取预设列表失败: "+err.Error(), c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, "获取成功", c)
}
// DuplicateAIPreset 复制预设
// @Tags AIPreset
// @Summary 复制AI预设
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "预设ID"
// @Success 200 {object} response.Response{data=app.AIPreset,msg=string} "复制成功"
// @Router /app/preset/duplicate/{id} [post]
func (a *AIPresetApi) DuplicateAIPreset(c *gin.Context) {
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.FailWithMessage("无效的预设ID", c)
return
}
// 获取用户ID
userID := utils.GetUserID(c)
if userID == 0 {
response.FailWithMessage("获取用户信息失败", c)
return
}
preset, err := aiPresetService.DuplicateAIPreset(userID, uint(presetID))
if err != nil {
global.GVA_LOG.Error("复制预设失败", zap.Error(err))
response.FailWithMessage("复制预设失败: "+err.Error(), c)
return
}
response.OkWithData(preset, c)
}
// SetDefaultPreset 设置默认预设
// @Tags AIPreset
// @Summary 设置默认AI预设
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "预设ID"
// @Success 200 {object} response.Response{msg=string} "设置成功"
// @Router /app/preset/setDefault/{id} [post]
func (a *AIPresetApi) SetDefaultPreset(c *gin.Context) {
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.FailWithMessage("无效的预设ID", c)
return
}
// 获取用户ID
userID := utils.GetUserID(c)
if userID == 0 {
response.FailWithMessage("获取用户信息失败", c)
return
}
if err := aiPresetService.SetDefaultPreset(userID, uint(presetID)); err != nil {
global.GVA_LOG.Error("设置默认预设失败", zap.Error(err))
response.FailWithMessage("设置默认预设失败: "+err.Error(), c)
return
}
response.OkWithMessage("设置成功", c)
}

View File

@@ -9,6 +9,7 @@ type ApiGroup struct {
RegexScriptApi
ProviderApi
ChatApi
AIPresetApi
}
var (

View File

@@ -96,6 +96,7 @@ func RegisterTables() {
app.AIUsageStat{},
app.AIRegexScript{},
app.AICharacterRegexScript{},
app.AICharacterWorldInfo{},
)
if err != nil {
global.GVA_LOG.Error("register table failed", zap.Error(err))

View File

@@ -153,6 +153,7 @@ func Routers() *gin.Engine {
appRouter.InitRegexScriptRouter(appGroup) // 正则脚本路由:/app/regex/*
appRouter.InitProviderRouter(appGroup) // AI提供商路由/app/provider/*
appRouter.InitChatRouter(appGroup) // 对话路由:/app/chat/*
appRouter.InitAIPresetRouter(appGroup) // AI预设路由/app/preset/*
}
//插件路由安装

View File

@@ -0,0 +1,24 @@
package request
import "git.echol.cn/loser/st/server/model/common/request"
// CreateAIPresetRequest 创建预设请求
type CreateAIPresetRequest struct {
Name string `json:"name" binding:"required,max=200"`
Type string `json:"type" binding:"required,max=100"`
Content map[string]interface{} `json:"content" binding:"required"`
}
// UpdateAIPresetRequest 更新预设请求
type UpdateAIPresetRequest struct {
Name string `json:"name" binding:"required,max=200"`
Type string `json:"type" binding:"required,max=100"`
Content map[string]interface{} `json:"content" binding:"required"`
}
// AIPresetSearch 预设搜索请求
type AIPresetSearch struct {
request.PageInfo
Name string `json:"name" form:"name"`
Type string `json:"type" form:"type"`
}

View File

@@ -0,0 +1,42 @@
package response
import (
"encoding/json"
"git.echol.cn/loser/st/server/model/app"
"time"
)
// AIPresetResponse 预设响应
type AIPresetResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Content map[string]interface{} `json:"content"`
IsSystem bool `json:"isSystem"`
IsDefault bool `json:"isDefault"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ToAIPresetResponse 转换为响应格式
func ToAIPresetResponse(preset *app.AIPreset) *AIPresetResponse {
content := make(map[string]interface{})
if preset.Config != nil && len(preset.Config) > 0 {
// 将 datatypes.JSON 转换为 map
if err := json.Unmarshal(preset.Config, &content); err != nil {
// 如果解析失败,返回空 map
content = make(map[string]interface{})
}
}
return &AIPresetResponse{
ID: preset.ID,
Name: preset.Name,
Type: preset.PresetType,
Content: content,
IsSystem: preset.IsSystem,
IsDefault: preset.IsDefault,
CreatedAt: preset.CreatedAt,
UpdatedAt: preset.UpdatedAt,
}
}

View File

@@ -0,0 +1,23 @@
package app
import (
"git.echol.cn/loser/st/server/api/v1"
"git.echol.cn/loser/st/server/middleware"
"github.com/gin-gonic/gin"
)
type AIPresetRouter struct{}
func (r *AIPresetRouter) InitAIPresetRouter(Router *gin.RouterGroup) {
presetRouter := Router.Group("preset").Use(middleware.JWTAuth())
presetApi := v1.ApiGroupApp.AppApiGroup.AIPresetApi
{
presetRouter.POST("create", presetApi.CreateAIPreset) // 创建预设
presetRouter.PUT("update/:id", presetApi.UpdateAIPreset) // 更新预设
presetRouter.DELETE("delete/:id", presetApi.DeleteAIPreset) // 删除预设
presetRouter.GET("get/:id", presetApi.GetAIPreset) // 获取预设详情
presetRouter.POST("list", presetApi.GetAIPresetList) // 获取预设列表
presetRouter.POST("duplicate/:id", presetApi.DuplicateAIPreset) // 复制预设
presetRouter.POST("setDefault/:id", presetApi.SetDefaultPreset) // 设置默认预设
}
}

View File

@@ -7,4 +7,5 @@ type RouterGroup struct {
RegexScriptRouter
ProviderRouter
ChatRouter
AIPresetRouter
}

View File

@@ -0,0 +1,198 @@
package app
import (
"encoding/json"
"errors"
"git.echol.cn/loser/st/server/global"
"git.echol.cn/loser/st/server/model/app"
"git.echol.cn/loser/st/server/model/app/request"
"git.echol.cn/loser/st/server/model/app/response"
"gorm.io/datatypes"
)
type AIPresetService struct{}
// CreateAIPreset 创建预设
func (s *AIPresetService) CreateAIPreset(userID uint, req *request.CreateAIPresetRequest) (*app.AIPreset, error) {
// 将 content 转换为 JSON
configBytes, err := json.Marshal(req.Content)
if err != nil {
return nil, errors.New("配置格式错误")
}
preset := &app.AIPreset{
Name: req.Name,
UserID: &userID,
PresetType: req.Type,
Config: datatypes.JSON(configBytes),
IsSystem: false,
IsDefault: false,
}
if err := global.GVA_DB.Create(preset).Error; err != nil {
return nil, err
}
return preset, nil
}
// UpdateAIPreset 更新预设
func (s *AIPresetService) UpdateAIPreset(userID uint, presetID uint, req *request.UpdateAIPresetRequest) error {
// 检查预设是否存在且属于当前用户
var preset app.AIPreset
if err := global.GVA_DB.Where("id = ? AND user_id = ?", presetID, userID).First(&preset).Error; err != nil {
return errors.New("预设不存在或无权限")
}
// 系统预设不允许修改
if preset.IsSystem {
return errors.New("系统预设不允许修改")
}
// 将 content 转换为 JSON
configBytes, err := json.Marshal(req.Content)
if err != nil {
return errors.New("配置格式错误")
}
// 更新
updates := map[string]interface{}{
"name": req.Name,
"preset_type": req.Type,
"config": datatypes.JSON(configBytes),
}
if err := global.GVA_DB.Model(&preset).Updates(updates).Error; err != nil {
return err
}
return nil
}
// DeleteAIPreset 删除预设
func (s *AIPresetService) DeleteAIPreset(userID uint, presetID uint) error {
// 检查预设是否存在且属于当前用户
var preset app.AIPreset
if err := global.GVA_DB.Where("id = ? AND user_id = ?", presetID, userID).First(&preset).Error; err != nil {
return errors.New("预设不存在或无权限")
}
// 系统预设不允许删除
if preset.IsSystem {
return errors.New("系统预设不允许删除")
}
if err := global.GVA_DB.Delete(&preset).Error; err != nil {
return err
}
return nil
}
// GetAIPreset 获取预设详情
func (s *AIPresetService) GetAIPreset(userID uint, presetID uint) (*response.AIPresetResponse, error) {
var preset app.AIPreset
// 可以查看自己的预设或系统预设
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_system = true)", presetID, userID).First(&preset).Error; err != nil {
return nil, errors.New("预设不存在")
}
return response.ToAIPresetResponse(&preset), nil
}
// GetAIPresetList 获取预设列表
func (s *AIPresetService) GetAIPresetList(userID uint, req *request.AIPresetSearch) ([]response.AIPresetResponse, int64, error) {
var presets []app.AIPreset
var total int64
db := global.GVA_DB.Model(&app.AIPreset{})
// 只查询自己的预设和系统预设
db = db.Where("user_id = ? OR is_system = true", userID)
// 搜索条件
if req.Name != "" {
db = db.Where("name LIKE ?", "%"+req.Name+"%")
}
if req.Type != "" {
db = db.Where("preset_type = ?", req.Type)
}
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
offset := (req.Page - 1) * req.PageSize
if err := db.Order("is_default DESC, updated_at DESC").
Offset(offset).
Limit(req.PageSize).
Find(&presets).Error; err != nil {
return nil, 0, err
}
// 转换为响应格式
var result []response.AIPresetResponse
for _, preset := range presets {
result = append(result, *response.ToAIPresetResponse(&preset))
}
return result, total, nil
}
// DuplicateAIPreset 复制预设
func (s *AIPresetService) DuplicateAIPreset(userID uint, presetID uint) (*app.AIPreset, error) {
// 获取原预设
var original app.AIPreset
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_system = true)", presetID, userID).First(&original).Error; err != nil {
return nil, errors.New("预设不存在")
}
// 创建副本
duplicate := &app.AIPreset{
Name: original.Name + " (副本)",
UserID: &userID,
PresetType: original.PresetType,
Config: original.Config,
IsSystem: false,
IsDefault: false,
}
if err := global.GVA_DB.Create(duplicate).Error; err != nil {
return nil, err
}
return duplicate, nil
}
// SetDefaultPreset 设置默认预设
func (s *AIPresetService) SetDefaultPreset(userID uint, presetID uint) error {
// 检查预设是否存在且属于当前用户
var preset app.AIPreset
if err := global.GVA_DB.Where("id = ? AND user_id = ?", presetID, userID).First(&preset).Error; err != nil {
return errors.New("预设不存在或无权限")
}
// 开启事务
tx := global.GVA_DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 取消当前用户的所有默认预设
if err := tx.Model(&app.AIPreset{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
tx.Rollback()
return err
}
// 设置新的默认预设
if err := tx.Model(&preset).Update("is_default", true).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}

View File

@@ -7,4 +7,5 @@ type AppServiceGroup struct {
RegexScriptService
ProviderService
ChatService
AIPresetService
}

View File

@@ -17,6 +17,7 @@
"element-plus": "^2.13.2",
"jquery": "^4.0.0",
"lodash": "^4.17.23",
"marked": "^17.0.3",
"pinia": "^3.0.4",
"uuid": "^13.0.0",
"vue": "^3.5.25",
@@ -1295,7 +1296,7 @@
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.0.5.tgz",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
@@ -1770,7 +1771,7 @@
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.1.tgz",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
@@ -2314,6 +2315,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
"integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -18,6 +18,7 @@
"element-plus": "^2.13.2",
"jquery": "^4.0.0",
"lodash": "^4.17.23",
"marked": "^17.0.3",
"pinia": "^3.0.4",
"uuid": "^13.0.0",
"vue": "^3.5.25",

View File

@@ -0,0 +1,74 @@
import request from '@/utils/request'
/**
* 创建AI预设
*/
export const createPreset = (data: any) => {
return request({
url: '/app/preset/create',
method: 'post',
data
})
}
/**
* 更新AI预设
*/
export const updatePreset = (id: number, data: any) => {
return request({
url: `/app/preset/update/${id}`,
method: 'put',
data
})
}
/**
* 删除AI预设
*/
export const deletePreset = (id: number) => {
return request({
url: `/app/preset/delete/${id}`,
method: 'delete'
})
}
/**
* 获取AI预设详情
*/
export const getPreset = (id: number) => {
return request({
url: `/app/preset/get/${id}`,
method: 'get'
})
}
/**
* 获取AI预设列表
*/
export const getPresetList = (data: any) => {
return request({
url: '/app/preset/list',
method: 'post',
data
})
}
/**
* 复制AI预设
*/
export const duplicatePreset = (id: number) => {
return request({
url: `/app/preset/duplicate/${id}`,
method: 'post'
})
}
/**
* 设置默认预设
*/
export const setDefaultPreset = (id: number) => {
return request({
url: `/app/preset/setDefault/${id}`,
method: 'post'
})
}

View File

@@ -11,9 +11,12 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
DrawerContentWrapper: typeof import('./components/DrawerContentWrapper.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
@@ -59,9 +62,14 @@ declare module 'vue' {
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
GenericPresetForm: typeof import('./components/preset/GenericPresetForm.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
MessageRenderer: typeof import('./components/MessageRenderer.vue')['default']
OpenAIPresetForm: typeof import('./components/preset/OpenAIPresetForm.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
VariableNode: typeof import('./components/VariableNode.vue')['default']
VariableViewer: typeof import('./components/VariableViewer.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']

View File

@@ -0,0 +1,152 @@
<template>
<div class="drawer-content-wrapper">
<!-- 面包屑导航 -->
<div v-if="breadcrumbs.length > 1" class="breadcrumb-nav">
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="(item, index) in breadcrumbs"
:key="index"
:class="{ clickable: index < breadcrumbs.length - 1 }"
@click="index < breadcrumbs.length - 1 && goToLevel(index)"
>
{{ item.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 当前视图 -->
<div class="drawer-view">
<component
:is="currentView.component"
:ref="(el: any) => setComponentRef(el)"
v-bind="currentView.props"
@navigate="handleNavigate"
@back="handleBack"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, provide } from 'vue'
interface ViewConfig {
component: any
props?: Record<string, any>
title: string
componentRef?: any
}
const props = defineProps<{
initialComponent: any
initialTitle: string
}>()
// 视图栈
const viewStack = ref<ViewConfig[]>([
{
component: props.initialComponent,
props: {},
title: props.initialTitle,
},
])
// 当前视图
const currentView = shallowRef(viewStack.value[0])
// 面包屑
const breadcrumbs = ref([{ title: props.initialTitle }])
// 设置组件引用
function setComponentRef(el: any) {
if (el && viewStack.value.length > 0) {
viewStack.value[viewStack.value.length - 1].componentRef = el
}
}
// 导航到新视图
function handleNavigate(config: { component: any; props?: Record<string, any>; title: string }) {
viewStack.value.push(config)
currentView.value = config
breadcrumbs.value.push({ title: config.title })
}
// 返回上一级
function handleBack() {
if (viewStack.value.length > 1) {
viewStack.value.pop()
breadcrumbs.value.pop()
const previousView = viewStack.value[viewStack.value.length - 1]
currentView.value = previousView
// 如果上一级视图有 refresh 方法,调用它
setTimeout(() => {
if (previousView.componentRef?.refresh) {
console.log('调用上一级视图的 refresh 方法')
previousView.componentRef.refresh()
}
}, 100)
}
}
// 跳转到指定层级
function goToLevel(index: number) {
if (index < viewStack.value.length - 1) {
viewStack.value = viewStack.value.slice(0, index + 1)
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1)
const targetView = viewStack.value[index]
currentView.value = targetView
// 如果目标视图有 refresh 方法,调用它
setTimeout(() => {
if (targetView.componentRef?.refresh) {
console.log('调用目标视图的 refresh 方法')
targetView.componentRef.refresh()
}
}, 100)
}
}
// 提供导航方法给子组件
provide('drawerNavigate', handleNavigate)
provide('drawerBack', handleBack)
</script>
<style scoped lang="scss">
.drawer-content-wrapper {
height: 100%;
display: flex;
flex-direction: column;
.breadcrumb-nav {
padding: 0 0 16px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
margin-bottom: 16px;
:deep(.el-breadcrumb__item) {
&.clickable {
cursor: pointer;
.el-breadcrumb__inner {
color: var(--el-color-primary);
transition: all 0.3s;
&:hover {
color: var(--el-color-primary-light-3);
}
}
}
&:last-child .el-breadcrumb__inner {
color: var(--el-text-color-regular);
font-weight: 500;
}
}
}
.drawer-view {
flex: 1;
overflow: auto;
}
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<div class="message-renderer" v-html="renderedContent"></div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
const props = defineProps<{
content: string
enableMarkdown?: boolean
enableHtml?: boolean
}>()
// 配置 marked
marked.setOptions({
breaks: true,
gfm: true,
})
// 渲染内容
const renderedContent = computed(() => {
let content = props.content || ''
// 如果启用 Markdown
if (props.enableMarkdown) {
content = marked(content) as string
}
// 如果启用 HTML使用 DOMPurify 清理
if (props.enableHtml) {
content = DOMPurify.sanitize(content, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'blockquote',
'a', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'div', 'span'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style'],
ALLOW_DATA_ATTR: false
})
} else {
// 不启用 HTML 时,转义 HTML 标签
content = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// 处理换行
content = content.replace(/\n/g, '<br>')
return content
})
</script>
<style scoped lang="scss">
.message-renderer {
word-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
:deep(p) {
margin: 0.5em 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
:deep(code) {
background: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
}
:deep(pre) {
background: var(--el-fill-color-light);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 0.5em 0;
code {
background: none;
padding: 0;
}
}
:deep(blockquote) {
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
margin: 0.5em 0;
color: var(--el-text-color-secondary);
}
:deep(a) {
color: var(--el-color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 0.5em 0;
}
:deep(table) {
border-collapse: collapse;
width: 100%;
margin: 0.5em 0;
th, td {
border: 1px solid var(--el-border-color);
padding: 8px 12px;
text-align: left;
}
th {
background: var(--el-fill-color-light);
font-weight: 600;
}
}
:deep(ul, ol) {
margin: 0.5em 0;
padding-left: 2em;
}
:deep(h1, h2, h3, h4, h5, h6) {
margin: 1em 0 0.5em 0;
font-weight: 600;
&:first-child {
margin-top: 0;
}
}
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<div class="variable-node">
<!-- 基本类型 -->
<span v-if="isBasicType" :class="`value-${valueType}`">
{{ formattedValue }}
</span>
<!-- 数组类型 -->
<div v-else-if="Array.isArray(value)" class="array-node">
<div class="node-header" @click="toggleExpand">
<el-icon :class="{ rotated: expanded }">
<ArrowRight />
</el-icon>
<span class="node-label">Array({{ value.length }})</span>
</div>
<div v-if="expanded" class="node-children">
<div
v-for="(item, index) in value"
:key="index"
class="child-item"
>
<span class="child-key">[{{ index }}]:</span>
<VariableNode :value="item" :depth="depth + 1" />
</div>
</div>
</div>
<!-- 对象类型 -->
<div v-else-if="isObject" class="object-node">
<div class="node-header" @click="toggleExpand">
<el-icon :class="{ rotated: expanded }">
<ArrowRight />
</el-icon>
<span class="node-label">Object({{ objectKeys.length }})</span>
</div>
<div v-if="expanded" class="node-children">
<div
v-for="key in objectKeys"
:key="key"
class="child-item"
>
<span class="child-key">{{ key }}:</span>
<VariableNode :value="value[key]" :depth="depth + 1" />
</div>
</div>
</div>
<!-- null undefined -->
<span v-else class="value-null">{{ value }}</span>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ArrowRight } from '@element-plus/icons-vue'
const props = defineProps<{
value: any
depth: number
}>()
const expanded = ref(props.depth < 2) // 默认展开前两层
// 值类型
const valueType = computed(() => typeof props.value)
// 是否为基本类型
const isBasicType = computed(() => {
const type = valueType.value
return type === 'string' || type === 'number' || type === 'boolean'
})
// 是否为对象
const isObject = computed(() => {
return props.value !== null && valueType.value === 'object' && !Array.isArray(props.value)
})
// 对象的键
const objectKeys = computed(() => {
if (isObject.value) {
return Object.keys(props.value)
}
return []
})
// 格式化的值
const formattedValue = computed(() => {
if (valueType.value === 'string') {
return `"${props.value}"`
}
return String(props.value)
})
// 切换展开/收起
function toggleExpand() {
expanded.value = !expanded.value
}
</script>
<style scoped lang="scss">
.variable-node {
.value-string {
color: #22863a;
}
.value-number {
color: #005cc5;
}
.value-boolean {
color: #d73a49;
}
.value-null {
color: #6a737d;
font-style: italic;
}
.array-node,
.object-node {
.node-header {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
&:hover {
background: var(--el-fill-color-light);
}
.el-icon {
transition: transform 0.2s;
font-size: 12px;
&.rotated {
transform: rotate(90deg);
}
}
.node-label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
.node-children {
margin-left: 16px;
padding-left: 8px;
border-left: 1px solid var(--el-border-color-lighter);
.child-item {
display: flex;
gap: 8px;
padding: 2px 0;
.child-key {
color: var(--el-color-primary);
font-weight: 500;
flex-shrink: 0;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="variable-viewer">
<el-card>
<template #header>
<div class="card-header">
<span>变量查看器</span>
<el-button size="small" :icon="Refresh" @click="handleRefresh">刷新</el-button>
</div>
</template>
<el-empty v-if="!variables || variables.length === 0" description="暂无变量" />
<div v-else class="variable-list">
<div
v-for="variable in variables"
:key="variable.key"
class="variable-item"
>
<div class="variable-header">
<span class="variable-key">{{ variable.key }}</span>
<el-tag :type="getVariableType(variable.value)" size="small">
{{ typeof variable.value }}
</el-tag>
</div>
<div class="variable-value">
<VariableNode :value="variable.value" :depth="0" />
</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import VariableNode from './VariableNode.vue'
const props = defineProps<{
variables: Array<{ key: string; value: any }>
}>()
const emit = defineEmits<{
(e: 'refresh'): void
}>()
// 刷新
function handleRefresh() {
emit('refresh')
}
// 获取变量类型标签颜色
function getVariableType(value: any): string {
const type = typeof value
switch (type) {
case 'string':
return 'success'
case 'number':
return 'warning'
case 'boolean':
return 'info'
case 'object':
return value === null ? '' : 'primary'
default:
return ''
}
}
</script>
<style scoped lang="scss">
.variable-viewer {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.variable-list {
display: flex;
flex-direction: column;
gap: 12px;
.variable-item {
padding: 12px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
.variable-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.variable-key {
font-weight: 600;
color: var(--el-text-color-primary);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
}
.variable-value {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
color: var(--el-text-color-regular);
}
}
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="generic-preset-form">
<el-alert
title="此预设类型暂不支持表单模式"
type="info"
:closable="false"
show-icon
>
<template #default>
<p>当前预设类型暂时只支持 JSON 模式编辑</p>
<p>请切换到 JSON 模式进行编辑</p>
</template>
</el-alert>
<el-form :model="localData" label-width="140px" style="margin-top: 20px;">
<el-form-item label="预设名称">
<el-input v-model="localData.name" placeholder="请输入预设名称" disabled />
</el-form-item>
<el-form-item label="预设类型">
<el-input v-model="localData.type" placeholder="预设类型" disabled />
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: any): void
}>()
const localData = ref({
name: props.modelValue?.name ?? '',
type: props.modelValue?.type ?? ''
})
watch(() => props.modelValue, (newVal) => {
if (newVal) {
localData.value = {
name: newVal.name ?? '',
type: newVal.type ?? ''
}
}
}, { deep: true })
</script>
<style scoped lang="scss">
.generic-preset-form {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,326 @@
<template>
<div class="openai-preset-form">
<el-form :model="localData" label-width="140px" label-position="left">
<!-- 基础参数 -->
<el-divider content-position="left">基础参数</el-divider>
<el-form-item label="Temperature">
<el-slider
v-model="localData.temperature"
:min="0"
:max="2"
:step="0.01"
show-input
:input-size="'small'"
/>
<span class="param-desc">控制输出的随机性值越高越随机</span>
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number
v-model="localData.max_tokens"
:min="1"
:max="32000"
:step="100"
/>
<span class="param-desc">生成的最大token数量</span>
</el-form-item>
<el-form-item label="Top P">
<el-slider
v-model="localData.top_p"
:min="0"
:max="1"
:step="0.01"
show-input
:input-size="'small'"
/>
<span class="param-desc">核采样参数控制输出多样性</span>
</el-form-item>
<el-form-item label="Frequency Penalty">
<el-slider
v-model="localData.frequency_penalty"
:min="-2"
:max="2"
:step="0.01"
show-input
:input-size="'small'"
/>
<span class="param-desc">降低重复词汇的频率</span>
</el-form-item>
<el-form-item label="Presence Penalty">
<el-slider
v-model="localData.presence_penalty"
:min="-2"
:max="2"
:step="0.01"
show-input
:input-size="'small'"
/>
<span class="param-desc">鼓励谈论新话题</span>
</el-form-item>
<!-- 提示词模板 -->
<el-divider content-position="left">提示词模板</el-divider>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.main_prompt">主提示词</el-checkbox>
<span class="prompt-desc">系统的主要提示词</span>
</div>
<el-input
v-if="promptsEnabled.main_prompt"
v-model="localData.main_prompt"
type="textarea"
:rows="4"
placeholder="输入主提示词..."
/>
</div>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.nsfw_prompt">NSFW提示词</el-checkbox>
<span class="prompt-desc">成人内容相关提示词</span>
</div>
<el-input
v-if="promptsEnabled.nsfw_prompt"
v-model="localData.nsfw_prompt"
type="textarea"
:rows="3"
placeholder="输入NSFW提示词..."
/>
</div>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.jailbreak_prompt">越狱提示词</el-checkbox>
<span class="prompt-desc">绕过限制的提示词</span>
</div>
<el-input
v-if="promptsEnabled.jailbreak_prompt"
v-model="localData.jailbreak_prompt"
type="textarea"
:rows="3"
placeholder="输入越狱提示词..."
/>
</div>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.impersonation_prompt">扮演提示词</el-checkbox>
<span class="prompt-desc">角色扮演相关提示词</span>
</div>
<el-input
v-if="promptsEnabled.impersonation_prompt"
v-model="localData.impersonation_prompt"
type="textarea"
:rows="2"
placeholder="输入扮演提示词..."
/>
</div>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.wi_format">世界书格式</el-checkbox>
<span class="prompt-desc">世界书条目的格式化模板</span>
</div>
<el-input
v-if="promptsEnabled.wi_format"
v-model="localData.wi_format"
type="textarea"
:rows="2"
placeholder="输入世界书格式..."
/>
</div>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.scenario_format">场景格式</el-checkbox>
<span class="prompt-desc">场景描述的格式化模板</span>
</div>
<el-input
v-if="promptsEnabled.scenario_format"
v-model="localData.scenario_format"
type="textarea"
:rows="2"
placeholder="输入场景格式..."
/>
</div>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.persona_description">角色描述</el-checkbox>
<span class="prompt-desc">角色人设描述模板</span>
</div>
<el-input
v-if="promptsEnabled.persona_description"
v-model="localData.persona_description"
type="textarea"
:rows="2"
placeholder="输入角色描述..."
/>
</div>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.new_chat_prompt">新对话提示词</el-checkbox>
<span class="prompt-desc">开始新对话时的提示词</span>
</div>
<el-input
v-if="promptsEnabled.new_chat_prompt"
v-model="localData.new_chat_prompt"
type="textarea"
:rows="2"
placeholder="输入新对话提示词..."
/>
</div>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.new_group_chat_prompt">群聊提示词</el-checkbox>
<span class="prompt-desc">群组对话的提示词</span>
</div>
<el-input
v-if="promptsEnabled.new_group_chat_prompt"
v-model="localData.new_group_chat_prompt"
type="textarea"
:rows="2"
placeholder="输入群聊提示词..."
/>
</div>
<div class="prompt-item">
<div class="prompt-header">
<el-checkbox v-model="promptsEnabled.new_example_chat_prompt">示例对话提示词</el-checkbox>
<span class="prompt-desc">示例对话的提示词</span>
</div>
<el-input
v-if="promptsEnabled.new_example_chat_prompt"
v-model="localData.new_example_chat_prompt"
type="textarea"
:rows="2"
placeholder="输入示例对话提示词..."
/>
</div>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: any): void
}>()
// 本地数据
const localData = ref({
temperature: props.modelValue?.temperature ?? 1.0,
max_tokens: props.modelValue?.max_tokens ?? 2000,
top_p: props.modelValue?.top_p ?? 1.0,
frequency_penalty: props.modelValue?.frequency_penalty ?? 0,
presence_penalty: props.modelValue?.presence_penalty ?? 0,
main_prompt: props.modelValue?.main_prompt ?? '',
nsfw_prompt: props.modelValue?.nsfw_prompt ?? '',
jailbreak_prompt: props.modelValue?.jailbreak_prompt ?? '',
impersonation_prompt: props.modelValue?.impersonation_prompt ?? '',
wi_format: props.modelValue?.wi_format ?? '',
scenario_format: props.modelValue?.scenario_format ?? '',
persona_description: props.modelValue?.persona_description ?? '',
new_chat_prompt: props.modelValue?.new_chat_prompt ?? '',
new_group_chat_prompt: props.modelValue?.new_group_chat_prompt ?? '',
new_example_chat_prompt: props.modelValue?.new_example_chat_prompt ?? ''
})
// 提示词启用状态
const promptsEnabled = ref({
main_prompt: !!props.modelValue?.main_prompt,
nsfw_prompt: !!props.modelValue?.nsfw_prompt,
jailbreak_prompt: !!props.modelValue?.jailbreak_prompt,
impersonation_prompt: !!props.modelValue?.impersonation_prompt,
wi_format: !!props.modelValue?.wi_format,
scenario_format: !!props.modelValue?.scenario_format,
persona_description: !!props.modelValue?.persona_description,
new_chat_prompt: !!props.modelValue?.new_chat_prompt,
new_group_chat_prompt: !!props.modelValue?.new_group_chat_prompt,
new_example_chat_prompt: !!props.modelValue?.new_example_chat_prompt
})
// 监听本地数据变化,同步到父组件
watch(localData, (newVal) => {
emit('update:modelValue', { ...newVal })
}, { deep: true })
// 监听启用状态变化,清空未启用的提示词
watch(promptsEnabled, (newVal) => {
Object.keys(newVal).forEach(key => {
if (!newVal[key as keyof typeof newVal]) {
localData.value[key as keyof typeof localData.value] = ''
}
})
}, { deep: true })
// 监听外部数据变化
watch(() => props.modelValue, (newVal) => {
if (newVal) {
localData.value = {
temperature: newVal.temperature ?? 1.0,
max_tokens: newVal.max_tokens ?? 2000,
top_p: newVal.top_p ?? 1.0,
frequency_penalty: newVal.frequency_penalty ?? 0,
presence_penalty: newVal.presence_penalty ?? 0,
main_prompt: newVal.main_prompt ?? '',
nsfw_prompt: newVal.nsfw_prompt ?? '',
jailbreak_prompt: newVal.jailbreak_prompt ?? '',
impersonation_prompt: newVal.impersonation_prompt ?? '',
wi_format: newVal.wi_format ?? '',
scenario_format: newVal.scenario_format ?? '',
persona_description: newVal.persona_description ?? '',
new_chat_prompt: newVal.new_chat_prompt ?? '',
new_group_chat_prompt: newVal.new_group_chat_prompt ?? '',
new_example_chat_prompt: newVal.new_example_chat_prompt ?? ''
}
}
}, { deep: true })
</script>
<style scoped lang="scss">
.openai-preset-form {
.param-desc {
margin-left: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.prompt-item {
margin-bottom: 20px;
.prompt-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.prompt-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
:deep(.el-form-item) {
margin-bottom: 24px;
}
:deep(.el-slider) {
flex: 1;
max-width: 400px;
}
}
</style>

View File

@@ -5,39 +5,82 @@
<div class="header-left">
<h1 class="logo" @click="router.push('/')">云酒馆</h1>
<!-- 主导航菜单 -->
<el-menu
:default-active="activeMenu"
mode="horizontal"
:ellipsis="false"
class="header-menu"
@select="handleMenuSelect"
<!-- 主导航菜单 - 改为按钮触发抽屉 -->
<div class="header-menu">
<el-button
text
:class="{ active: activeMenu === '/' }"
@click="router.push('/')"
>
<el-menu-item index="/">
<el-icon><Grid /></el-icon>
<span>角色广场</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLoggedIn" index="/my-characters">
</el-button>
<template v-if="authStore.isLoggedIn">
<el-button
text
:class="{ active: activeMenu === '/my-characters' }"
@click="openDrawer('my-characters')"
>
<el-icon><Files /></el-icon>
<span>我的角色卡</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLoggedIn" index="/worldbook">
</el-button>
<el-button
text
:class="{ active: activeMenu === '/worldbook' }"
@click="openDrawer('worldbook')"
>
<el-icon><Reading /></el-icon>
<span>世界书</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLoggedIn" index="/regex">
</el-button>
<el-button
text
:class="{ active: activeMenu === '/regex' }"
@click="openDrawer('regex')"
>
<el-icon><MagicStick /></el-icon>
<span>正则脚本</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLoggedIn" index="/chats">
</el-button>
<el-button
text
:class="{ active: activeMenu === '/scripts' }"
@click="openDrawer('scripts')"
>
<el-icon><Document /></el-icon>
<span>脚本管理</span>
</el-button>
<el-button
text
:class="{ active: activeMenu === '/preset' }"
@click="openDrawer('preset')"
>
<el-icon><Memo /></el-icon>
<span>AI 预设</span>
</el-button>
<el-button
text
:class="{ active: activeMenu === '/chats' }"
@click="router.push('/chats')"
>
<el-icon><ChatDotRound /></el-icon>
<span>对话</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLoggedIn" index="/ai-config">
</el-button>
<el-button
text
:class="{ active: activeMenu === '/ai-config' }"
@click="openDrawer('ai-config')"
>
<el-icon><Setting /></el-icon>
<span>AI 配置</span>
</el-menu-item>
</el-menu>
</el-button>
</template>
</div>
</div>
<div class="header-right">
@@ -74,14 +117,30 @@
<el-main class="layout-main">
<router-view />
</el-main>
<!-- 抽屉式面板 -->
<el-drawer
v-model="drawerVisible"
:title="drawerTitle"
:size="drawerSize"
direction="rtl"
:destroy-on-close="true"
>
<DrawerContentWrapper
v-if="drawerComponent"
:initial-component="drawerComponent"
:initial-title="drawerTitle"
/>
</el-drawer>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, shallowRef } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Grid, Files, Reading, MagicStick, Setting, ChatDotRound } from '@element-plus/icons-vue'
import { Grid, Files, Reading, MagicStick, Document, Memo, Setting, ChatDotRound } from '@element-plus/icons-vue'
import DrawerContentWrapper from '@/components/DrawerContentWrapper.vue'
const router = useRouter()
const route = useRoute()
@@ -90,19 +149,69 @@ const authStore = useAuthStore()
// 初始化用户信息
authStore.initUserInfo()
// 抽屉状态
const drawerVisible = ref(false)
const drawerTitle = ref('')
const drawerSize = ref('60%')
const drawerComponent = shallowRef<any>(null)
// 当前激活的菜单
const activeMenu = computed(() => {
if (route.path.startsWith('/my-characters')) return '/my-characters'
if (route.path.startsWith('/worldbook')) return '/worldbook'
if (route.path.startsWith('/regex')) return '/regex'
if (route.path.startsWith('/scripts')) return '/scripts'
if (route.path.startsWith('/preset')) return '/preset'
if (route.path.startsWith('/chats') || route.path.startsWith('/chat/')) return '/chats'
if (route.path.startsWith('/ai-config')) return '/ai-config'
return '/'
})
// 菜单选择处理
function handleMenuSelect(index: string) {
router.push(index)
// 打开抽屉
async function openDrawer(type: string) {
// 如果抽屉已经打开,先关闭它以重置状态
if (drawerVisible.value) {
drawerVisible.value = false
// 等待抽屉关闭动画完成
await new Promise(resolve => setTimeout(resolve, 300))
}
// 根据类型设置抽屉内容
switch (type) {
case 'my-characters':
drawerTitle.value = '我的角色卡'
drawerSize.value = '70%'
drawerComponent.value = (await import('@/views/character/MyCharacters.vue')).default
break
case 'worldbook':
drawerTitle.value = '世界书管理'
drawerSize.value = '75%'
drawerComponent.value = (await import('@/views/worldbook/WorldBookListDrawer.vue')).default
break
case 'regex':
drawerTitle.value = '正则脚本管理'
drawerSize.value = '70%'
drawerComponent.value = (await import('@/views/regex/RegexScriptList.vue')).default
break
case 'scripts':
drawerTitle.value = '脚本管理'
drawerSize.value = '70%'
drawerComponent.value = (await import('@/views/script/ScriptManager.vue')).default
break
case 'preset':
drawerTitle.value = 'AI 预设管理'
drawerSize.value = '70%'
drawerComponent.value = (await import('@/views/preset/PresetList.vue')).default
break
case 'ai-config':
drawerTitle.value = 'AI 配置'
drawerSize.value = '60%'
drawerComponent.value = (await import('@/views/provider/ProviderList.vue')).default
break
}
// 打开抽屉
drawerVisible.value = true
}
// 下拉菜单命令处理
@@ -155,8 +264,33 @@ function handleCommand(command: string) {
}
.header-menu {
border: none;
background: transparent;
display: flex;
align-items: center;
gap: 4px;
:deep(.el-button) {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
font-size: 14px;
color: var(--el-text-color-regular);
transition: all 0.3s;
&:hover {
color: var(--el-color-primary);
background: var(--el-fill-color-light);
}
&.active {
color: var(--el-color-primary);
font-weight: 500;
}
.el-icon {
font-size: 16px;
}
}
}
}

41
web-app-vue/src/types/preset.d.ts vendored Normal file
View File

@@ -0,0 +1,41 @@
// AI预设类型
export interface AIPreset {
id: number
name: string
type: string
content: Record<string, any>
isSystem: boolean
isDefault: boolean
createdAt: string
updatedAt: string
}
// 创建预设请求
export interface CreatePresetRequest {
name: string
type: string
content: Record<string, any>
}
// 更新预设请求
export interface UpdatePresetRequest {
name: string
type: string
content: Record<string, any>
}
// 预设列表查询参数
export interface PresetListParams {
page: number
pageSize: number
name?: string
type?: string
}
// 预设类型选项
export const PRESET_TYPE_OPTIONS = [
{ label: 'OpenAI', value: 'openai' },
{ label: 'Claude', value: 'claude' },
{ label: 'Gemini', value: 'gemini' },
{ label: 'Custom', value: 'custom' }
]

View File

@@ -143,6 +143,14 @@ import { ElMessageBox, ElMessage } from 'element-plus'
import { ArrowLeft, MoreFilled, Promotion, Loading } from '@element-plus/icons-vue'
import * as chatApi from '@/api/chat'
import * as regexScriptApi from '@/api/regexScript'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
// 配置 marked
marked.setOptions({
breaks: true,
gfm: true,
})
const router = useRouter()
const route = useRoute()
@@ -319,17 +327,40 @@ function isFullHtmlDocument(text: string | undefined): boolean {
return t.startsWith('<!doctype html') || t.startsWith('<html') || t.includes('<body>')
}
/** 简单的 Markdown 渲染(粗体、斜体、代码、换行 */
/** 增强的 Markdown 渲染(支持完整 Markdown 语法和 HTML */
function renderMarkdown(text: string): string {
if (!text) return ''
try {
// 使用 marked 渲染 Markdown
let html = marked(text) as string
// 使用 DOMPurify 清理 HTML保留常用标签
html = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'blockquote',
'a', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'div', 'span', 'hr'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style', 'target', 'rel'],
ALLOW_DATA_ATTR: false
})
return html
} catch (error) {
console.error('Markdown 渲染失败:', error)
// 降级到简单渲染
return text
// 仅做最基本的 & 转义,保留 HTML 标签以支持角色卡/正则脚本输出的富文本
.replace(/&/g, '&amp;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
}
}
</script>
<style scoped lang="scss">
@@ -436,16 +467,106 @@ function renderMarkdown(text: string): string {
font-size: 14px;
line-height: 1.6;
:deep(p) {
margin: 0.5em 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
:deep(code) {
background: var(--el-fill-color-darker);
padding: 1px 4px;
border-radius: 3px;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
:deep(pre) {
background: var(--el-fill-color-darker);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 0.5em 0;
code {
background: none;
padding: 0;
}
}
:deep(blockquote) {
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
margin: 0.5em 0;
color: var(--el-text-color-secondary);
}
:deep(a) {
color: var(--el-color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 0.5em 0;
}
:deep(table) {
border-collapse: collapse;
width: 100%;
margin: 0.5em 0;
th, td {
border: 1px solid var(--el-border-color);
padding: 8px 12px;
text-align: left;
}
th {
background: var(--el-fill-color-light);
font-weight: 600;
}
}
:deep(ul, ol) {
margin: 0.5em 0;
padding-left: 2em;
}
:deep(h1, h2, h3, h4, h5, h6) {
margin: 1em 0 0.5em 0;
font-weight: 600;
&:first-child {
margin-top: 0;
}
}
:deep(hr) {
border: none;
border-top: 1px solid var(--el-border-color-lighter);
margin: 1em 0;
}
:deep(strong) {
font-weight: 600;
}
:deep(em) {
font-style: italic;
}
}
.msg-meta {

View File

@@ -0,0 +1,340 @@
<template>
<div class="preset-edit">
<!-- 页面标题 -->
<div class="page-header">
<h2>{{ isEdit ? '编辑预设' : '创建预设' }}</h2>
<div class="actions">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
保存
</el-button>
</div>
</div>
<!-- 基本信息 -->
<el-card class="info-card">
<template #header>
<span>基本信息</span>
</template>
<el-form :model="formData" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="预设名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入预设名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="预设类型" prop="type">
<el-select
v-model="formData.type"
placeholder="请选择预设类型"
:disabled="isEdit"
@change="handleTypeChange"
>
<el-option
v-for="option in PRESET_TYPE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-form>
</el-card>
<!-- 预设内容 -->
<el-card class="content-card">
<template #header>
<div class="card-header">
<span>预设内容</span>
<el-radio-group v-model="editMode" size="small">
<el-radio-button label="form">表单模式</el-radio-button>
<el-radio-button label="json">JSON模式</el-radio-button>
</el-radio-group>
</div>
</template>
<!-- 表单模式 -->
<div v-if="editMode === 'form'" class="form-mode">
<component
:is="currentFormComponent"
v-model="formData.content"
/>
</div>
<!-- JSON模式 -->
<div v-else class="json-mode">
<el-input
v-model="jsonContent"
type="textarea"
:rows="20"
placeholder="请输入JSON格式的预设内容"
/>
<div class="json-actions">
<el-button size="small" @click="formatJson">格式化</el-button>
<el-button size="small" @click="validateJson">验证</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, inject, watch, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as presetApi from '@/api/preset'
import { PRESET_TYPE_OPTIONS } from '@/types/preset.d'
import type { AIPreset, CreatePresetRequest } from '@/types/preset'
import OpenAIPresetForm from '@/components/preset/OpenAIPresetForm.vue'
import GenericPresetForm from '@/components/preset/GenericPresetForm.vue'
// Props用于抽屉模式
const props = defineProps<{
presetId?: number
mode?: 'create' | 'edit'
}>()
const route = useRoute()
const router = useRouter()
// 抽屉导航方法
const drawerBack = inject<any>('drawerBack', null)
// 表单引用
const formRef = ref()
// 本地加载状态
const saving = ref(false)
// 编辑模式form 或 json
const editMode = ref<'form' | 'json'>('form')
// 是否为编辑模式
const isEdit = computed(() => {
if (props.mode) {
return props.mode === 'edit'
}
return !!route.params.id
})
// 获取预设ID
const presetId = computed(() => {
if (props.presetId) {
return props.presetId
}
return route.params.id ? Number(route.params.id) : undefined
})
// 表单数据
const formData = ref<CreatePresetRequest>({
name: '',
type: 'openai',
content: {}
})
// JSON内容
const jsonContent = ref('')
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入预设名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择预设类型', trigger: 'change' }
]
}
// 当前表单组件
const currentFormComponent = shallowRef<any>(OpenAIPresetForm)
// 根据类型选择表单组件
function getFormComponent(type: string) {
switch (type) {
case 'openai':
return OpenAIPresetForm
default:
return GenericPresetForm
}
}
// 类型变化处理
function handleTypeChange(type: string) {
currentFormComponent.value = getFormComponent(type)
// 重置内容
formData.value.content = {}
jsonContent.value = '{}'
}
// 同步表单数据到JSON
watch(() => formData.value.content, (newVal) => {
if (editMode.value === 'form') {
jsonContent.value = JSON.stringify(newVal, null, 2)
}
}, { deep: true })
// 同步JSON到表单数据
watch(jsonContent, (newVal) => {
if (editMode.value === 'json') {
try {
formData.value.content = JSON.parse(newVal)
} catch (error) {
// JSON格式错误不更新
}
}
})
// 格式化JSON
function formatJson() {
try {
const obj = JSON.parse(jsonContent.value)
jsonContent.value = JSON.stringify(obj, null, 2)
ElMessage.success('格式化成功')
} catch (error) {
ElMessage.error('JSON格式错误')
}
}
// 验证JSON
function validateJson() {
try {
JSON.parse(jsonContent.value)
ElMessage.success('JSON格式正确')
} catch (error: any) {
ElMessage.error(`JSON格式错误: ${error.message}`)
}
}
// 保存
const handleSave = async () => {
if (saving.value) {
return
}
try {
saving.value = true
// 验证表单
await formRef.value.validate()
// 如果是JSON模式验证JSON格式
if (editMode.value === 'json') {
try {
formData.value.content = JSON.parse(jsonContent.value)
} catch (error) {
ElMessage.error('JSON格式错误请检查')
return
}
}
// 保存
if (isEdit.value && presetId.value) {
await presetApi.updatePreset(presetId.value, {
name: formData.value.name,
type: formData.value.type,
content: formData.value.content
})
ElMessage.success('更新成功')
} else {
await presetApi.createPreset(formData.value)
ElMessage.success('创建成功')
}
// 返回
if (drawerBack) {
drawerBack()
} else {
router.push('/preset')
}
} catch (error: any) {
console.error('保存失败:', error)
ElMessage.error(error.response?.data?.msg || '保存失败')
} finally {
saving.value = false
}
}
// 取消
const handleCancel = () => {
if (drawerBack) {
drawerBack()
} else {
router.back()
}
}
// 初始化
onMounted(async () => {
if (isEdit.value && presetId.value) {
try {
const res = await presetApi.getPreset(presetId.value)
const preset = res.data
formData.value = {
name: preset.name,
type: preset.type,
content: preset.content || {}
}
jsonContent.value = JSON.stringify(preset.content || {}, null, 2)
currentFormComponent.value = getFormComponent(preset.type)
} catch (error) {
ElMessage.error('加载预设失败')
if (drawerBack) {
drawerBack()
} else {
router.push('/preset')
}
}
} else {
// 新建时初始化JSON内容
jsonContent.value = '{}'
}
})
</script>
<style scoped lang="scss">
.preset-edit {
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 24px;
color: #303133;
}
.actions {
display: flex;
gap: 10px;
}
}
.info-card {
margin-bottom: 20px;
}
.content-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.json-mode {
.json-actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
}
}
}
</style>

View File

@@ -0,0 +1,304 @@
<template>
<div class="preset-list">
<!-- 操作按钮 -->
<div class="actions-bar">
<el-button type="primary" :icon="Plus" @click="handleCreate">
创建预设
</el-button>
<el-upload
:show-file-list="false"
:before-upload="handleImport"
accept=".json"
>
<el-button type="success" :icon="Upload">导入预设</el-button>
</el-upload>
</div>
<!-- 搜索栏 -->
<el-card class="search-card">
<el-form :inline="true" :model="searchForm" @submit.prevent="handleSearch">
<el-form-item label="预设名称">
<el-input
v-model="searchForm.name"
placeholder="搜索预设"
clearable
@clear="handleSearch"
/>
</el-form-item>
<el-form-item label="类型">
<el-select
v-model="searchForm.type"
placeholder="全部"
clearable
@change="handleSearch"
>
<el-option
v-for="option in PRESET_TYPE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 预设列表 -->
<el-card class="list-card">
<el-table
v-loading="loading"
:data="presets"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="预设名称" min-width="200" />
<el-table-column label="类型" width="150">
<template #default="{ row }">
<el-tag>{{ getTypeLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDate(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="success"
size="small"
@click="handleDuplicate(row)"
>
复制
</el-button>
<el-button
type="info"
size="small"
@click="handleExport(row)"
>
导出
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="searchForm.page"
v-model:page-size="searchForm.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, inject } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Upload } from '@element-plus/icons-vue'
import * as presetApi from '@/api/preset'
import { PRESET_TYPE_OPTIONS } from '@/types/preset.d'
import type { AIPreset } from '@/types/preset'
import { defineAsyncComponent } from 'vue'
const drawerNavigate = inject<any>('drawerNavigate')
const loading = ref(false)
const presets = ref<AIPreset[]>([])
const total = ref(0)
const searchForm = ref({
page: 1,
pageSize: 20,
name: '',
type: ''
})
// 加载预设列表
async function loadPresets() {
loading.value = true
try {
const res = await presetApi.getPresetList(searchForm.value) as any
presets.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (error) {
ElMessage.error('加载预设列表失败')
} finally {
loading.value = false
}
}
// 暴露刷新方法
defineExpose({
refresh: loadPresets
})
// 搜索
function handleSearch() {
searchForm.value.page = 1
loadPresets()
}
// 重置
function handleReset() {
searchForm.value = {
page: 1,
pageSize: 20,
name: '',
type: ''
}
loadPresets()
}
// 创建预设
function handleCreate() {
const PresetEdit = defineAsyncComponent(() => import('./PresetEdit.vue'))
drawerNavigate({
component: PresetEdit,
props: { mode: 'create' },
title: '创建AI预设',
})
}
// 编辑预设
function handleEdit(row: AIPreset) {
const PresetEdit = defineAsyncComponent(() => import('./PresetEdit.vue'))
drawerNavigate({
component: PresetEdit,
props: { presetId: row.id, mode: 'edit' },
title: `编辑预设 - ${row.name}`,
})
}
// 复制预设
async function handleDuplicate(row: AIPreset) {
try {
await ElMessageBox.confirm(
`确定要复制预设"${row.name}"吗?`,
'提示',
{ type: 'warning' }
)
await presetApi.duplicatePreset(row.id)
ElMessage.success('复制成功')
loadPresets()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('复制失败')
}
}
}
// 导出预设
async function handleExport(row: AIPreset) {
try {
const blob = new Blob([JSON.stringify(row.content, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${row.name}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error) {
ElMessage.error('导出失败')
}
}
// 删除预设
async function handleDelete(row: AIPreset) {
try {
await ElMessageBox.confirm(
`确定要删除预设"${row.name}"吗?此操作不可恢复!`,
'警告',
{ type: 'warning' }
)
await presetApi.deletePreset(row.id)
ElMessage.success('删除成功')
loadPresets()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
// 导入预设
async function handleImport(file: File) {
try {
const text = await file.text()
const content = JSON.parse(text)
await presetApi.createPreset({
name: content.name || file.name.replace('.json', ''),
type: content.type || 'openai',
content: content
})
ElMessage.success('导入成功')
loadPresets()
} catch (error) {
ElMessage.error('导入失败')
}
return false
}
// 获取类型标签
function getTypeLabel(type: string) {
const option = PRESET_TYPE_OPTIONS.find(o => o.value === type)
return option?.label || type
}
// 格式化日期
function formatDate(date: string) {
return new Date(date).toLocaleString('zh-CN')
}
onMounted(() => {
loadPresets()
})
</script>
<style scoped lang="scss">
.preset-list {
.actions-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.search-card {
margin-bottom: 16px;
}
.list-card {
.pagination {
margin-top: 16px;
display: flex;
justify-content: center;
}
}
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<div class="script-manager">
<el-card>
<template #header>
<div class="card-header">
<span>脚本管理</span>
<el-button type="primary" size="small" :icon="Plus">
添加脚本
</el-button>
</div>
</template>
<el-empty description="脚本管理功能开发中..." />
</el-card>
</div>
</template>
<script setup lang="ts">
import { Plus } from '@element-plus/icons-vue'
// 暴露刷新方法
defineExpose({
refresh: () => {
console.log('刷新脚本列表')
}
})
</script>
<style scoped lang="scss">
.script-manager {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
}
</style>

View File

@@ -5,7 +5,7 @@
<h2>{{ isEdit ? '编辑世界书' : '创建世界书' }}</h2>
<div class="actions">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="worldInfoStore.loading" @click="handleSave">
<el-button type="primary" :loading="saving" @click="handleSave">
保存
</el-button>
</div>
@@ -91,24 +91,50 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, inject } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, QuestionFilled } from '@element-plus/icons-vue'
import { useWorldInfoStore } from '@/stores/worldInfo'
import * as worldInfoApi from '@/api/worldInfo'
import type { CreateWorldBookRequest, WorldInfoEntry } from '@/types/worldInfo'
import { v4 as uuidv4 } from 'uuid'
import WorldInfoEntryForm from './WorldInfoEntryForm.vue'
// Props用于抽屉模式
const props = defineProps<{
bookId?: number
mode?: 'create' | 'edit'
}>()
const route = useRoute()
const router = useRouter()
const worldInfoStore = useWorldInfoStore()
// 抽屉导航方法
const drawerBack = inject<any>('drawerBack', null)
// 表单引用
const formRef = ref()
// 是否为编辑模式
const isEdit = computed(() => !!route.params.id)
// 本地加载状态
const saving = ref(false)
// 是否为编辑模式支持props和route两种方式
const isEdit = computed(() => {
if (props.mode) {
return props.mode === 'edit'
}
return !!route.params.id
})
// 获取世界书ID支持props和route两种方式
const bookId = computed(() => {
if (props.bookId) {
return props.bookId
}
return route.params.id ? Number(route.params.id) : undefined
})
// 展开的条目
const activeEntries = ref<string[]>([])
@@ -193,44 +219,80 @@ const handleDeleteEntry = async (index: number) => {
// 保存
const handleSave = async () => {
if (saving.value) {
console.log('正在保存中,忽略重复点击')
return
}
try {
saving.value = true
console.log('开始保存世界书...')
console.log('isEdit:', isEdit.value)
console.log('bookId:', bookId.value)
console.log('formData:', formData.value)
// 验证表单
await formRef.value.validate()
console.log('表单验证通过')
// 验证至少有一个条目
if (formData.value.entries.length === 0) {
ElMessage.warning('请至少添加一个条目')
return
}
console.log('条目数量验证通过:', formData.value.entries.length)
// 保存
if (isEdit.value) {
await worldInfoStore.updateWorldBook(Number(route.params.id), {
// 保存 - 直接调用API而不是通过store避免store自动刷新列表
if (isEdit.value && bookId.value) {
console.log('执行更新操作...')
const response = await worldInfoApi.updateWorldBook(bookId.value, {
bookName: formData.value.bookName,
isGlobal: formData.value.isGlobal,
entries: formData.value.entries,
linkedChars: formData.value.linkedChars
})
console.log('更新成功', response)
ElMessage.success('更新成功')
} else {
await worldInfoStore.createWorldBook(formData.value)
console.log('执行创建操作...')
const response = await worldInfoApi.createWorldBook(formData.value)
console.log('创建成功', response)
ElMessage.success('创建成功')
}
// 保存成功后返回
console.log('准备返回...')
if (drawerBack) {
console.log('使用抽屉返回')
drawerBack()
} else {
console.log('使用路由返回')
router.push('/worldbook')
} catch (error) {
}
} catch (error: any) {
console.error('保存失败:', error)
ElMessage.error(error.response?.data?.msg || '保存失败')
} finally {
saving.value = false
}
}
// 取消
const handleCancel = () => {
// 如果在抽屉模式下,使用抽屉导航返回
if (drawerBack) {
drawerBack()
} else {
// 否则使用路由返回
router.back()
}
}
// 初始化
onMounted(async () => {
if (isEdit.value) {
if (isEdit.value && bookId.value) {
try {
const book = await worldInfoStore.fetchWorldBookDetail(Number(route.params.id))
const book = await worldInfoStore.fetchWorldBookDetail(bookId.value)
formData.value = {
bookName: book.bookName,
isGlobal: book.isGlobal,
@@ -239,9 +301,13 @@ onMounted(async () => {
}
} catch (error) {
ElMessage.error('加载世界书失败')
if (drawerBack) {
drawerBack()
} else {
router.push('/worldbook')
}
}
}
})
</script>

View File

@@ -0,0 +1,293 @@
<template>
<div class="world-book-list-drawer">
<!-- 操作按钮 -->
<div class="actions-bar">
<el-upload
:show-file-list="false"
:before-upload="handleImport"
accept=".json"
>
<el-button type="success" :icon="Upload">导入世界书</el-button>
</el-upload>
<el-button type="primary" :icon="Plus" @click="handleCreate">
创建世界书
</el-button>
</div>
<!-- 搜索栏 -->
<el-card class="search-card">
<el-form :inline="true" :model="searchForm" @submit.prevent="handleSearch">
<el-form-item label="世界书名称">
<el-input
v-model="searchForm.bookName"
placeholder="搜索世界书"
clearable
@clear="handleSearch"
/>
</el-form-item>
<el-form-item label="类型">
<el-select
v-model="searchForm.isGlobal"
placeholder="全部"
clearable
@change="handleSearch"
>
<el-option label="全局" :value="true" />
<el-option label="非全局" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 世界书列表 -->
<el-card class="list-card">
<el-table
v-loading="loading"
:data="worldBooks"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="bookName" label="世界书名称" min-width="200" />
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.isGlobal ? 'success' : 'info'">
{{ row.isGlobal ? '全局' : '非全局' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="条目数" width="100">
<template #default="{ row }">
{{ row.entryCount || 0 }}
</template>
</el-table-column>
<el-table-column label="关联角色" width="120">
<template #default="{ row }">
{{ row.linkedChars?.length || 0 }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDate(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="success"
size="small"
@click="handleDuplicate(row)"
>
复制
</el-button>
<el-button
type="info"
size="small"
@click="handleExport(row)"
>
导出
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="searchForm.page"
v-model:page-size="searchForm.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, inject } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Upload } from '@element-plus/icons-vue'
import * as worldInfoApi from '@/api/worldInfo'
import type { WorldBook } from '@/types/worldInfo'
import { defineAsyncComponent } from 'vue'
const drawerNavigate = inject<any>('drawerNavigate')
const loading = ref(false)
const worldBooks = ref<WorldBook[]>([])
const total = ref(0)
const searchForm = ref({
page: 1,
pageSize: 20,
bookName: '',
isGlobal: undefined as boolean | undefined,
})
// 加载世界书列表
async function loadWorldBooks() {
loading.value = true
try {
const res = await worldInfoApi.getWorldBookList(searchForm.value) as any
worldBooks.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (error) {
ElMessage.error('加载世界书列表失败')
} finally {
loading.value = false
}
}
// 暴露刷新方法给父组件
defineExpose({
refresh: loadWorldBooks
})
// 搜索
function handleSearch() {
searchForm.value.page = 1
loadWorldBooks()
}
// 重置
function handleReset() {
searchForm.value = {
page: 1,
pageSize: 20,
bookName: '',
isGlobal: undefined,
}
loadWorldBooks()
}
// 创建世界书
function handleCreate() {
const WorldBookEdit = defineAsyncComponent(() => import('./WorldBookEdit.vue'))
drawerNavigate({
component: WorldBookEdit,
props: { mode: 'create' },
title: '创建世界书',
})
}
// 编辑世界书
function handleEdit(row: WorldBook) {
const WorldBookEdit = defineAsyncComponent(() => import('./WorldBookEdit.vue'))
drawerNavigate({
component: WorldBookEdit,
props: { bookId: row.id, mode: 'edit' },
title: `编辑世界书 - ${row.bookName}`,
})
}
// 复制世界书
async function handleDuplicate(row: WorldBook) {
try {
await ElMessageBox.confirm(
`确定要复制世界书"${row.bookName}"吗?`,
'提示',
{ type: 'warning' }
)
await worldInfoApi.duplicateWorldBook(row.id)
ElMessage.success('复制成功')
loadWorldBooks()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('复制失败')
}
}
}
// 导出世界书
async function handleExport(row: WorldBook) {
try {
await worldInfoApi.downloadWorldBookJSON(row.id)
ElMessage.success('导出成功')
} catch (error) {
ElMessage.error('导出失败')
}
}
// 删除世界书
async function handleDelete(row: WorldBook) {
try {
await ElMessageBox.confirm(
`确定要删除世界书"${row.bookName}"吗?此操作不可恢复!`,
'警告',
{ type: 'warning' }
)
await worldInfoApi.deleteWorldBook(row.id)
ElMessage.success('删除成功')
loadWorldBooks()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
// 导入世界书
async function handleImport(file: File) {
try {
await worldInfoApi.importWorldBook(file)
ElMessage.success('导入成功')
loadWorldBooks()
} catch (error) {
ElMessage.error('导入失败')
}
return false
}
// 格式化日期
function formatDate(date: string) {
return new Date(date).toLocaleString('zh-CN')
}
onMounted(() => {
loadWorldBooks()
})
</script>
<style scoped lang="scss">
.world-book-list-drawer {
.actions-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.search-card {
margin-bottom: 16px;
}
.list-card {
.pagination {
margin-top: 16px;
display: flex;
justify-content: center;
}
}
}
</style>

664
word_info/word1.json Normal file

File diff suppressed because one or more lines are too long