feat(admin): 添加提示词批量删除和定时同步功能

- 后端新增批量删除提示词接口和实现
- 前端管理页面添加多选和批量删除功能
- 添加定时同步GitHub远程提示词源功能
- 新增系统设置中的提示词同步配置选项
- 更新文档说明新的批量操作和定时同步功能
- 添加相关的测试用例和依赖库
This commit is contained in:
HouYunFei
2026-05-22 16:01:00 +08:00
parent 3e82a1c8b3
commit 03396a9d18
18 changed files with 207 additions and 15 deletions
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
+ [新增] 管理后台提示词管理支持多选批量删除。
+ [新增] 新增定义拉取GitHub提示词源功能。
## v0.0.6 - 2026-05-22
+ [新增] 管理后台支持配置模型渠道,前端当前无需鉴权即可直接使用后端渠道能力。
+ [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。
+ [优化] 后端模型代理路径调整为 OpenAI 风格。
+8
View File
@@ -129,6 +129,7 @@
| 字段 | 类型 | 说明 |
|------------|----------|----------|
| `channels` | object[] | 模型渠道配置列表 |
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
`channels` 每项字段:
@@ -143,6 +144,13 @@
| `enabled` | bool | 是否启用 |
| `remark` | string | 备注 |
`promptSync` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 是否开启定时同步 |
| `cron` | string | Cron 表达式,默认每天 03:00 |
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
### dicts
+2
View File
@@ -1,3 +1,5 @@
# 待测试
- 管理后台 `/admin/prompts` 新增提示词批量删除,需要确认多选、确认弹窗、删除后列表刷新和筛选条件下删除行为。
- 管理后台 `/admin/settings` 的私有配置新增提示词定时同步开关和 Cron 表达式;开启后后端会按配置同步内置 GitHub 远程提示词源,需要确认保存配置和到点同步行为。
- 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。
+13 -1
View File
@@ -59,13 +59,18 @@
"enabled": true,
"remark": ""
}
]
],
"promptSync": {
"enabled": false,
"cron": "0 3 * * *"
}
}
```
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `channels` | object[] | 模型渠道列表 |
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
`channels` 每项字段:
@@ -81,3 +86,10 @@
| `remark` | string | 备注 |
后端调用模型时,会从已启用、已配置 `baseUrl``apiKey`、且 `models` 包含目标模型的渠道中选择一个。
`promptSync` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | boolean | 是否开启定时同步 |
| `cron` | string | Cron 表达式,默认每天 03:00 |
+1
View File
@@ -8,6 +8,7 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.48.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
+2
View File
@@ -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-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+14
View File
@@ -13,6 +13,10 @@ type adminSyncRequest struct {
Category string `json:"category"`
}
type adminBatchDeleteRequest struct {
IDs []string `json:"ids"`
}
func AdminPromptCategories(w http.ResponseWriter, r *http.Request) {
OK(w, service.ListPromptCategories())
}
@@ -45,6 +49,16 @@ func AdminDeletePrompt(w http.ResponseWriter, r *http.Request, id string) {
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) {
var request adminSyncRequest
_ = json.NewDecoder(r.Body).Decode(&request)
+1
View File
@@ -15,5 +15,6 @@ func main() {
if err := service.EnsureDefaultAdmin(); err != nil {
log.Fatal(err)
}
service.StartPromptSyncScheduler()
log.Fatal(router.New().Run(":" + config.Cfg.Port))
}
+8 -1
View File
@@ -38,7 +38,14 @@ type PublicSetting struct {
// PrivateSetting 私有配置。
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 系统配置。
+9
View File
@@ -99,6 +99,15 @@ func DeletePrompt(id string) 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 用远程同步结果替换整个提示词分类。
func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error {
db, err := DB()
+1
View File
@@ -41,6 +41,7 @@ func New() *gin.Engine {
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt))
admin.POST("/prompts/batch-delete", gin.WrapF(handler.AdminDeletePrompts))
admin.DELETE("/prompts/:id", func(c *gin.Context) {
handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id"))
})
+70
View File
@@ -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
}
+7
View File
@@ -48,6 +48,13 @@ func DeletePrompt(id string) error {
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 {
codes := []string{}
for _, item := range items {
+4
View File
@@ -32,6 +32,9 @@ func SaveSettings(settings model.Settings) (model.Settings, error) {
settings = normalizeSettings(settings)
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
result, err := repository.SaveSettings(settings, now())
if err == nil {
RefreshPromptSyncScheduler()
}
return hidePrivateAPIKeys(result), err
}
@@ -72,6 +75,7 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
if setting.Channels == nil {
setting.Channels = []model.ModelChannel{}
}
setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync)
for i := range setting.Channels {
if setting.Channels[i].Protocol == "" {
setting.Channels[i].Protocol = "openai"
+19 -2
View File
@@ -10,12 +10,14 @@ import type { Prompt } from "@/services/api/prompts";
import { useAdminPrompts } from "./use-admin-prompts";
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 [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
const [detailPrompt, setDetailPrompt] = 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 defaultCategory = categories[0]?.category || "";
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
@@ -32,6 +34,12 @@ export default function AdminPromptsPage() {
setEditingPrompt(null);
};
const batchDeletePrompts = async () => {
await deletePrompts(selectedPromptIds);
setSelectedPromptIds([]);
setIsBatchDeleteOpen(false);
};
const columns: ProColumns<Prompt>[] = [
{
title: "封面",
@@ -96,7 +104,12 @@ export default function AdminPromptsPage() {
cardProps={{ variant: "borderless" }}
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>}
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) }}
/>
</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="取消">
{deletingPrompt?.title}
</Modal>
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{selectedPromptIds.length}
</Modal>
</main>
);
}
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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 { 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(() => {
const error = categoriesQuery.error || promptsQuery.error;
if (!error) return;
@@ -101,7 +113,7 @@ export function useAdminPrompts() {
page,
pageSize,
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,
syncCategory: (category: string) => syncMutation.mutateAsync(category),
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
@@ -116,5 +128,6 @@ export function useAdminPrompts() {
},
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
};
}
+20 -9
View File
@@ -35,7 +35,7 @@ const emptySettings: AdminSettings = {
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: "" };
@@ -62,6 +62,7 @@ export default function AdminSettingsPage() {
const [isSaving, setIsSaving] = useState(false);
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
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 activeMode = editorMode[activeTab];
const activeJsonText = jsonText[activeTab];
@@ -321,13 +322,19 @@ export default function AdminSettingsPage() {
<Alert
showIcon
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>
<Table
rowKey={(_, index) => String(index)}
rowKey="_rowKey"
pagination={false}
dataSource={channels}
dataSource={channelTableData}
columns={[
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
@@ -339,13 +346,13 @@ export default function AdminSettingsPage() {
key: "actions",
width: 220,
align: "right",
render: (_, __, index) => (
render: (_, item) => (
<Space size={4}>
<Button size="small" onClick={() => openTestDialog(index)}></Button>
<Button size="small" onClick={() => openChannelDrawer(index)}></Button>
<Button size="small" onClick={() => openTestDialog(item._index)}></Button>
<Button size="small" onClick={() => openChannelDrawer(item._index)}></Button>
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => {
const nextChannels = [...channels];
nextChannels.splice(index, 1);
nextChannels.splice(item._index, 1);
void persistChannels(nextChannels);
}} />
</Space>
@@ -470,6 +477,10 @@ function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}):
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
return {
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 {
public: saved.public,
private: { channels },
private: { ...saved.private, channels },
};
}
+8
View File
@@ -58,6 +58,10 @@ export async function deleteAdminPrompt(token: string, id: string) {
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 = {
keyword?: string;
type?: string;
@@ -108,6 +112,10 @@ export type AdminPublicSettings = {
export type AdminPrivateSettings = {
channels: AdminModelChannel[];
promptSync: {
enabled: boolean;
cron: string;
};
};
export type AdminSettings = {