feat(admin): 添加提示词批量删除和定时同步功能
- 后端新增批量删除提示词接口和实现 - 前端管理页面添加多选和批量删除功能 - 添加定时同步GitHub远程提示词源功能 - 新增系统设置中的提示词同步配置选项 - 更新文档说明新的批量操作和定时同步功能 - 添加相关的测试用例和依赖库
This commit is contained in:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [新增] 管理后台提示词管理支持多选批量删除。
|
||||||
|
+ [新增] 新增定义拉取GitHub提示词源功能。
|
||||||
|
|
||||||
|
## v0.0.6 - 2026-05-22
|
||||||
|
|
||||||
+ [新增] 管理后台支持配置模型渠道,前端当前无需鉴权即可直接使用后端渠道能力。
|
+ [新增] 管理后台支持配置模型渠道,前端当前无需鉴权即可直接使用后端渠道能力。
|
||||||
+ [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。
|
+ [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。
|
||||||
+ [优化] 后端模型代理路径调整为 OpenAI 风格。
|
+ [优化] 后端模型代理路径调整为 OpenAI 风格。
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------------|----------|----------|
|
|------------|----------|----------|
|
||||||
| `channels` | object[] | 模型渠道配置列表 |
|
| `channels` | object[] | 模型渠道配置列表 |
|
||||||
|
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
|
||||||
|
|
||||||
`channels` 每项字段:
|
`channels` 每项字段:
|
||||||
|
|
||||||
@@ -143,6 +144,13 @@
|
|||||||
| `enabled` | bool | 是否启用 |
|
| `enabled` | bool | 是否启用 |
|
||||||
| `remark` | string | 备注 |
|
| `remark` | string | 备注 |
|
||||||
|
|
||||||
|
`promptSync` 字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | bool | 是否开启定时同步 |
|
||||||
|
| `cron` | string | Cron 表达式,默认每天 03:00 |
|
||||||
|
|
||||||
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
|
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
|
||||||
|
|
||||||
### dicts
|
### dicts
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
|
- 管理后台 `/admin/prompts` 新增提示词批量删除,需要确认多选、确认弹窗、删除后列表刷新和筛选条件下删除行为。
|
||||||
|
- 管理后台 `/admin/settings` 的私有配置新增提示词定时同步开关和 Cron 表达式;开启后后端会按配置同步内置 GitHub 远程提示词源,需要确认保存配置和到点同步行为。
|
||||||
- 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。
|
- 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。
|
||||||
|
|||||||
+13
-1
@@ -59,13 +59,18 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"remark": ""
|
"remark": ""
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"promptSync": {
|
||||||
|
"enabled": false,
|
||||||
|
"cron": "0 3 * * *"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `channels` | object[] | 模型渠道列表 |
|
| `channels` | object[] | 模型渠道列表 |
|
||||||
|
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
|
||||||
|
|
||||||
`channels` 每项字段:
|
`channels` 每项字段:
|
||||||
|
|
||||||
@@ -81,3 +86,10 @@
|
|||||||
| `remark` | string | 备注 |
|
| `remark` | string | 备注 |
|
||||||
|
|
||||||
后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。
|
后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。
|
||||||
|
|
||||||
|
`promptSync` 字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | boolean | 是否开启定时同步 |
|
||||||
|
| `cron` | string | Cron 表达式,默认每天 03:00 |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/mysql v1.6.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type adminSyncRequest struct {
|
|||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminBatchDeleteRequest struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
func AdminPromptCategories(w http.ResponseWriter, r *http.Request) {
|
func AdminPromptCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
OK(w, service.ListPromptCategories())
|
OK(w, service.ListPromptCategories())
|
||||||
}
|
}
|
||||||
@@ -45,6 +49,16 @@ func AdminDeletePrompt(w http.ResponseWriter, r *http.Request, id string) {
|
|||||||
OK(w, true)
|
OK(w, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AdminDeletePrompts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request adminBatchDeleteRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||||
|
if err := service.DeletePrompts(request.IDs); err != nil {
|
||||||
|
FailError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OK(w, true)
|
||||||
|
}
|
||||||
|
|
||||||
func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) {
|
func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
var request adminSyncRequest
|
var request adminSyncRequest
|
||||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ func main() {
|
|||||||
if err := service.EnsureDefaultAdmin(); err != nil {
|
if err := service.EnsureDefaultAdmin(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
service.StartPromptSyncScheduler()
|
||||||
log.Fatal(router.New().Run(":" + config.Cfg.Port))
|
log.Fatal(router.New().Run(":" + config.Cfg.Port))
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -38,7 +38,14 @@ type PublicSetting struct {
|
|||||||
|
|
||||||
// PrivateSetting 私有配置。
|
// PrivateSetting 私有配置。
|
||||||
type PrivateSetting struct {
|
type PrivateSetting struct {
|
||||||
Channels []ModelChannel `json:"channels"`
|
Channels []ModelChannel `json:"channels"`
|
||||||
|
PromptSync PromptSyncSetting `json:"promptSync"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptSyncSetting 提示词定时同步配置。
|
||||||
|
type PromptSyncSetting struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Cron string `json:"cron"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setting 系统配置。
|
// Setting 系统配置。
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ func DeletePrompt(id string) error {
|
|||||||
return db.Delete(&model.Prompt{}, "id = ?", id).Error
|
return db.Delete(&model.Prompt{}, "id = ?", id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletePrompts 批量删除提示词。
|
||||||
|
func DeletePrompts(ids []string) error {
|
||||||
|
db, err := DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.Delete(&model.Prompt{}, "id IN ?", ids).Error
|
||||||
|
}
|
||||||
|
|
||||||
// ReplacePromptCategory 用远程同步结果替换整个提示词分类。
|
// ReplacePromptCategory 用远程同步结果替换整个提示词分类。
|
||||||
func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error {
|
func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error {
|
||||||
db, err := DB()
|
db, err := DB()
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func New() *gin.Engine {
|
|||||||
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
|
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
|
||||||
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
|
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
|
||||||
admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt))
|
admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt))
|
||||||
|
admin.POST("/prompts/batch-delete", gin.WrapF(handler.AdminDeletePrompts))
|
||||||
admin.DELETE("/prompts/:id", func(c *gin.Context) {
|
admin.DELETE("/prompts/:id", func(c *gin.Context) {
|
||||||
handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id"))
|
handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id"))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/basketikun/infinite-canvas/model"
|
||||||
|
"github.com/basketikun/infinite-canvas/repository"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultPromptSyncCron = "0 3 * * *"
|
||||||
|
|
||||||
|
var (
|
||||||
|
promptSyncCron *cron.Cron
|
||||||
|
promptSyncOnce sync.Once
|
||||||
|
promptSyncMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartPromptSyncScheduler() {
|
||||||
|
promptSyncOnce.Do(func() {
|
||||||
|
promptSyncCron = cron.New()
|
||||||
|
promptSyncCron.Start()
|
||||||
|
})
|
||||||
|
RefreshPromptSyncScheduler()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshPromptSyncScheduler() {
|
||||||
|
promptSyncMu.Lock()
|
||||||
|
defer promptSyncMu.Unlock()
|
||||||
|
if promptSyncCron == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range promptSyncCron.Entries() {
|
||||||
|
promptSyncCron.Remove(entry.ID)
|
||||||
|
}
|
||||||
|
settings, err := repository.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("load prompt sync setting failed err=%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setting := normalizePromptSyncSetting(settings.Private.PromptSync)
|
||||||
|
if !setting.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := promptSyncCron.AddFunc(setting.Cron, SyncRemotePromptCategories); err != nil {
|
||||||
|
log.Printf("add prompt sync cron failed cron=%s err=%v", setting.Cron, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SyncRemotePromptCategories() {
|
||||||
|
for _, category := range repository.PromptCategories() {
|
||||||
|
if !category.Remote {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("scheduled prompt sync start category=%s", category.Category)
|
||||||
|
if _, err := SyncPromptCategory(category.Category); err != nil {
|
||||||
|
log.Printf("scheduled prompt sync failed category=%s err=%v", category.Category, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("scheduled prompt sync done category=%s", category.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePromptSyncSetting(setting model.PromptSyncSetting) model.PromptSyncSetting {
|
||||||
|
if setting.Cron == "" {
|
||||||
|
setting.Cron = defaultPromptSyncCron
|
||||||
|
}
|
||||||
|
return setting
|
||||||
|
}
|
||||||
@@ -48,6 +48,13 @@ func DeletePrompt(id string) error {
|
|||||||
return repository.DeletePrompt(id)
|
return repository.DeletePrompt(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeletePrompts(ids []string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return repository.DeletePrompts(ids)
|
||||||
|
}
|
||||||
|
|
||||||
func promptCategoryCodes(items []model.PromptCategory) []string {
|
func promptCategoryCodes(items []model.PromptCategory) []string {
|
||||||
codes := []string{}
|
codes := []string{}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ func SaveSettings(settings model.Settings) (model.Settings, error) {
|
|||||||
settings = normalizeSettings(settings)
|
settings = normalizeSettings(settings)
|
||||||
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
|
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
|
||||||
result, err := repository.SaveSettings(settings, now())
|
result, err := repository.SaveSettings(settings, now())
|
||||||
|
if err == nil {
|
||||||
|
RefreshPromptSyncScheduler()
|
||||||
|
}
|
||||||
return hidePrivateAPIKeys(result), err
|
return hidePrivateAPIKeys(result), err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +75,7 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
|
|||||||
if setting.Channels == nil {
|
if setting.Channels == nil {
|
||||||
setting.Channels = []model.ModelChannel{}
|
setting.Channels = []model.ModelChannel{}
|
||||||
}
|
}
|
||||||
|
setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync)
|
||||||
for i := range setting.Channels {
|
for i := range setting.Channels {
|
||||||
if setting.Channels[i].Protocol == "" {
|
if setting.Channels[i].Protocol == "" {
|
||||||
setting.Channels[i].Protocol = "openai"
|
setting.Channels[i].Protocol = "openai"
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import type { Prompt } from "@/services/api/prompts";
|
|||||||
import { useAdminPrompts } from "./use-admin-prompts";
|
import { useAdminPrompts } from "./use-admin-prompts";
|
||||||
|
|
||||||
export default function AdminPromptsPage() {
|
export default function AdminPromptsPage() {
|
||||||
const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt } = useAdminPrompts();
|
const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt, deletePrompts } = useAdminPrompts();
|
||||||
const copyText = useCopyText();
|
const copyText = useCopyText();
|
||||||
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
|
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
|
||||||
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
|
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
|
||||||
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
|
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
|
||||||
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null);
|
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null);
|
||||||
|
const [selectedPromptIds, setSelectedPromptIds] = useState<string[]>([]);
|
||||||
|
const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false);
|
||||||
const [isSyncOpen, setIsSyncOpen] = useState(false);
|
const [isSyncOpen, setIsSyncOpen] = useState(false);
|
||||||
const defaultCategory = categories[0]?.category || "";
|
const defaultCategory = categories[0]?.category || "";
|
||||||
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
|
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
|
||||||
@@ -32,6 +34,12 @@ export default function AdminPromptsPage() {
|
|||||||
setEditingPrompt(null);
|
setEditingPrompt(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const batchDeletePrompts = async () => {
|
||||||
|
await deletePrompts(selectedPromptIds);
|
||||||
|
setSelectedPromptIds([]);
|
||||||
|
setIsBatchDeleteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ProColumns<Prompt>[] = [
|
const columns: ProColumns<Prompt>[] = [
|
||||||
{
|
{
|
||||||
title: "封面",
|
title: "封面",
|
||||||
@@ -96,7 +104,12 @@ export default function AdminPromptsPage() {
|
|||||||
cardProps={{ variant: "borderless" }}
|
cardProps={{ variant: "borderless" }}
|
||||||
headerTitle={<Space><Typography.Text strong>提示词列表</Typography.Text><Tag>{total} 条</Tag></Space>}
|
headerTitle={<Space><Typography.Text strong>提示词列表</Typography.Text><Tag>{total} 条</Tag></Space>}
|
||||||
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
|
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
|
||||||
toolBarRender={() => [<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>同步</Button>, <Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>新增</Button>]}
|
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>批量删除{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}</Button>,
|
||||||
|
<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>同步</Button>,
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>新增</Button>,
|
||||||
|
]}
|
||||||
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
|
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -146,6 +159,10 @@ export default function AdminPromptsPage() {
|
|||||||
<Modal title="删除提示词" open={Boolean(deletingPrompt)} onCancel={() => setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
<Modal title="删除提示词" open={Boolean(deletingPrompt)} onCancel={() => setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||||
确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。
|
确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||||
|
确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。
|
||||||
|
</Modal>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { App } from "antd";
|
import { App } from "antd";
|
||||||
|
|
||||||
import { deleteAdminPrompt, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
|
import { deleteAdminPrompt, deleteAdminPrompts, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
|
||||||
import type { Prompt } from "@/services/api/prompts";
|
import type { Prompt } from "@/services/api/prompts";
|
||||||
import { useUserStore } from "@/stores/use-user-store";
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
|
|
||||||
@@ -71,6 +71,18 @@ export function useAdminPrompts() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const batchDeleteMutation = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||||
|
message.success("提示词已批量删除");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
message.error(error instanceof Error ? error.message : "批量删除失败");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const error = categoriesQuery.error || promptsQuery.error;
|
const error = categoriesQuery.error || promptsQuery.error;
|
||||||
if (!error) return;
|
if (!error) return;
|
||||||
@@ -101,7 +113,7 @@ export function useAdminPrompts() {
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
total: data?.total || 0,
|
total: data?.total || 0,
|
||||||
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending,
|
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending,
|
||||||
isSyncing: syncMutation.isPending,
|
isSyncing: syncMutation.isPending,
|
||||||
syncCategory: (category: string) => syncMutation.mutateAsync(category),
|
syncCategory: (category: string) => syncMutation.mutateAsync(category),
|
||||||
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
|
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
|
||||||
@@ -116,5 +128,6 @@ export function useAdminPrompts() {
|
|||||||
},
|
},
|
||||||
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
|
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
|
||||||
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
||||||
|
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const emptySettings: AdminSettings = {
|
|||||||
allowCustomChannel: true,
|
allowCustomChannel: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
private: { channels: [] },
|
private: { channels: [], promptSync: { enabled: false, cron: "0 3 * * *" } },
|
||||||
};
|
};
|
||||||
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
|
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ export default function AdminSettingsPage() {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
|
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
|
||||||
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
|
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
|
||||||
|
const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]);
|
||||||
const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]);
|
const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]);
|
||||||
const activeMode = editorMode[activeTab];
|
const activeMode = editorMode[activeTab];
|
||||||
const activeJsonText = jsonText[activeTab];
|
const activeJsonText = jsonText[activeTab];
|
||||||
@@ -321,13 +322,19 @@ export default function AdminSettingsPage() {
|
|||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="warning"
|
type="warning"
|
||||||
message="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。"
|
title="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。"
|
||||||
/>
|
/>
|
||||||
|
<Card size="small" title="提示词定时同步">
|
||||||
|
<Row gutter={16} align="middle">
|
||||||
|
<Col xs={24} md={8}><Form.Item name={["private", "promptSync", "enabled"]} label="开启定时同步" valuePropName="checked"><Switch /></Form.Item></Col>
|
||||||
|
<Col xs={24} md={16}><Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每天 03:00 同步内置 GitHub 远程提示词源"><Input placeholder="0 3 * * *" /></Form.Item></Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>新增渠道</Button>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>新增渠道</Button>
|
||||||
<Table
|
<Table
|
||||||
rowKey={(_, index) => String(index)}
|
rowKey="_rowKey"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={channels}
|
dataSource={channelTableData}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
|
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
|
||||||
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
|
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
|
||||||
@@ -339,13 +346,13 @@ export default function AdminSettingsPage() {
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
width: 220,
|
width: 220,
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (_, __, index) => (
|
render: (_, item) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Button size="small" onClick={() => openTestDialog(index)}>测试</Button>
|
<Button size="small" onClick={() => openTestDialog(item._index)}>测试</Button>
|
||||||
<Button size="small" onClick={() => openChannelDrawer(index)}>编辑</Button>
|
<Button size="small" onClick={() => openChannelDrawer(item._index)}>编辑</Button>
|
||||||
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => {
|
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => {
|
||||||
const nextChannels = [...channels];
|
const nextChannels = [...channels];
|
||||||
nextChannels.splice(index, 1);
|
nextChannels.splice(item._index, 1);
|
||||||
void persistChannels(nextChannels);
|
void persistChannels(nextChannels);
|
||||||
}} />
|
}} />
|
||||||
</Space>
|
</Space>
|
||||||
@@ -470,6 +477,10 @@ function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}):
|
|||||||
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
|
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
|
||||||
return {
|
return {
|
||||||
channels: (setting.channels || []).map(normalizeChannel),
|
channels: (setting.channels || []).map(normalizeChannel),
|
||||||
|
promptSync: {
|
||||||
|
enabled: setting.promptSync?.enabled === true,
|
||||||
|
cron: setting.promptSync?.cron || "0 3 * * *",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +504,7 @@ function mergeChannelApiKeys(currentChannels: AdminModelChannel[], saved: AdminS
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
public: saved.public,
|
public: saved.public,
|
||||||
private: { channels },
|
private: { ...saved.private, channels },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export async function deleteAdminPrompt(token: string, id: string) {
|
|||||||
return apiDelete<boolean>(`/api/admin/prompts/${encodeURIComponent(id)}`, token);
|
return apiDelete<boolean>(`/api/admin/prompts/${encodeURIComponent(id)}`, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminPrompts(token: string, ids: string[]) {
|
||||||
|
return apiPost<boolean>("/api/admin/prompts/batch-delete", { ids }, token);
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminAssetQuery = {
|
export type AdminAssetQuery = {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -108,6 +112,10 @@ export type AdminPublicSettings = {
|
|||||||
|
|
||||||
export type AdminPrivateSettings = {
|
export type AdminPrivateSettings = {
|
||||||
channels: AdminModelChannel[];
|
channels: AdminModelChannel[];
|
||||||
|
promptSync: {
|
||||||
|
enabled: boolean;
|
||||||
|
cron: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminSettings = {
|
export type AdminSettings = {
|
||||||
|
|||||||
Reference in New Issue
Block a user