feat: transition to a fully frontend architecture by removing backend dependencies and updating configuration for local deployment
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/settings");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 || "");
|
||||
|
||||
@@ -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)}>
|
||||
调整
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user