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
+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 = {