feat: transition to a fully frontend architecture by removing backend dependencies and updating configuration for local deployment

This commit is contained in:
HouYunFei
2026-06-16 12:56:49 +08:00
parent 8a66524ea7
commit 7f0199da59
36 changed files with 571 additions and 1773 deletions
-271
View File
@@ -1,271 +0,0 @@
"use client";
import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import { useCopyText } from "@/hooks/use-copy-text";
import type { AdminAsset } from "@/services/api/admin";
import { useAdminAssets } from "./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 copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>();
const [keywordText, setKeywordText] = useState(keyword);
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]);
useEffect(() => setKeywordText(keyword), [keyword]);
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={keywordText} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={() => searchAssets(keywordText)} onChange={(event) => setKeywordText(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={() => {
setKeywordText("");
resetFilters();
}}
>
重置
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchAssets(keywordText)}>
查询
</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={() => copyText(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>
);
}
@@ -1,88 +0,0 @@
"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";
const defaultPageSize = 10;
const localToken = "";
export function useAdminAssets() {
const { message } = App.useApp();
const queryClient = useQueryClient();
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", keyword, type, tag, page, pageSize],
queryFn: () => fetchAdminAssets(localToken, { keyword, type, tag, page, pageSize }),
retry: false,
});
const saveMutation = useMutation({
mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(localToken, 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(localToken, 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);
}
}, [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 data = query.data;
return {
assets: data?.items || [],
tags: data?.tags || [],
keyword,
kind: type,
tag,
page,
pageSize,
total: data?.total || 0,
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
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: () => query.refetch(),
saveAsset: (asset: Partial<AdminAsset>) => saveMutation.mutateAsync(asset),
deleteAsset: (id: string) => deleteMutation.mutateAsync(id),
};
}
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function AdminCreditLogsPage() {
redirect("/admin/settings");
}
-74
View File
@@ -1,74 +0,0 @@
"use client";
import { FileTextOutlined, HomeOutlined, PictureOutlined, SettingOutlined } from "@ant-design/icons";
import { Button, Flex, Layout, Menu, Typography, theme } from "antd";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { adminLayoutStyle } from "@/lib/app-theme";
const adminMenus = [
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
];
export default function AdminLayout({ children }: { children: ReactNode }) {
const { token: antToken } = theme.useToken();
const pathname = usePathname();
const activeKey = pathname.startsWith("/admin/settings")
? "/admin/settings"
: pathname.startsWith("/admin/assets")
? "/admin/assets"
: pathname.startsWith("/admin/prompts")
? "/admin/prompts"
: "";
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
return (
<Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>
无限画布
</Typography.Text>
</Flex>
<Menu
mode="inline"
selectedKeys={[activeKey]}
style={adminLayoutStyle.menu}
items={adminMenus.map((item) => ({
...item,
label: (
<Link href={item.key} style={{ color: "inherit" }}>
{item.label}
</Link>
),
style: adminLayoutStyle.menuItem,
}))}
/>
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">
前往画布
</Button>
</Flex>
</Layout.Sider>
<Layout style={{ background: antToken.colorBgLayout }}>
<Layout.Header
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}
>
<Typography.Title level={5} style={{ margin: 0 }}>
{pageTitle}
</Typography.Title>
<Flex align="center" gap={4}>
<UserStatusActions showConfig={false} />
</Flex>
</Layout.Header>
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
</Layout>
</Layout>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/settings");
}
-347
View File
@@ -1,347 +0,0 @@
"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 { Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import { useCopyText } from "@/hooks/use-copy-text";
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,
deletePrompts,
} = useAdminPrompts();
const copyText = useCopyText();
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
const [keywordText, setKeywordText] = useState(keyword);
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;
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]);
useEffect(() => setKeywordText(keyword), [keyword]);
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 batchDeletePrompts = async () => {
await deletePrompts(selectedPromptIds);
setSelectedPromptIds([]);
setIsBatchDeleteOpen(false);
};
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={keywordText} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={() => searchPrompts(keywordText)} onChange={(event) => setKeywordText(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={() => {
setKeywordText("");
resetFilters();
}}
>
重置
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchPrompts(keywordText)}>
查询
</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() }}
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>
<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={() => copyText(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)}
mask={{ closable: !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>
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。
</Modal>
</main>
);
}
@@ -1,128 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { deleteAdminPrompt, deleteAdminPrompts, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
import type { Prompt } from "@/services/api/prompts";
const defaultPageSize = 10;
const localToken = "";
export function useAdminPrompts() {
const { message } = App.useApp();
const queryClient = useQueryClient();
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"],
queryFn: () => fetchAdminPromptCategories(localToken),
retry: false,
});
const promptsQuery = useQuery({
queryKey: ["admin", "prompts", keyword, category, tag, page, pageSize],
queryFn: () => fetchAdminPrompts(localToken, { keyword, category, tag, page, pageSize }),
retry: false,
});
const syncMutation = useMutation({
mutationFn: (category: string) => syncAdminPromptCategory(localToken, category),
onSuccess: async (categories) => {
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories"], 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(localToken, 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(localToken, 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 : "删除失败");
},
});
const batchDeleteMutation = useMutation({
mutationFn: (ids: string[]) => deleteAdminPrompts(localToken, 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;
const errorMessage = error instanceof Error ? error.message : "读取提示词失败";
message.error(errorMessage);
}, [categoriesQuery.error, message, promptsQuery.error]);
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 data = promptsQuery.data;
return {
categories: categoriesQuery.data || [],
prompts: data?.items || [],
tags: data?.tags || [],
keyword,
category,
tag,
page,
pageSize,
total: data?.total || 0,
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 }),
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: async () => {
await categoriesQuery.refetch();
await promptsQuery.refetch();
},
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
};
}
@@ -1,48 +0,0 @@
"use client";
import { Button, Card, Typography } from "antd";
import { Database, Settings2, Sparkles } from "lucide-react";
import Link from "next/link";
import { useConfigStore } from "@/stores/use-config-store";
export default function AdminSettingsPage() {
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
return (
<div className="p-6">
<div className="grid gap-4 lg:grid-cols-3">
<Card>
<Settings2 className="mb-4 size-6 text-stone-700 dark:text-stone-200" />
<Typography.Title level={4} className="!mb-2">
前台直连配置
</Typography.Title>
<Typography.Paragraph type="secondary">模型 Base URL、API Key、默认模型和 WebDAV 同步都保存在浏览器本地,不再通过后台渠道转发。</Typography.Paragraph>
<Button type="primary" onClick={() => openConfigDialog(false)}>
打开配置弹窗
</Button>
</Card>
<Card>
<Sparkles className="mb-4 size-6 text-stone-700 dark:text-stone-200" />
<Typography.Title level={4} className="!mb-2">
第三方提示词
</Typography.Title>
<Typography.Paragraph type="secondary">提示词由 Next.js route 从开源仓库拉取,并缓存在当前运行实例的内存里;页面和管理列表都读取同一份缓存。</Typography.Paragraph>
<Link href="/admin/prompts">
<Button>查看提示词</Button>
</Link>
</Card>
<Card>
<Database className="mb-4 size-6 text-stone-700 dark:text-stone-200" />
<Typography.Title level={4} className="!mb-2">
本地数据
</Typography.Title>
<Typography.Paragraph type="secondary">画布项目、我的素材、生成记录和 AI Key 默认留在浏览器本地;需要跨设备时请在配置弹窗里使用 WebDAV 同步。</Typography.Paragraph>
<Link href="/canvas">
<Button>前往画布</Button>
</Link>
</Card>
</div>
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function AdminUsersPage() {
redirect("/admin/settings");
}
@@ -2721,9 +2721,7 @@ function CanvasTopBar({
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const titleRef = useRef<HTMLDivElement>(null);
const accountRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false);
useEffect(() => {
if (!isTitleEditing) return;
@@ -2734,15 +2732,6 @@ function CanvasTopBar({
return () => document.removeEventListener("pointerdown", close, true);
}, [isTitleEditing, onFinishTitleEditing]);
useEffect(() => {
if (!accountOpen) return;
const close = (event: PointerEvent) => {
if (!accountRef.current?.contains(event.target as Node)) setAccountOpen(false);
};
document.addEventListener("pointerdown", close, true);
return () => document.removeEventListener("pointerdown", close, true);
}, [accountOpen]);
return (
<>
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between px-4">
@@ -2800,14 +2789,7 @@ function CanvasTopBar({
<div className="pointer-events-auto flex items-center gap-1.5">
<UserStatusActions
variant="canvas"
accountOpen={accountOpen}
onAccountOpenChange={setAccountOpen}
accountRef={accountRef}
getPopupContainer={(node) => node.parentElement || document.body}
onOpenShortcuts={() => {
setShortcutsOpen(true);
setAccountOpen(false);
}}
onOpenShortcuts={() => setShortcutsOpen(true)}
/>
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
@@ -5,7 +5,7 @@ import { Button, Tooltip } from "antd";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import type { AuthUser } from "@/services/api/auth";
import type { LocalUser } from "@/stores/use-user-store";
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
export type CanvasAgentMode = "online" | "local";
@@ -21,7 +21,7 @@ export type CanvasAgentChatMessage = {
const WORKING_TEXT = "working...";
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: AuthUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
const isUser = item.role === "user";
const isSystem = item.role === "system";
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
@@ -276,7 +276,7 @@ function AgentAvatar({ theme }: { theme: (typeof canvasThemes)[keyof typeof canv
);
}
function AgentUserAvatar({ user, theme }: { user: AuthUser | null; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
function AgentUserAvatar({ user, theme }: { user: LocalUser | null; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const avatarUrl = user?.avatarUrl?.trim();
return (
<span className="grid size-8 shrink-0 place-items-center overflow-hidden rounded-full" style={{ color: theme.node.text }}>
@@ -25,13 +25,12 @@ type CanvasConfigNodePanelProps = {
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle }: CanvasConfigNodePanelProps) {
const globalConfig = useEffectiveConfig();
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = node.metadata?.generationMode || "image";
const config = buildNodeConfig(globalConfig, node, mode);
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? count : 1 });
const credits = requestCreditCost({ channelMode: config.channelMode, model: config.model, count: mode === "image" ? count : 1 });
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
@@ -31,7 +31,6 @@ type CanvasNodePromptPanelProps = {
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = defaultMode(node.type);
@@ -40,7 +39,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? config.count : 1 });
const credits = requestCreditCost({ channelMode: config.channelMode, model: config.model, count: mode === "image" ? config.count : 1 });
useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
+2 -2
View File
@@ -12,7 +12,7 @@ import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal";
import { canvasThemes } from "@/lib/canvas-theme";
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { modelOptionLabel, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { nanoid } from "nanoid";
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
@@ -396,7 +396,7 @@ export default function ImagePage() {
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
<span className="truncate text-stone-500 dark:text-stone-400">
{model} · {effectiveConfig.size} · {effectiveConfig.quality}
{modelOptionLabel(effectiveConfig, model)} · {effectiveConfig.size} · {effectiveConfig.quality}
</span>
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
调整
-36
View File
@@ -1,36 +0,0 @@
"use client";
import { Button } from "antd";
import { ArrowLeft, Settings2 } from "lucide-react";
import Link from "next/link";
import { useConfigStore } from "@/stores/use-config-store";
export default function LoginPage() {
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
return (
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
<section className="w-full max-w-[440px] text-center">
<span
className="mx-auto mb-4 block size-12 bg-stone-950 dark:bg-stone-100"
style={{
mask: "url(/logo.svg) center / contain no-repeat",
WebkitMask: "url(/logo.svg) center / contain no-repeat",
}}
aria-label="无限画布"
/>
<h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100">无需账号登录</h1>
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">当前版本以浏览器本地数据为主,AI 请求使用你在本机配置的 Base URL 和 API Key 前台直连。</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Button type="primary" icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)}>
打开配置
</Button>
<Link href="/canvas">
<Button icon={<ArrowLeft className="size-4" />}>返回画布</Button>
</Link>
</div>
</section>
</main>
);
}
+2 -2
View File
@@ -18,7 +18,7 @@ import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video";
import { useAssetStore } from "@/stores/use-asset-store";
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { modelOptionLabel, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -460,7 +460,7 @@ export default function VideoPage() {
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
<span className="truncate text-stone-500 dark:text-stone-400">
{model} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s
{modelOptionLabel(effectiveConfig, model)} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s
</span>
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
调整
+1 -8
View File
@@ -1,4 +1,4 @@
import { Home, LogIn } from "lucide-react";
import { Home } from "lucide-react";
import Link from "next/link";
export default function NotFound() {
@@ -14,13 +14,6 @@ export default function NotFound() {
<Home className="size-4" />
返回首页
</Link>
<Link
href="/login"
className="inline-flex h-10 items-center gap-2 rounded-lg border border-stone-200 bg-background px-4 text-sm font-medium text-stone-900 transition hover:bg-stone-100 dark:border-stone-800 dark:text-stone-100 dark:hover:bg-stone-800"
>
<LogIn className="size-4" />
去登录
</Link>
</div>
</section>
</main>