first commit

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
HouYunFei
2026-05-19 07:53:53 +08:00
commit 472bf8b732
133 changed files with 16869 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import type { AdminAsset } from "@/services/api/admin";
import { useAdminAssets } from "../hooks/use-admin-assets";
type AssetFormValues = Partial<AdminAsset> & { tagText?: string };
const typeOptions = [
{ label: "全部类型", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
];
const editTypeOptions = typeOptions.slice(1);
export default function AdminAssetsPage() {
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
const { message } = App.useApp();
const [form] = Form.useForm<AssetFormValues>();
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
const formType = Form.useWatch("type", form) || editingAsset?.type || "text";
const tagOptions = tags.map((item) => ({ label: item, value: item }));
useEffect(() => {
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
}, [editingAsset, form]);
const copyValue = async (value: string) => {
await navigator.clipboard.writeText(value);
message.success("已复制");
};
const saveAsset = async () => {
const value = await form.validateFields();
const nextType = value.type || "text";
await saveAdminAsset({
...editingAsset,
...value,
type: nextType,
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean),
});
setEditingAsset(null);
};
const columns: ProColumns<AdminAsset>[] = [
{
title: "封面",
dataIndex: "coverUrl",
width: 88,
render: (_, item) => <Image src={item.coverUrl || item.url || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
},
{
title: "标题",
dataIndex: "title",
width: 260,
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>{item.title}</Typography.Link>,
},
{
title: "类型",
dataIndex: "type",
width: 84,
render: (_, item) => <Tag>{item.type === "image" ? "图片" : "文本"}</Tag>,
},
{
title: "标签",
dataIndex: "tags",
width: 180,
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
},
{
title: "分类",
dataIndex: "category",
width: 120,
render: (_, item) => <Typography.Text type="secondary">{item.category || "未标注"}</Typography.Text>,
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} /></Tooltip>
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} /></Tooltip>
</Space>
),
},
];
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} /></Form.Item></Col>
<Col flex="180px"><Form.Item label="类型"><Select value={kind} onChange={changeKind} options={typeOptions} /></Form.Item></Col>
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}></Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}></Button></Space></Form.Item></Col>
</Row>
</Form>
</Card>
<ProTable<AdminAsset>
rowKey="id"
columns={columns}
dataSource={assets}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>}
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
toolBarRender={() => [<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", 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>
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}><Select options={editTypeOptions} /></Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
<Form.Item name="category" label="分类"><Input /></Form.Item>
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item>
{formType === "image" ? <Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}><Input /></Form.Item> : <Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}><Input.TextArea rows={6} /></Form.Item>}
</Form>
</Modal>
<Modal title="素材详情" open={Boolean(detailAsset)} width={760} onCancel={() => setDetailAsset(null)} footer={<Button onClick={() => setDetailAsset(null)}></Button>}>
{detailAsset ? (
<Flex vertical gap={14}>
<Flex gap={14} align="start">
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{detailAsset.title}</Typography.Title>
<Space wrap><Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}{(detailAsset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
</Flex>
</Flex>
{detailAsset.description ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailAsset.description}</Typography.Paragraph> : null}
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
<Button icon={<CopyOutlined />} onClick={() => void copyValue(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}></Button>
</Flex>
) : null}
</Modal>
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={async () => { if (!deletingAsset) return; await deleteAsset(deletingAsset.id); setDeletingAsset(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>
</main>
);
}
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { deleteAdminAsset, fetchAdminAssets, saveAdminAsset, type AdminAsset } from "@/services/api/admin";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
export function useAdminAssets() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [type, setType] = useState("");
const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const query = useQuery({
queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize],
queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const saveMutation = useMutation({
mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(token, asset),
onSuccess: async (_, asset) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success(asset.id ? "素材已保存" : "素材已新增");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败");
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminAsset(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success("素材已删除");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "删除失败");
},
});
useEffect(() => {
if (query.isError) {
const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) {
clearSession();
}
}
}, [clearSession, message, query.error, query.isError]);
const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, type, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) {
queryState.page = 1;
}
setKeyword(queryState.keyword);
setType(queryState.type);
setTag(queryState.tag);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const refreshAssets = async () => {
await query.refetch();
};
const saveAsset = async (asset: Partial<AdminAsset>) => {
await saveMutation.mutateAsync(asset);
};
const deleteAsset = async (id: string) => {
await deleteMutation.mutateAsync(id);
};
const data = query.data;
const isLoading = query.isFetching || saveMutation.isPending || deleteMutation.isPending;
return {
assets: data?.items || [],
tags: data?.tags || [],
keyword,
kind: type,
tag,
page,
pageSize,
total: data?.total || 0,
isLoading,
searchAssets: (value = keyword) => updateFilters({ keyword: value }),
changeKind: (value: string) => updateFilters({ type: value, tag: [] }),
changeTag: (value: string[]) => updateFilters({ tag: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }),
refreshAssets,
saveAsset,
deleteAsset,
};
}
@@ -0,0 +1,137 @@
"use client";
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 type { Prompt } from "@/services/api/prompts";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
export function useAdminPrompts() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [category, setCategory] = useState("");
const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const categoriesQuery = useQuery({
queryKey: ["admin", "prompt-categories", token],
queryFn: () => fetchAdminPromptCategories(token),
enabled: Boolean(token),
retry: false,
});
const promptsQuery = useQuery({
queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize],
queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const syncMutation = useMutation({
mutationFn: (category: string) => syncAdminPromptCategory(token, category),
onSuccess: async (categories) => {
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories", token], categories);
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("远程提示词源已同步");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "同步失败");
},
});
const saveMutation = useMutation({
mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(token, prompt),
onSuccess: async (_, prompt) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success(prompt.id ? "提示词已保存" : "提示词已新增");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败");
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminPrompt(token, id),
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(() => {
if (categoriesQuery.isError) {
const errorMessage = categoriesQuery.error instanceof Error ? categoriesQuery.error.message : "读取提示词分类失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) {
clearSession();
}
}
}, [categoriesQuery.error, categoriesQuery.isError, clearSession, message]);
useEffect(() => {
if (promptsQuery.isError) {
const errorMessage = promptsQuery.error instanceof Error ? promptsQuery.error.message : "读取提示词失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) {
clearSession();
}
}
}, [clearSession, message, promptsQuery.error, promptsQuery.isError]);
const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, category, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) {
queryState.page = 1;
}
setKeyword(queryState.keyword);
setCategory(queryState.category);
setTag(queryState.tag);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const refreshPrompts = async () => {
await categoriesQuery.refetch();
await promptsQuery.refetch();
};
const data = promptsQuery.data;
const isLoading = categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending;
return {
categories: categoriesQuery.data || [],
prompts: data?.items || [],
tags: data?.tags || [],
keyword,
category,
tag,
page,
pageSize,
total: data?.total || 0,
isLoading,
isSyncing: syncMutation.isPending,
syncCategory: (category: string) => syncMutation.mutateAsync(category),
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
changeCategory: (value: string) => updateFilters({ category: value, tag: [] }),
changeTag: (value: string[]) => updateFilters({ tag: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }),
refreshPrompts,
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
};
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined } from "@ant-design/icons";
import { Button, Flex, Layout, Menu, Typography } from "antd";
import { LogOut } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { UserStatusActions } from "@/components/user-status-actions";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
export default function AdminLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const token = useUserStore((state) => state.token);
const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady);
const logout = useUserStore((state) => state.clearSession);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const activeKey = pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
const pageTitle = pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
useEffect(() => {
if (!isReady) return;
if (!token) {
router.replace("/login?redirect=/admin");
return;
}
if (user?.role !== "admin") {
router.replace("/");
}
}, [isReady, router, token, user?.role]);
if (!isReady || !token || user?.role !== "admin") {
return (
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center", background: "var(--background)" }}>
<span />
</div>
);
}
return (
<Layout className="admin-layout" hasSider style={{ height: "100vh", overflow: "hidden" }}>
<Layout.Sider width={232} style={{ height: "100vh", overflow: "hidden" }}>
<Flex className="admin-brand" align="center" gap={12} style={{ height: 56, padding: "0 20px" }}>
<span className="admin-logo" aria-hidden />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Flex>
<Menu
mode="inline"
selectedKeys={[activeKey]}
style={{ borderInlineEnd: 0, padding: "12px 8px" }}
items={[
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: <Link href="/admin/prompts" style={{ color: "inherit" }}></Link> },
{ key: "/admin/assets", icon: <PictureOutlined />, label: <Link href="/admin/assets" style={{ color: "inherit" }}></Link> },
]}
/>
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12 }}>
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer"></Button>
<Button block icon={<LogoutOutlined />} onClick={logout}>退</Button>
</Flex>
</Layout.Sider>
<Layout>
<Layout.Header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: 56, padding: "0 24px" }}>
<Typography.Title level={5} style={{ margin: 0 }}>{pageTitle}</Typography.Title>
<Flex align="center" gap={4}>
<UserStatusActions
version={appVersion}
theme={colorTheme}
onThemeChange={setTheme}
showConfig={false}
userName={user.username}
menuItems={[{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout }]}
/>
</Flex>
</Layout.Header>
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
</Layout>
</Layout>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/assets");
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { CopyOutlined, DeleteOutlined, EditOutlined, ExportOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from "@ant-design/icons";
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import type { Prompt } from "@/services/api/prompts";
import { useAdminPrompts } from "../hooks/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 { message } = App.useApp();
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 [isSyncOpen, setIsSyncOpen] = useState(false);
const defaultCategory = categories[0]?.category || "";
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))];
const tagOptions = tags.map((item) => ({ label: item, value: item }));
useEffect(() => {
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
}, [editingPrompt, form]);
const copyPrompt = async (value: string) => {
await navigator.clipboard.writeText(value);
message.success("已复制");
};
const savePrompt = async () => {
const value = await form.validateFields();
await saveAdminPrompt({ ...editingPrompt, ...value, category: value.category || defaultCategory, tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean) });
setEditingPrompt(null);
};
const columns: ProColumns<Prompt>[] = [
{
title: "封面",
dataIndex: "coverUrl",
width: 88,
render: (_, item) => <Image src={item.coverUrl || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
},
{
title: "标题",
dataIndex: "title",
width: 260,
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>{item.title}</Typography.Link>,
},
{
title: "分类",
dataIndex: "category",
width: 150,
render: (_, item) => <Typography.Text type="secondary">{categoryName(item.category)}</Typography.Text>,
},
{
title: "标签",
dataIndex: "tags",
width: 180,
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} /></Tooltip>
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} /></Tooltip>
</Space>
),
},
];
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} /></Form.Item></Col>
<Col flex="220px"><Form.Item label="分组"><Select value={category} onChange={changeCategory} options={categoryOptions} /></Form.Item></Col>
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}></Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}></Button></Space></Form.Item></Col>
</Row>
</Form>
</Card>
<ProTable<Prompt>
rowKey="id"
columns={columns}
dataSource={prompts}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
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>]}
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>
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
<Form.Item name="category" label="分类"><Select options={categories.map((item) => ({ label: item.name, value: item.category }))} /></Form.Item>
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}><Input.TextArea rows={6} /></Form.Item>
</Form>
</Modal>
<Modal title="提示词详情" open={Boolean(detailPrompt)} width={760} onCancel={() => setDetailPrompt(null)} footer={<Button onClick={() => setDetailPrompt(null)}></Button>}>
{detailPrompt ? (
<Flex vertical gap={14}>
<Flex gap={14} align="start">
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{detailPrompt.title}</Typography.Title>
<Space wrap><Tag>{categoryName(detailPrompt.category)}</Tag>{(detailPrompt.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
</Flex>
</Flex>
{detailPrompt.preview ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailPrompt.preview}</Typography.Paragraph> : null}
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
<Space>
<Button icon={<CopyOutlined />} onClick={() => void copyPrompt(detailPrompt.prompt)}></Button>
{detailPrompt.githubUrl ? <Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank"></Button> : null}
</Space>
</Flex>
) : null}
</Modal>
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} maskClosable={!isSyncing} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}></Button>}>
<Table
rowKey="category"
dataSource={categories.filter((item) => item.remote)}
pagination={false}
columns={[
{ title: "远程源", dataIndex: "name", render: (_, item) => <Flex align="center" gap={8}>{item.name}{item.githubUrl ? <Typography.Link href={item.githubUrl} target="_blank"><ExportOutlined /></Typography.Link> : null}</Flex> },
{ title: "", key: "sync", width: 96, align: "right", render: (_, item) => <Button type="primary" loading={isSyncing} onClick={async () => { try { await syncCategory(item.category); setIsSyncOpen(false); } catch {} }}></Button> },
]}
/>
</Modal>
<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>
</main>
);
}