feat: update admin navigation and configuration for local deployment, enhance user experience with direct API connections

This commit is contained in:
HouYunFei
2026-06-16 12:07:35 +08:00
parent 0d90465339
commit 8a66524ea7
83 changed files with 649 additions and 7173 deletions
@@ -5,15 +5,13 @@ 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;
const localToken = "";
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[]>([]);
@@ -21,14 +19,13 @@ export function useAdminAssets() {
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),
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(token, asset),
mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(localToken, asset),
onSuccess: async (_, asset) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success(asset.id ? "素材已保存" : "素材已新增");
@@ -39,7 +36,7 @@ export function useAdminAssets() {
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminAsset(token, id),
mutationFn: (id: string) => deleteAdminAsset(localToken, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success("素材已删除");
@@ -53,9 +50,8 @@ export function useAdminAssets() {
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]);
}, [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 };
+2 -218
View File
@@ -1,221 +1,5 @@
"use client";
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { Button, Card, Col, Form, Input, InputNumber, Modal, Row, Space, Tag, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import type { AdminCreditLog } from "@/services/api/admin";
import { useAdminCreditLogs } from "./use-admin-credit-logs";
type CreditLogFormValues = Partial<AdminCreditLog>;
const creditLogTypeLabels: Record<string, string> = {
admin_adjust: "后台调整",
ai_consume: "模型消费",
ai_refund: "失败返还",
};
import { redirect } from "next/navigation";
export default function AdminCreditLogsPage() {
const { logs, keyword, page, pageSize, total, isLoading, searchLogs, changePage, changePageSize, resetFilters, refreshLogs, saveLog: saveAdminLog, deleteLog } = useAdminCreditLogs();
const [form] = Form.useForm<CreditLogFormValues>();
const [keywordText, setKeywordText] = useState(keyword);
const [editingLog, setEditingLog] = useState<Partial<AdminCreditLog> | null>(null);
const [deletingLog, setDeletingLog] = useState<AdminCreditLog | null>(null);
useEffect(() => setKeywordText(keyword), [keyword]);
useEffect(() => {
if (editingLog) form.setFieldsValue({ type: "admin_adjust", amount: 0, balance: 0, ...editingLog });
}, [editingLog, form]);
const saveLog = async () => {
const value = await form.validateFields();
await saveAdminLog({ ...editingLog, ...value });
setEditingLog(null);
};
const columns: ProColumns<AdminCreditLog>[] = [
{
title: "用户 ID",
dataIndex: "userId",
width: 220,
render: (_, item) => <Typography.Text copyable>{item.userId}</Typography.Text>,
},
{
title: "类型",
dataIndex: "type",
width: 140,
render: (_, item) => <Tag>{creditLogTypeLabels[item.type] || item.type || "-"}</Tag>,
},
{
title: "变动",
dataIndex: "amount",
width: 100,
render: (_, item) => <Typography.Text type={item.amount >= 0 ? "success" : "danger"}>{item.amount}</Typography.Text>,
},
{
title: "余额",
dataIndex: "balance",
width: 100,
},
{
title: "备注",
dataIndex: "remark",
ellipsis: true,
render: (_, item) => <Typography.Text type="secondary">{item.remark || "-"}</Typography.Text>,
},
{
title: "创建时间",
dataIndex: "createdAt",
width: 180,
render: (_, item) => <Typography.Text type="secondary">{item.createdAt ? dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss") : "-"}</Typography.Text>,
},
{
title: "操作",
key: "actions",
width: 96,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingLog(item)} />
</Tooltip>
<Tooltip title="删除">
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingLog(item)} />
</Tooltip>
</Space>
),
},
];
return (
<main style={{ padding: 24 }}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px">
<Form.Item label="关键词">
<Input.Search value={keywordText} placeholder="搜索用户 ID、类型、备注或关联 ID" allowClear enterButton={<SearchOutlined />} onSearch={() => searchLogs(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
</Form.Item>
</Col>
<Col flex="none">
<Form.Item>
<Space>
<Button
onClick={() => {
setKeywordText("");
resetFilters();
}}
>
重置
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchLogs(keywordText)}>
查询
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
<ProTable<AdminCreditLog>
rowKey="id"
columns={columns}
dataSource={logs}
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 refreshLogs() }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingLog({ type: "admin_adjust", amount: 0, balance: 0 })}>
新增
</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)),
}}
/>
</Space>
<Modal title={editingLog?.id ? "编辑日志" : "新增日志"} open={Boolean(editingLog)} width={680} onCancel={() => setEditingLog(null)} onOk={() => void saveLog()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Row gutter={14}>
<Col span={12}>
<Form.Item name="userId" label="用户 ID" rules={[{ required: true, message: "请输入用户 ID" }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请输入类型" }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="amount" label="变动数量" rules={[{ required: true, message: "请输入变动数量" }]}>
<InputNumber precision={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="balance" label="变动后余额" rules={[{ required: true, message: "请输入变动后余额" }]}>
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="relatedId" label="关联 ID">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="createdAt" label="创建时间">
<Input placeholder="不填则新增时自动生成" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="extra" label="扩展信息">
<Input.TextArea rows={3} />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
<Modal
title="删除日志"
open={Boolean(deletingLog)}
onCancel={() => setDeletingLog(null)}
onOk={async () => {
if (!deletingLog) return;
await deleteLog(deletingLog.id);
setDeletingLog(null);
}}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
确定删除这条算力点日志吗?
</Modal>
</main>
);
redirect("/admin/settings");
}
@@ -1,79 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { deleteAdminCreditLog, fetchAdminCreditLogs, saveAdminCreditLog, type AdminCreditLog } from "@/services/api/admin";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
export function useAdminCreditLogs() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const query = useQuery({
queryKey: ["admin", "credit-logs", token, keyword, page, pageSize],
queryFn: () => fetchAdminCreditLogs(token, { keyword, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const saveMutation = useMutation({
mutationFn: (log: Partial<AdminCreditLog>) => saveAdminCreditLog(token, log),
onSuccess: async (_, log) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "credit-logs"] });
message.success(log.id ? "日志已保存" : "日志已新增");
},
onError: (error) => message.error(error instanceof Error ? error.message : "保存失败"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminCreditLog(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "credit-logs"] });
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; page: number; pageSize: number }>) => {
const queryState = { keyword, page, pageSize, ...next };
if (next.keyword !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const data = query.data;
return {
logs: data?.items || [],
keyword,
page,
pageSize,
total: data?.total || 0,
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
searchLogs: (value = keyword) => updateFilters({ keyword: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", page: 1, pageSize: defaultPageSize }),
refreshLogs: () => query.refetch(),
saveLog: (log: Partial<AdminCreditLog>) => saveMutation.mutateAsync(log),
deleteLog: (id: string) => deleteMutation.mutateAsync(id),
};
}
+4 -39
View File
@@ -1,19 +1,15 @@
"use client";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined, TransactionOutlined, UserOutlined } from "@ant-design/icons";
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, useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { adminLayoutStyle } from "@/lib/app-theme";
import { useUserStore } from "@/stores/use-user-store";
const adminMenus = [
{ key: "/admin/users", icon: <UserOutlined />, label: "用户管理" },
{ key: "/admin/credit-logs", icon: <TransactionOutlined />, label: "算力点日志" },
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
@@ -21,43 +17,15 @@ const adminMenus = [
export default function AdminLayout({ children }: { children: ReactNode }) {
const { token: antToken } = theme.useToken();
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 activeKey = pathname.startsWith("/admin/settings")
? "/admin/settings"
: pathname.startsWith("/admin/assets")
? "/admin/assets"
: pathname.startsWith("/admin/prompts")
? "/admin/prompts"
: pathname.startsWith("/admin/credit-logs")
? "/admin/credit-logs"
: pathname.startsWith("/admin/users")
? "/admin/users"
: "";
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : pathname.startsWith("/admin/prompts") ? "提示词管理" : pathname.startsWith("/admin/credit-logs") ? "算力点日志" : "用户管理";
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: antToken.colorBgLayout }}>
<span />
</div>
);
}
: "";
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
return (
<Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
@@ -86,9 +54,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">
前往画布
</Button>
<Button block icon={<LogoutOutlined />} onClick={logout}>
退出登录
</Button>
</Flex>
</Layout.Sider>
<Layout style={{ background: antToken.colorBgLayout }}>
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/users");
redirect("/admin/settings");
}
@@ -6,15 +6,13 @@ import { App } from "antd";
import { deleteAdminPrompt, deleteAdminPrompts, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
import type { Prompt } from "@/services/api/prompts";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
const localToken = "";
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[]>([]);
@@ -22,23 +20,21 @@ export function useAdminPrompts() {
const [pageSize, setPageSize] = useState(defaultPageSize);
const categoriesQuery = useQuery({
queryKey: ["admin", "prompt-categories", token],
queryFn: () => fetchAdminPromptCategories(token),
enabled: Boolean(token),
queryKey: ["admin", "prompt-categories"],
queryFn: () => fetchAdminPromptCategories(localToken),
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),
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(token, category),
mutationFn: (category: string) => syncAdminPromptCategory(localToken, category),
onSuccess: async (categories) => {
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories", token], categories);
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories"], categories);
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("远程提示词源已同步");
},
@@ -48,7 +44,7 @@ export function useAdminPrompts() {
});
const saveMutation = useMutation({
mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(token, prompt),
mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(localToken, prompt),
onSuccess: async (_, prompt) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
@@ -60,7 +56,7 @@ export function useAdminPrompts() {
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminPrompt(token, id),
mutationFn: (id: string) => deleteAdminPrompt(localToken, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
@@ -72,7 +68,7 @@ export function useAdminPrompts() {
});
const batchDeleteMutation = useMutation({
mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids),
mutationFn: (ids: string[]) => deleteAdminPrompts(localToken, ids),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
@@ -88,8 +84,7 @@ export function useAdminPrompts() {
if (!error) return;
const errorMessage = error instanceof Error ? error.message : "读取提示词失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
}, [categoriesQuery.error, clearSession, message, promptsQuery.error]);
}, [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 };
File diff suppressed because it is too large Load Diff
+2 -258
View File
@@ -1,261 +1,5 @@
"use client";
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { Avatar, Button, Card, Col, Divider, Flex, Form, Input, InputNumber, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import type { AdminUser } from "@/services/api/admin";
import { useAdminUsers } from "./use-admin-users";
type UserFormValues = Partial<AdminUser> & { password?: string };
const roleOptions = [
{ label: "普通用户", value: "user" },
{ label: "管理员", value: "admin" },
];
const statusOptions = [
{ label: "正常", value: "active" },
{ label: "禁用", value: "ban" },
];
import { redirect } from "next/navigation";
export default function AdminUsersPage() {
const { users, keyword, page, pageSize, total, isLoading, searchUsers, changePage, changePageSize, resetFilters, refreshUsers, saveUser: saveAdminUser, adjustCredits, deleteUser } = useAdminUsers();
const [form] = Form.useForm<UserFormValues>();
const [keywordText, setKeywordText] = useState(keyword);
const [editingUser, setEditingUser] = useState<Partial<AdminUser> | null>(null);
const [deletingUser, setDeletingUser] = useState<AdminUser | null>(null);
useEffect(() => setKeywordText(keyword), [keyword]);
useEffect(() => {
if (editingUser) form.setFieldsValue({ role: "user", status: "active", ...editingUser, password: "" });
}, [editingUser, form]);
const saveUser = async () => {
const value = await form.validateFields();
const userValue = { ...value };
delete userValue.credits;
await saveAdminUser({ ...editingUser, ...userValue, password: value.password || undefined });
setEditingUser(null);
};
const saveCredits = async () => {
if (!editingUser?.id) return;
await adjustCredits(editingUser.id, form.getFieldValue("credits") || 0);
};
const columns: ProColumns<AdminUser>[] = [
{
title: "用户",
dataIndex: "username",
width: 260,
render: (_, item) => (
<Flex align="center" gap={10} style={{ minWidth: 0 }}>
<Avatar src={item.avatarUrl || undefined}>{(item.displayName || item.username || "U").slice(0, 1).toUpperCase()}</Avatar>
<Flex vertical style={{ minWidth: 0 }}>
<Typography.Text strong ellipsis>
{item.displayName || item.username}
</Typography.Text>
<Typography.Text type="secondary" ellipsis>
{item.username}
</Typography.Text>
</Flex>
</Flex>
),
},
{
title: "角色",
dataIndex: "role",
width: 100,
render: (_, item) => <Tag color={item.role === "admin" ? "gold" : "default"}>{item.role === "admin" ? "管理员" : "用户"}</Tag>,
},
{
title: "状态",
dataIndex: "status",
width: 90,
render: (_, item) => <Tag color={item.status === "ban" ? "red" : "green"}>{item.status === "ban" ? "禁用" : "正常"}</Tag>,
},
{
title: "算力点",
dataIndex: "credits",
width: 100,
render: (_, item) => <Typography.Text>{item.credits}</Typography.Text>,
},
{
title: "Linux.do",
dataIndex: "linuxDoId",
width: 140,
render: (_, item) => <Typography.Text type="secondary">{item.linuxDoId || "-"}</Typography.Text>,
},
{
title: "最近登录",
dataIndex: "lastLoginAt",
width: 180,
render: (_, item) => <Typography.Text type="secondary">{item.lastLoginAt ? dayjs(item.lastLoginAt).format("YYYY-MM-DD HH:mm:ss") : "-"}</Typography.Text>,
},
{
title: "操作",
key: "actions",
width: 96,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingUser(item)} />
</Tooltip>
<Tooltip title="删除">
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingUser(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="搜索用户名、昵称、邮箱或 Linux.do ID"
allowClear
enterButton={<SearchOutlined />}
onSearch={() => searchUsers(keywordText)}
onChange={(event) => setKeywordText(event.target.value)}
/>
</Form.Item>
</Col>
<Col flex="none">
<Form.Item>
<Space>
<Button
onClick={() => {
setKeywordText("");
resetFilters();
}}
>
重置
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchUsers(keywordText)}>
查询
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
<ProTable<AdminUser>
rowKey="id"
columns={columns}
dataSource={users}
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 refreshUsers() }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingUser({ role: "user", status: "active" })}>
新增
</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={editingUser?.id ? "编辑用户" : "新增用户"} open={Boolean(editingUser)} width={680} onCancel={() => setEditingUser(null)} onOk={() => void saveUser()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Typography.Text strong>基础信息</Typography.Text>
<Row gutter={14}>
<Col span={12}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: "请输入用户名" }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="password" label={editingUser?.id ? "新密码" : "密码"} rules={editingUser?.id ? [] : [{ required: true, message: "请输入密码" }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="displayName" label="昵称">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="email" label="邮箱">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="role" label="角色" rules={[{ required: true, message: "请选择角色" }]}>
<Select options={roleOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
<Select options={statusOptions} />
</Form.Item>
</Col>
</Row>
{editingUser?.id ? (
<>
<Divider style={{ margin: "4px 0 16px" }} />
<Typography.Text strong>算力点调整</Typography.Text>
<Row gutter={14}>
<Col span={12}>
<Form.Item label="算力点">
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="credits" noStyle>
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
</Form.Item>
<Button onClick={() => void saveCredits()}>调整</Button>
</Space.Compact>
</Form.Item>
</Col>
</Row>
</>
) : null}
</Form>
</Modal>
<Modal
title="删除用户"
open={Boolean(deletingUser)}
onCancel={() => setDeletingUser(null)}
onOk={async () => {
if (!deletingUser) return;
await deleteUser(deletingUser.id);
setDeletingUser(null);
}}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
确定删除「{deletingUser?.displayName || deletingUser?.username}」吗?删除后该账号将无法继续登录。
</Modal>
</main>
);
redirect("/admin/settings");
}
@@ -1,89 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { adjustAdminUserCredits, deleteAdminUser, fetchAdminUsers, saveAdminUser, type AdminUser } from "@/services/api/admin";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
export function useAdminUsers() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const query = useQuery({
queryKey: ["admin", "users", token, keyword, page, pageSize],
queryFn: () => fetchAdminUsers(token, { keyword, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const saveMutation = useMutation({
mutationFn: (user: Partial<AdminUser> & { password?: string }) => saveAdminUser(token, user),
onSuccess: async (_, user) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
message.success(user.id ? "用户已保存" : "用户已新增");
},
onError: (error) => message.error(error instanceof Error ? error.message : "保存失败"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminUser(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
message.success("用户已删除");
},
onError: (error) => message.error(error instanceof Error ? error.message : "删除失败"),
});
const creditMutation = useMutation({
mutationFn: ({ id, credits }: { id: string; credits: number }) => adjustAdminUserCredits(token, id, credits),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
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; page: number; pageSize: number }>) => {
const queryState = { keyword, page, pageSize, ...next };
if (next.keyword !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const data = query.data;
return {
users: data?.items || [],
keyword,
page,
pageSize,
total: data?.total || 0,
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending || creditMutation.isPending,
searchUsers: (value = keyword) => updateFilters({ keyword: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", page: 1, pageSize: defaultPageSize }),
refreshUsers: () => query.refetch(),
saveUser: (user: Partial<AdminUser> & { password?: string }) => saveMutation.mutateAsync(user),
adjustCredits: (id: string, credits: number) => creditMutation.mutateAsync({ id, credits }),
deleteUser: (id: string) => deleteMutation.mutateAsync(id),
};
}
@@ -48,7 +48,7 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentAvatar theme={theme} /> : null}
<div className={`min-w-0 max-w-[82%] text-sm leading-6 ${isUser ? "text-right" : "text-left"}`} style={{ color }}>
<div className="whitespace-pre-wrap break-words">{item.text}</div>
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
{item.meta ? <div className="mt-1 text-[11px] opacity-45">{item.meta}</div> : null}
</div>
+22 -123
View File
@@ -1,136 +1,35 @@
"use client";
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { App, Button, Form, Input, Segmented, Space } from "antd";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { Button } from "antd";
import { ArrowLeft, Settings2 } from "lucide-react";
import Link from "next/link";
import { fetchCurrentUser } from "@/services/api/auth";
import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
type LoginFormValues = {
username: string;
password: string;
confirmPassword?: string;
};
// 仅放行站内相对路径,拦截开放重定向。浏览器会忽略 URL 中的 Tab/换行/回车,并把
// //host 或 /\host 解析为协议相对的跨站地址,因此先剥离控制字符,再拒绝 // 与 /\ 前缀。
function safeRedirect(value: string | null): string {
const cleaned = (value ?? "").replace(/[\t\n\r]/g, "");
if (!cleaned.startsWith("/") || cleaned.startsWith("//") || cleaned.startsWith("/\\")) {
return "/";
}
return cleaned;
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginContent />
</Suspense>
);
}
function LoginContent() {
const { message } = App.useApp();
const router = useRouter();
const searchParams = useSearchParams();
const login = useUserStore((state) => state.login);
const register = useUserStore((state) => state.register);
const setSession = useUserStore((state) => state.setSession);
const isLoading = useUserStore((state) => state.isLoading);
const linuxDoEnabled = useConfigStore((state) => state.publicSettings?.auth?.linuxDo?.enabled === true);
const allowRegister = useConfigStore((state) => state.publicSettings?.auth?.allowRegister !== false);
const [mode, setMode] = useState<"login" | "register">("login");
const redirect = safeRedirect(searchParams.get("redirect"));
useEffect(() => {
const token = searchParams.get("token");
const error = searchParams.get("error");
if (error) message.error(error);
if (!token) return;
void fetchCurrentUser(token).then((user) => {
setSession(token, user);
message.success("登录成功");
router.replace(redirect);
router.refresh();
});
}, [message, redirect, router, searchParams, setSession]);
useEffect(() => {
if (!allowRegister && mode === "register") setMode("login");
}, [allowRegister, mode]);
const submit = async (values: LoginFormValues) => {
try {
if (mode === "register" && !allowRegister) {
message.error("当前未开放注册");
return;
}
if (mode === "register" && values.password !== values.confirmPassword) {
message.error("两次输入的密码不一致");
return;
}
const action = mode === "register" ? register : login;
const user = await action({ username: values.username, password: values.password });
message.success(mode === "register" ? "注册成功" : "登录成功");
router.replace(redirect);
router.refresh();
if (user.role !== "admin") router.replace("/");
} catch (error) {
message.error(error instanceof Error ? error.message : "登录失败");
}
};
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-[420px]">
<div className="mb-7 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">支持账号密码和 Linux.do 登录。</p>
<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>
<Form<LoginFormValues> layout="vertical" size="large" requiredMark={false} onFinish={submit}>
<Form.Item>
<Segmented
block
value={mode}
onChange={(value) => setMode(value as "login" | "register")}
options={allowRegister ? [{ label: "登录", value: "login" }, { label: "注册", value: "register" }] : [{ label: "登录", value: "login" }]}
/>
</Form.Item>
<Form.Item name="username" label={<span className="font-medium text-stone-800 dark:text-stone-200">用户名</span>} rules={[{ required: true, message: "请输入用户名" }]}>
<Input prefix={<UserOutlined />} autoComplete="username" />
</Form.Item>
<Form.Item name="password" label={<span className="font-medium text-stone-800 dark:text-stone-200">密码</span>} rules={[{ required: true, message: "请输入密码" }]}>
<Input.Password prefix={<LockOutlined />} autoComplete="current-password" />
</Form.Item>
{mode === "register" ? (
<Form.Item name="confirmPassword" label={<span className="font-medium text-stone-800 dark:text-stone-200">确认密码</span>} rules={[{ required: true, message: "请再次输入密码" }]}>
<Input.Password prefix={<LockOutlined />} autoComplete="new-password" />
</Form.Item>
) : null}
<Space orientation="vertical" size={12} style={{ width: "100%" }}>
<Button block type="primary" htmlType="submit" loading={isLoading}>
{mode === "register" ? "注册" : "登录"}
</Button>
{linuxDoEnabled ? (
<Button block href={`/api/auth/linux-do/authorize?redirect=${encodeURIComponent(redirect)}`} icon={<img src="/icons/linuxdo.svg" alt="" width={18} height={18} />}>
使用 Linux.do 登录
</Button>
) : null}
</Space>
</Form>
</section>
</main>
);
-60
View File
@@ -1,60 +0,0 @@
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export const maxDuration = 300;
type RouteContext = {
params: Promise<{ path: string[] }>;
};
function proxyHeaders(request: NextRequest) {
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("content-length");
headers.delete("connection");
headers.set("x-forwarded-host", request.nextUrl.host);
headers.set("x-forwarded-proto", request.nextUrl.protocol.replace(":", ""));
return headers;
}
function responseHeaders(response: Response) {
const headers = new Headers(response.headers);
headers.delete("content-length");
headers.delete("content-encoding");
headers.delete("transfer-encoding");
return headers;
}
async function proxy(request: NextRequest, context: RouteContext) {
const { path } = await context.params;
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080";
const target = `${apiBaseUrl.replace(/\/$/, "")}/api/${path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`;
const hasBody = request.method !== "GET" && request.method !== "HEAD";
try {
const response = await fetch(target, {
method: request.method,
headers: proxyHeaders(request),
body: hasBody ? request.body : undefined,
duplex: hasBody ? "half" : undefined,
redirect: "manual",
} as RequestInit & { duplex?: "half" });
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders(response),
});
} catch (error) {
console.error("Failed to proxy", target, error);
return Response.json({ code: 1, data: null, msg: "接口连接失败,请确认后端服务已启动" }, { status: 502 });
}
}
export const GET = proxy;
export const HEAD = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;
export const OPTIONS = proxy;
+258
View File
@@ -0,0 +1,258 @@
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Prompt = {
id: string;
title: string;
coverUrl: string;
prompt: string;
tags: string[];
category: string;
githubUrl: string;
preview: string;
createdAt: string;
updatedAt: string;
};
type PromptCategory = {
category: string;
githubUrl: string;
build: () => Promise<Omit<Prompt, "category" | "githubUrl">[]>;
};
const gptImage2RawBase = "https://raw.githubusercontent.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts/main";
const awesomeGptImageRawBase = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main";
const awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main";
const youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main";
const youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main";
const davidWuGptImage2RawBase = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main";
const gptImage2CaseFiles = ["README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"];
const cacheTtlMs = 1000 * 60 * 60;
const categories: PromptCategory[] = [
{ category: "gpt-image-2-prompts", githubUrl: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", build: buildGptImage2Prompts },
{ category: "awesome-gpt-image", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", build: buildAwesomeGptImagePrompts },
{ category: "awesome-gpt4o-image-prompts", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", build: buildAwesomeGpt4oImagePrompts },
{ category: "youmind-gpt-image-2", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", build: () => buildYouMindPrompts(youMindGptImage2RawBase, "youmind-gpt-image-2", "gpt-image-2") },
{ category: "youmind-nano-banana-pro", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", build: () => buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro") },
{ category: "davidwu-gpt-image2-prompts", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", build: buildDavidWuGptImage2Prompts },
];
let memoryCache: { items: Prompt[]; fetchedAt: number } | null = null;
let loadingPrompts: Promise<Prompt[]> | null = null;
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const keyword = (params.get("keyword") || "").trim().toLowerCase();
const tags = params.getAll("tag").filter(Boolean);
const category = params.get("category") || "";
const page = Math.max(1, Number(params.get("page")) || 1);
const pageSize = Math.max(1, Math.min(100, Number(params.get("pageSize")) || 20));
const items = await getPrompts();
const withoutTagFilter = filterPrompts(items, { keyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword, category, tags });
return Response.json({
items: filtered.slice((page - 1) * pageSize, page * pageSize),
tags: collectTags(withoutTagFilter),
categories: categories.map((item) => item.category),
total: filtered.length,
});
}
async function getPrompts() {
if (memoryCache && Date.now() - memoryCache.fetchedAt < cacheTtlMs) return memoryCache.items;
if (loadingPrompts) return loadingPrompts;
loadingPrompts = loadPrompts().finally(() => {
loadingPrompts = null;
});
return loadingPrompts;
}
async function loadPrompts() {
const settled = await Promise.all(
categories.map(async (category) => {
try {
const items = await category.build();
return items.map((item) => ({ ...item, category: category.category, githubUrl: category.githubUrl }));
} catch {
return [];
}
}),
);
const items = settled.flat();
memoryCache = { items, fetchedAt: Date.now() };
return items;
}
function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) {
return items.filter((item) => {
if (isActiveOption(options.category) && item.category !== options.category) return false;
if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false;
if (!options.keyword) return true;
return [item.title, item.prompt, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
});
}
async function buildGptImage2Prompts() {
const data = (await fetchJson<{ records?: Array<{ title?: string; tweet_url?: string; image_dir?: string; category?: string; added_at?: string }> }>(gptImage2RawBase, "data/ingested_tweets.json")).records || [];
const cases = new Map<string, string>();
const markdowns = await Promise.all(gptImage2CaseFiles.map((file) => fetchText(gptImage2RawBase, file)));
markdowns.forEach((markdown) => collectGptImage2Cases(cases, markdown));
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
data.forEach((item) => {
const prompt = cases.get(item.tweet_url || "");
if (!item.title || !prompt || !item.image_dir) return;
const image = `${gptImage2RawBase}/${item.image_dir}/output.jpg`;
items.push({ id: `gpt-image-2-prompts-${leftPad(items.length + 1)}`, title: item.title, coverUrl: image, prompt, tags: tagsFromCategory(item.category || ""), preview: markdownPreview([image]), createdAt: item.added_at || "", updatedAt: item.added_at || "" });
});
return items;
}
function collectGptImage2Cases(cases: Map<string, string>, markdown: string) {
for (const match of markdown.matchAll(/### Case \d+: \[[^\]]+]\(([^)]+)\).*?\*\*Prompt:\*\*\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/gs)) {
cases.set(match[1], match[2].trim());
}
}
async function buildAwesomeGptImagePrompts() {
const markdown = await fetchText(awesomeGptImageRawBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const section of splitBeforeHeading(markdown, "## ")) {
const tags = tagsFromHeading(firstMatch(section, /^##\s+(.+)$/m));
for (const block of splitBeforeHeading(section, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).replace(/\[([^\]]+)]\([^)]+\)/g, "$1").trim();
const prompt = firstMatch(block, /\*\*提示词:\*\*\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGptImageRawBase, block);
items.push(defaultPrompt(`awesome-gpt-image-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", tags, markdownPreview(images)));
}
}
return items;
}
async function buildAwesomeGpt4oImagePrompts() {
const markdown = await fetchText(awesomeGpt4oImagePromptsBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).trim();
const prompt = firstMatch(block, /- \*\*提示词文本:\*\*\s*`(.*?)`/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGpt4oImagePromptsBase, block);
items.push(defaultPrompt(`awesome-gpt4o-image-prompts-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", ["gpt4o"], markdownPreview(images)));
}
return items;
}
async function buildYouMindPrompts(baseUrl: string, idPrefix: string, modelTag: string) {
const markdown = await fetchText(baseUrl, "README_zh.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+No\.\s*\d+:\s*(.+)$/m).trim();
const prompt = firstMatch(block, /#### .*?提示词\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(baseUrl, block);
items.push(defaultPrompt(`${idPrefix}-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", youMindTags(title, modelTag), markdownPreview(images)));
}
return items;
}
async function buildDavidWuGptImage2Prompts() {
const data = await fetchJson<Array<{ id?: number; title_en?: string; title_cn?: string; category?: string; category_cn?: string; prompt?: string; note?: string; author?: string; source?: string; needs_ref?: boolean; image?: string }>>(davidWuGptImage2RawBase, "prompts.json");
return data
.map((item, index) => {
const title = (item.title_cn || item.title_en || "").trim();
const prompt = (item.prompt || "").trim();
if (!title || !prompt) return null;
const image = absoluteImage(davidWuGptImage2RawBase, item.image || "");
const preview = [item.title_en, item.note, image ? `![](${image})` : ""].filter(Boolean).join("\n\n");
return defaultPrompt(`davidwu-gpt-image2-prompts-${leftPad(item.id || index + 1)}`, title, prompt, image, davidWuTags(item), preview);
})
.filter((item): item is Omit<Prompt, "category" | "githubUrl"> => Boolean(item));
}
function defaultPrompt(id: string, title: string, prompt: string, coverUrl: string, tags: string[], preview: string): Omit<Prompt, "category" | "githubUrl"> {
return { id, title, coverUrl, prompt, tags, preview, createdAt: "", updatedAt: "" };
}
async function fetchText(baseUrl: string, file: string) {
const response = await fetch(`${baseUrl}/${file}`, { cache: "no-store" });
if (!response.ok) throw new Error(`${file} 拉取失败`);
return response.text();
}
async function fetchJson<T>(baseUrl: string, file: string) {
return JSON.parse(await fetchText(baseUrl, file)) as T;
}
function splitBeforeHeading(markdown: string, prefix: string) {
const blocks: string[] = [];
let current: string[] = [];
for (const line of markdown.split("\n")) {
if (line.startsWith(prefix) && current.length) {
blocks.push(current.join("\n"));
current = [];
}
current.push(line);
}
blocks.push(current.join("\n"));
return blocks;
}
function firstMatch(value: string, pattern: RegExp) {
return pattern.exec(value)?.[1] || "";
}
function extractMarkdownImages(baseUrl: string, markdown: string) {
return Array.from(markdown.matchAll(/!\[[^\]]*]\(([^)]+)\)/g), (match) => absoluteImage(baseUrl, match[1])).filter(Boolean);
}
function absoluteImage(baseUrl: string, image: string) {
if (!image) return "";
if (/^https?:\/\//i.test(image)) return image;
return `${baseUrl}/${image.replace(/^\.?\//, "")}`;
}
function tagsFromCategory(category: string) {
return splitTags(category.replace(/\s+Cases$/i, ""), /\s*(?:&|and)\s*/);
}
function tagsFromHeading(heading: string) {
return splitTags(heading.replace(/[^\p{L}\p{N}/&、与 ]/gu, ""), /\s*(?:\/|&|、|与)\s*/);
}
function youMindTags(title: string, modelTag: string) {
const [, prefix] = title.match(/^(.+?) - /) || [];
return [modelTag, ...tagsFromHeading(prefix || "")];
}
function davidWuTags(item: { category_cn?: string; category?: string; author?: string; source?: string; needs_ref?: boolean }) {
const tags = splitTags([item.category_cn, item.category, item.author, item.source].filter(Boolean).join("/"), /\//);
if (item.needs_ref) tags.push("需要参考图");
return tags;
}
function splitTags(value: string, pattern: RegExp) {
return value
.split(pattern)
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean);
}
function markdownPreview(images: string[]) {
return images.filter(Boolean).map((image) => `![](${image})`).join("\n\n");
}
function collectTags(items: Prompt[]) {
return Array.from(new Set(items.flatMap((item) => item.tags).filter(Boolean)));
}
function leftPad(value: number) {
return String(value).padStart(4, "0");
}
function isActiveOption(value: string) {
return value && value !== "全部" && value !== "all";
}