🎨 优化前端菜单栏(修改为抽屉模式)&& 新增ai预设功能 && 优化ai对话前端渲染
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -179,4 +179,5 @@ uploads/
|
||||
|
||||
# SillyTavern 核心脚本和扩展文件(从 web-app 一次性复制,不提交到 Git)
|
||||
server/data/st-core-scripts/
|
||||
server/data/extensions/
|
||||
server/data/extensions/
|
||||
.vite
|
||||
256
server/api/v1/app/ai_preset.go
Normal file
256
server/api/v1/app/ai_preset.go
Normal 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)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ type ApiGroup struct {
|
||||
RegexScriptApi
|
||||
ProviderApi
|
||||
ChatApi
|
||||
AIPresetApi
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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/*
|
||||
}
|
||||
|
||||
//插件路由安装
|
||||
|
||||
24
server/model/app/request/ai_preset.go
Normal file
24
server/model/app/request/ai_preset.go
Normal 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"`
|
||||
}
|
||||
42
server/model/app/response/ai_preset.go
Normal file
42
server/model/app/response/ai_preset.go
Normal 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,
|
||||
}
|
||||
}
|
||||
23
server/router/app/ai_preset.go
Normal file
23
server/router/app/ai_preset.go
Normal 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) // 设置默认预设
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ type RouterGroup struct {
|
||||
RegexScriptRouter
|
||||
ProviderRouter
|
||||
ChatRouter
|
||||
AIPresetRouter
|
||||
}
|
||||
|
||||
198
server/service/app/ai_preset.go
Normal file
198
server/service/app/ai_preset.go
Normal 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
|
||||
}
|
||||
@@ -7,4 +7,5 @@ type AppServiceGroup struct {
|
||||
RegexScriptService
|
||||
ProviderService
|
||||
ChatService
|
||||
AIPresetService
|
||||
}
|
||||
|
||||
17
web-app-vue/package-lock.json
generated
17
web-app-vue/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
74
web-app-vue/src/api/preset.ts
Normal file
74
web-app-vue/src/api/preset.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
8
web-app-vue/src/components.d.ts
vendored
8
web-app-vue/src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
152
web-app-vue/src/components/DrawerContentWrapper.vue
Normal file
152
web-app-vue/src/components/DrawerContentWrapper.vue
Normal 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>
|
||||
156
web-app-vue/src/components/MessageRenderer.vue
Normal file
156
web-app-vue/src/components/MessageRenderer.vue
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// 处理换行
|
||||
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>
|
||||
166
web-app-vue/src/components/VariableNode.vue
Normal file
166
web-app-vue/src/components/VariableNode.vue
Normal 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>
|
||||
110
web-app-vue/src/components/VariableViewer.vue
Normal file
110
web-app-vue/src/components/VariableViewer.vue
Normal 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>
|
||||
57
web-app-vue/src/components/preset/GenericPresetForm.vue
Normal file
57
web-app-vue/src/components/preset/GenericPresetForm.vue
Normal 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>
|
||||
326
web-app-vue/src/components/preset/OpenAIPresetForm.vue
Normal file
326
web-app-vue/src/components/preset/OpenAIPresetForm.vue
Normal 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>
|
||||
@@ -4,42 +4,85 @@
|
||||
<el-header class="layout-header">
|
||||
<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"
|
||||
>
|
||||
<el-menu-item index="/">
|
||||
|
||||
<!-- 主导航菜单 - 改为按钮触发抽屉 -->
|
||||
<div class="header-menu">
|
||||
<el-button
|
||||
text
|
||||
:class="{ active: activeMenu === '/' }"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>角色广场</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="authStore.isLoggedIn" index="/my-characters">
|
||||
<el-icon><Files /></el-icon>
|
||||
<span>我的角色卡</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="authStore.isLoggedIn" index="/worldbook">
|
||||
<el-icon><Reading /></el-icon>
|
||||
<span>世界书</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="authStore.isLoggedIn" index="/regex">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span>正则脚本</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="authStore.isLoggedIn" index="/chats">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>对话</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="authStore.isLoggedIn" index="/ai-config">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>AI 配置</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</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-button>
|
||||
|
||||
<el-button
|
||||
text
|
||||
:class="{ active: activeMenu === '/worldbook' }"
|
||||
@click="openDrawer('worldbook')"
|
||||
>
|
||||
<el-icon><Reading /></el-icon>
|
||||
<span>世界书</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
text
|
||||
:class="{ active: activeMenu === '/regex' }"
|
||||
@click="openDrawer('regex')"
|
||||
>
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span>正则脚本</span>
|
||||
</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-button>
|
||||
|
||||
<el-button
|
||||
text
|
||||
:class="{ active: activeMenu === '/ai-config' }"
|
||||
@click="openDrawer('ai-config')"
|
||||
>
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>AI 配置</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 未登录状态 -->
|
||||
<div v-if="!authStore.isLoggedIn" class="auth-buttons">
|
||||
@@ -50,7 +93,7 @@
|
||||
注册
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 已登录状态 -->
|
||||
<el-dropdown v-else @command="handleCommand">
|
||||
<div class="user-info">
|
||||
@@ -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
41
web-app-vue/src/types/preset.d.ts
vendored
Normal 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' }
|
||||
]
|
||||
@@ -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,16 +327,39 @@ 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 ''
|
||||
return text
|
||||
// 仅做最基本的 & 转义,保留 HTML 标签以支持角色卡/正则脚本输出的富文本
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>')
|
||||
|
||||
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
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
340
web-app-vue/src/views/preset/PresetEdit.vue
Normal file
340
web-app-vue/src/views/preset/PresetEdit.vue
Normal 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>
|
||||
304
web-app-vue/src/views/preset/PresetList.vue
Normal file
304
web-app-vue/src/views/preset/PresetList.vue
Normal 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>
|
||||
38
web-app-vue/src/views/script/ScriptManager.vue
Normal file
38
web-app-vue/src/views/script/ScriptManager.vue
Normal 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>
|
||||
@@ -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('创建成功')
|
||||
}
|
||||
|
||||
router.push('/worldbook')
|
||||
} catch (error) {
|
||||
// 保存成功后返回
|
||||
console.log('准备返回...')
|
||||
if (drawerBack) {
|
||||
console.log('使用抽屉返回')
|
||||
drawerBack()
|
||||
} else {
|
||||
console.log('使用路由返回')
|
||||
router.push('/worldbook')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
router.back()
|
||||
// 如果在抽屉模式下,使用抽屉导航返回
|
||||
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,7 +301,11 @@ onMounted(async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载世界书失败')
|
||||
router.push('/worldbook')
|
||||
if (drawerBack) {
|
||||
drawerBack()
|
||||
} else {
|
||||
router.push('/worldbook')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
293
web-app-vue/src/views/worldbook/WorldBookListDrawer.vue
Normal file
293
web-app-vue/src/views/worldbook/WorldBookListDrawer.vue
Normal 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
664
word_info/word1.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user