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";
}
+49 -79
View File
@@ -9,7 +9,7 @@ import { fetchImageModels } from "@/services/api/image";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { filterModelsByCapability, useConfigStore, type ModelCapability } from "@/stores/use-config-store";
type ModelGroup = {
capability: ModelCapability;
@@ -67,26 +67,19 @@ export function AppConfigModal() {
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const publicSettings = useConfigStore((state) => state.publicSettings);
const effectiveConfig = useEffectiveConfig();
const modelChannel = publicSettings?.modelChannel;
const allowCustomChannel = modelChannel?.allowCustomChannel === true;
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
const modelOptions = config.models.map((model) => ({ label: model, value: model }));
const webdavReady = Boolean(webdav.url.trim());
const finishConfig = () => {
setConfigDialogOpen(false);
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
if (!modelConfig.imageModel.trim() || !modelConfig.videoModel.trim() || !modelConfig.textModel.trim()) return;
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
if (!config.baseUrl.trim() || !config.apiKey.trim()) return;
if (!config.imageModel.trim() || !config.videoModel.trim() || !config.textModel.trim()) return;
if (config.channelMode !== "local") updateConfig("channelMode", "local");
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
clearPromptContinue();
};
const refreshModels = async () => {
if (effectiveMode === "remote") return;
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
message.error("请先填写 Base URL 和 API Key");
return;
@@ -197,74 +190,53 @@ export function AppConfigModal() {
>
<div className="pt-1">
<Form layout="vertical" requiredMark={false}>
{allowCustomChannel ? (
<Form.Item label="渠道模式" className="mb-5">
<Segmented
block
size="middle"
value={effectiveMode}
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
options={[
{ label: "本地直连", value: "local" },
{ label: "云端渠道", value: "remote" },
]}
/>
<div className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs leading-5 text-stone-500">AI OpenAI API Key </div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
</Form.Item>
) : null}
{effectiveMode === "local" ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-5 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-stone-500"> {config.models.length} </div>
</div>
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
</Button>
</div>
</>
) : (
<div className="mb-5 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
<div className="font-medium text-stone-900 dark:text-stone-100"></div>
<div className="mt-1"> {modelChannel?.availableModels.length || 0} </div>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-5 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-stone-500"> {config.models.length} </div>
</div>
)}
{effectiveMode === "local" ? (
<section className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3">
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs text-stone-500"></div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{modelGroups.map((group) => (
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
<Select
mode="multiple"
showSearch
allowClear
maxTagCount="responsive"
placeholder={config.models.length ? `请选择${group.optionsLabel}` : "请先拉取模型列表"}
value={config[group.modelsKey]}
options={modelOptions}
onChange={(models) => updateCapabilityModels(group, models)}
/>
</Form.Item>
))}
</div>
</section>
) : null}
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
</Button>
</div>
<section className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3">
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs text-stone-500"></div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{modelGroups.map((group) => (
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
<Select
mode="tags"
showSearch
allowClear
maxTagCount="responsive"
placeholder={config.models.length ? `请选择或输入${group.optionsLabel}` : "输入模型名,或先拉取模型列表"}
value={config[group.modelsKey]}
options={modelOptions}
onChange={(models) => updateCapabilityModels(group, models)}
/>
</Form.Item>
))}
</div>
</section>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{modelGroups.map((group) => (
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
</Form.Item>
))}
</div>
@@ -300,11 +272,9 @@ export function AppConfigModal() {
<Form.Item label="默认音频指令" className="mb-4">
<Input.TextArea rows={2} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
</Form.Item>
{effectiveMode === "local" ? (
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
) : null}
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
<section className="mt-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
+2 -17
View File
@@ -2,54 +2,39 @@
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { App } from "antd";
import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp();
const handledConfigParams = useRef(false);
const pathname = usePathname();
const hydrateUser = useUserStore((state) => state.hydrateUser);
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
const publicSettings = useConfigStore((state) => state.publicSettings);
const updateConfig = useConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const isLoginPage = pathname === "/login" || pathname === "/admin/login";
useEffect(() => {
void loadPublicSettings();
}, [loadPublicSettings]);
useEffect(() => {
if (!isLoginPage) void hydrateUser();
}, [hydrateUser, isLoginPage]);
useEffect(() => {
if (handledConfigParams.current) return;
const searchParams = new URLSearchParams(window.location.search);
const baseUrl = searchParams.get("baseUrl") || searchParams.get("baseurl");
const apiKey = searchParams.get("apiKey") || searchParams.get("apikey");
if (!baseUrl && !apiKey) return;
if (!publicSettings) return;
handledConfigParams.current = true;
searchParams.delete("baseUrl");
searchParams.delete("baseurl");
searchParams.delete("apiKey");
searchParams.delete("apikey");
window.history.replaceState(null, "", `${window.location.pathname}${searchParams.size ? `?${searchParams}` : ""}${window.location.hash}`);
if (!publicSettings.modelChannel.allowCustomChannel) {
openConfigDialog(false);
message.error("后台未允许用户自定义渠道,请联系管理员进行配置");
return;
}
updateConfig("channelMode", "local");
if (baseUrl) updateConfig("baseUrl", baseUrl);
if (apiKey) updateConfig("apiKey", apiKey);
openConfigDialog(false);
}, [message, openConfigDialog, publicSettings, updateConfig]);
message.success("已导入本地直连配置");
}, [message, openConfigDialog, updateConfig]);
return <>{children}</>;
}
@@ -1,15 +1,13 @@
"use client";
import type { CSSProperties, RefObject } from "react";
import { Avatar, Dropdown, Tooltip } from "antd";
import { BookOpen, Keyboard, LogOut, Settings2, Shield } from "lucide-react";
import { Avatar, Dropdown } from "antd";
import { BookOpen, Keyboard, LogOut, Settings2 } from "lucide-react";
import type { ItemType } from "antd/es/menu/interface";
import Link from "next/link";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { GitHubLink } from "@/components/layout/github-link";
import { VersionReleaseModal } from "@/components/layout/version-release-modal";
import { CreditSymbol } from "@/constant/credits";
import { DOCS_URL } from "@/constant/env";
import { cn } from "@/lib/utils";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -35,7 +33,6 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const canvasTheme = canvasThemes[theme];
const userName = user?.displayName || user?.username || "";
const credits = user?.credits ?? 0;
const avatarUrl = user?.avatarUrl?.trim();
const avatarText = (userName.trim()[0] || "U").toUpperCase();
const naturalIconClass = "inline-flex size-7 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4";
@@ -46,10 +43,9 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text, background: "transparent" } : undefined;
const menuItems: ItemType[] = [
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin"></Link> }] : []),
...(onOpenShortcuts ? [{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: onOpenShortcuts }] : []),
{ type: "divider" },
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
{ key: "logout", icon: <LogOut className="size-4" />, label: "清除本地账户", onClick: logout },
];
return (
@@ -65,24 +61,11 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
<VersionReleaseModal style={versionStyle} />
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
{variant === "canvas" && user ? (
<Tooltip title="当前算力点余额" placement="bottom">
<div className="flex h-8 shrink-0 items-center gap-1.5 px-1.5 text-xs font-medium tabular-nums opacity-75 transition hover:opacity-100" style={{ color: canvasTheme.node.text }}>
<CreditSymbol className="text-sm leading-none" />
<span>{credits.toLocaleString()}</span>
</div>
</Tooltip>
) : null}
{!user && onOpenShortcuts ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenShortcuts} aria-label="快捷键" title="快捷键">
<Keyboard className="size-4" />
</button>
) : null}
{!user ? (
<Link href="/login" className="px-1.5 text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100" style={iconStyle}>
</Link>
) : null}
{user ? (
<div ref={accountRef}>
<Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
-1
View File
@@ -85,7 +85,6 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
if (config.channelMode === "remote") return `暂无可用${label}模型`;
if (capability && config.models.length) return "请先在上方配置可选模型";
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里拉取模型列表";
}
+81 -22
View File
@@ -1,5 +1,7 @@
import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/request";
import type { Prompt, PromptListResponse } from "@/services/api/prompts";
import { nanoid } from "nanoid";
import { fetchPrompts, type Prompt, type PromptListResponse } from "@/services/api/prompts";
import { fetchImageModels } from "@/services/api/image";
export type AdminPromptCategory = {
category: string;
@@ -57,39 +59,40 @@ export type AdminUserQuery = {
};
export async function fetchAdminUsers(token: string, query: AdminUserQuery = {}) {
return apiGet<AdminUserListResponse>("/api/admin/users", compactApiParams(query), token);
return { items: [], total: 0 };
}
export async function saveAdminUser(token: string, user: Partial<AdminUser> & { password?: string }) {
return apiPost<AdminUser>("/api/admin/users", user, token);
return { ...emptyAdminUser(), ...user, id: user.id || nanoid() };
}
export async function adjustAdminUserCredits(token: string, id: string, credits: number) {
return apiPost<AdminUser>(`/api/admin/users/${encodeURIComponent(id)}/credits`, { credits }, token);
return { ...emptyAdminUser(), id, credits };
}
export async function deleteAdminUser(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/users/${encodeURIComponent(id)}`, token);
return true;
}
export async function fetchAdminCreditLogs(token: string, query: AdminUserQuery = {}) {
return apiGet<AdminCreditLogListResponse>("/api/admin/credit-logs", compactApiParams(query), token);
return { items: [], total: 0 };
}
export async function saveAdminCreditLog(token: string, log: Partial<AdminCreditLog>) {
return apiPost<AdminCreditLog>("/api/admin/credit-logs", log, token);
return { id: log.id || nanoid(), userId: "", type: "", amount: 0, balance: 0, relatedId: "", remark: "", extra: "", createdAt: new Date().toISOString(), ...log };
}
export async function deleteAdminCreditLog(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/credit-logs/${encodeURIComponent(id)}`, token);
return true;
}
export async function fetchAdminPromptCategories(token: string) {
return apiGet<AdminPromptCategory[]>("/api/admin/prompt-categories", undefined, token);
return promptCategories;
}
export async function syncAdminPromptCategory(token: string, category: string) {
return apiPost<AdminPromptCategory[]>("/api/admin/prompt-categories/sync", { category }, token);
await fetchPrompts({ category, pageSize: 1 });
return promptCategories;
}
export type AdminPromptQuery = {
@@ -121,19 +124,20 @@ export type AdminAssetListResponse = {
};
export async function fetchAdminPrompts(token: string, query: AdminPromptQuery = {}) {
return apiGet<PromptListResponse>("/api/admin/prompts", compactApiParams(query), token);
return fetchPrompts(query);
}
export async function saveAdminPrompt(token: string, prompt: Partial<Prompt>) {
return apiPost<Prompt>("/api/admin/prompts", prompt, token);
const now = new Date().toISOString();
return { id: prompt.id || nanoid(), title: "", coverUrl: "", prompt: "", tags: [], category: "", githubUrl: "", preview: "", createdAt: now, updatedAt: now, ...prompt };
}
export async function deleteAdminPrompt(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/prompts/${encodeURIComponent(id)}`, token);
return true;
}
export async function deleteAdminPrompts(token: string, ids: string[]) {
return apiPost<boolean>("/api/admin/prompts/batch-delete", { ids }, token);
return true;
}
export type AdminAssetQuery = {
@@ -145,15 +149,15 @@ export type AdminAssetQuery = {
};
export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) {
return apiGet<AdminAssetListResponse>("/api/admin/assets", compactApiParams(query), token);
return { items: [], tags: [], total: 0 };
}
export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>) {
return apiPost<AdminAsset>("/api/admin/assets", asset, token);
return { id: asset.id || nanoid(), title: "", type: "text", coverUrl: "", tags: [], category: "", description: "", content: "", url: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...asset };
}
export async function deleteAdminAsset(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/assets/${encodeURIComponent(id)}`, token);
return true;
}
export type AdminModelChannel = {
@@ -213,11 +217,11 @@ export type AdminSettings = {
};
export async function fetchAdminSettings(token: string) {
return apiGet<AdminSettings>("/api/admin/settings", undefined, token);
return defaultAdminSettings();
}
export async function saveAdminSettings(token: string, settings: AdminSettings) {
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
return settings;
}
export type AdminChannelActionRequest = {
@@ -227,9 +231,64 @@ export type AdminChannelActionRequest = {
};
export async function fetchChannelModels(token: string, payload: AdminChannelActionRequest) {
return apiPost<string[]>("/api/admin/settings/channel-models", payload, token);
return fetchImageModels({ ...defaultAiConfig(), baseUrl: payload.channel.baseUrl, apiKey: payload.channel.apiKey || "local", model: payload.model || "", models: payload.channel.models });
}
export async function testChannelModel(token: string, payload: AdminChannelActionRequest) {
return apiPost<string>("/api/admin/settings/channel-test", payload, token);
return "前台直连配置已保存,请在前台生成时验证模型可用性";
}
const promptCategories: AdminPromptCategory[] = [
{ category: "gpt-image-2-prompts", name: "GPT Image 2 Prompts", description: "", file: "", githubUrl: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", remote: true },
{ category: "awesome-gpt-image", name: "Awesome GPT Image", description: "", file: "", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", remote: true },
{ category: "awesome-gpt4o-image-prompts", name: "Awesome GPT-4o Image Prompts", description: "", file: "", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", remote: true },
{ category: "youmind-gpt-image-2", name: "YouMind GPT Image 2", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", remote: true },
{ category: "youmind-nano-banana-pro", name: "YouMind Nano Banana Pro", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", remote: true },
{ category: "davidwu-gpt-image2-prompts", name: "DavidWu GPT Image2 Prompts", description: "", file: "", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", remote: true },
];
function emptyAdminUser(): AdminUser {
const now = new Date().toISOString();
return { id: "", username: "", email: "", displayName: "", avatarUrl: "", role: "user", credits: 0, affCode: "", affCount: 0, inviterId: "", linuxDoId: "", status: "active", lastLoginAt: "", createdAt: now, updatedAt: now };
}
function defaultAdminSettings(): AdminSettings {
return {
public: {
modelChannel: { availableModels: [], modelCosts: [], defaultModel: "", defaultImageModel: "", defaultVideoModel: "", defaultTextModel: "", systemPrompt: "", allowCustomChannel: true },
auth: { allowRegister: false, linuxDo: { enabled: false } },
},
private: { channels: [], promptSync: { enabled: false, cron: "" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } },
};
}
function defaultAiConfig() {
return {
channelMode: "local" as const,
baseUrl: "",
apiKey: "",
model: "",
imageModel: "",
videoModel: "",
textModel: "",
audioModel: "",
audioVoice: "",
audioFormat: "",
audioSpeed: "",
audioInstructions: "",
videoSeconds: "",
vquality: "",
videoGenerateAudio: "",
videoWatermark: "",
systemPrompt: "",
models: [],
imageModels: [],
videoModels: [],
textModels: [],
audioModels: [],
quality: "",
size: "",
count: "",
canvasImageCount: "",
};
}
+15 -3
View File
@@ -1,5 +1,3 @@
import { apiGet, compactApiParams } from "@/services/api/request";
export type AssetLibraryItem = {
id: string;
title: string;
@@ -29,5 +27,19 @@ export type AssetLibraryQuery = {
};
export async function fetchAssetLibrary(query: AssetLibraryQuery = {}) {
return apiGet<AssetLibraryResponse>("/api/assets", compactApiParams(query));
const items: AssetLibraryItem[] = [];
const filtered = items.filter((item) => {
const keyword = query.keyword?.trim().toLowerCase();
if (query.type && item.type !== query.type) return false;
if (query.tag?.length && !query.tag.some((tag) => item.tags.includes(tag))) return false;
if (!keyword) return true;
return [item.title, item.description, item.content, item.url, item.category, ...item.tags].join(" ").toLowerCase().includes(keyword);
});
const page = Math.max(1, Number(query.page) || 1);
const pageSize = Math.max(1, Number(query.pageSize) || filtered.length || 1);
return {
items: filtered.slice((page - 1) * pageSize, page * pageSize),
tags: Array.from(new Set(filtered.flatMap((item) => item.tags).filter(Boolean))),
total: filtered.length,
};
}
+7 -19
View File
@@ -3,27 +3,16 @@ import axios from "axios";
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig) {
const token = useUserStore.getState().token;
return config.channelMode === "remote"
? {
...(token ? { Authorization: `Bearer ${token}` } : {}),
"Content-Type": "application/json",
}
: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
return {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
};
}
export async function requestAudioGeneration(config: AiConfig, prompt: string): Promise<Blob> {
@@ -46,7 +35,6 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string):
{ headers: aiHeaders(config), responseType: "blob" },
);
await assertAudioBlob(response.data);
refreshRemoteUser(config);
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
} catch (error) {
throw new Error(readAxiosError(error, "音频生成失败"));
@@ -60,8 +48,8 @@ export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<U
function assertAudioConfig(config: AiConfig, model: string) {
if (!model) throw new Error("请先配置音频模型");
if (config.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
}
async function assertAudioBlob(blob: Blob) {
+4 -5
View File
@@ -1,5 +1,3 @@
import { apiGet, apiPost } from "@/services/api/request";
export const AUTH_TOKEN_KEY = "infinite-canvas-auth-token-v1";
export type UserRole = "guest" | "user" | "admin";
@@ -26,13 +24,14 @@ export type AuthPayload = {
};
export async function login(payload: AuthPayload) {
return apiPost<AuthSession>("/api/auth/login", payload);
const now = new Date().toISOString();
return { token: "", user: { id: "local-user", username: payload.username || "local", displayName: payload.username || "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: now, updatedAt: now } };
}
export async function register(payload: AuthPayload) {
return apiPost<AuthSession>("/api/auth/register", payload);
return login(payload);
}
export async function fetchCurrentUser(token?: string) {
return apiGet<AuthUser>("/api/auth/me", undefined, token);
return { id: "local-user", username: "local", displayName: "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: "", updatedAt: "" };
}
+5 -20
View File
@@ -1,7 +1,6 @@
import axios from "axios";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
@@ -169,24 +168,14 @@ function withSystemPrompt(config: AiConfig, prompt: string) {
}
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
const token = useUserStore.getState().token;
return config.channelMode === "remote"
? {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(contentType ? { "Content-Type": contentType } : {}),
}
: {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
return {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) {
@@ -215,7 +204,6 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
},
);
const images = parseImagePayload(response.data);
refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
@@ -246,7 +234,6 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
try {
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
const images = parseImagePayload(response.data);
refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
@@ -311,12 +298,10 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
refreshRemoteUser(config);
return answer || "没有返回内容";
}
export async function fetchImageModels(config: AiConfig) {
if (config.channelMode === "remote") return config.models;
try {
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
headers: {
+5 -3
View File
@@ -1,4 +1,4 @@
import { apiGet, compactApiParams } from "@/services/api/request";
import { compactApiParams, serializeApiParams } from "@/services/api/request";
export type Prompt = {
id: string;
@@ -23,8 +23,7 @@ export type PromptListResponse = {
};
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page, pageSize }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
return apiGet<PromptListResponse>(
"/api/prompts",
const params = serializeApiParams(
compactApiParams({
...(keyword ? { keyword } : {}),
...(tag.length ? { tag } : {}),
@@ -33,6 +32,9 @@ export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROM
...(pageSize ? { pageSize } : {}),
}),
);
const response = await fetch(`/api/prompts${params.size ? `?${params}` : ""}`);
if (!response.ok) throw new Error("获取提示词失败");
return (await response.json()) as PromptListResponse;
}
export function formatPromptDate(value: string) {
-66
View File
@@ -1,13 +1,5 @@
import axios from "axios";
export type ApiParams = Record<string, string | string[] | number | number[] | undefined>;
type ApiResponse<T> = {
code: number;
data: T;
msg: string;
};
export function compactApiParams(params: ApiParams) {
return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0))) as ApiParams;
}
@@ -21,61 +13,3 @@ export function serializeApiParams(params?: ApiParams) {
}
return queryParams;
}
export async function apiGet<T>(url: string, params?: ApiParams, token?: string) {
return apiRequest<T>({
url,
method: "GET",
params: params || undefined,
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
}
export async function apiPost<T>(url: string, body?: unknown, token?: string) {
return apiRequest<T>({
url,
method: "POST",
data: body ?? {},
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}
export async function apiDelete<T>(url: string, token?: string) {
return apiRequest<T>({
url,
method: "DELETE",
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
}
async function apiRequest<T>(config: { url: string; method: "GET" | "POST" | "DELETE"; params?: ApiParams; data?: unknown; headers?: Record<string, string> }) {
let response;
try {
response = await axios.request<ApiResponse<T>>({
url: config.url,
method: config.method,
params: config.params,
paramsSerializer: { serialize: (params) => serializeApiParams(params as ApiParams).toString() },
data: config.data,
headers: config.headers,
validateStatus: () => true,
});
} catch {
throw new Error("接口连接失败,请确认后端服务已启动");
}
const result = response.data;
if (!result || typeof result !== "object") {
throw new Error(response.status === 404 ? "接口不存在,请确认后端服务已启动" : "接口返回异常,请稍后重试");
}
const payload = result as ApiResponse<T>;
if (response.status < 200 || response.status >= 300 || payload.code !== 0) {
throw new Error(payload.msg || "请求失败");
}
return payload.data;
}
+21 -43
View File
@@ -5,7 +5,6 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil
import { imageToDataUrl } from "@/services/image-storage";
import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -18,31 +17,20 @@ type SeedanceTask = {
content?: { video_url?: string; last_frame_url?: string } | null;
};
type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
type ReferenceMediaUploadResponse = { id: string; url: string; mimeType: string; bytes: number };
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance"; model: string };
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
const token = useUserStore.getState().token;
return config.channelMode === "remote"
? {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(contentType ? { "Content-Type": contentType } : {}),
}
: {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
return {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise<VideoGenerationResult> {
@@ -102,11 +90,10 @@ async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: st
async function pollOpenAIVideoTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
try {
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config) })).data);
if (video.status === "completed") {
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined, responseType: "blob" });
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob" });
await assertVideoBlob(content.data);
refreshRemoteUser(config);
return { status: "completed", result: { blob: content.data } };
}
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" };
@@ -145,11 +132,10 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
try {
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config) })).data);
if (state.status === "succeeded") {
const url = state.content?.video_url;
if (!url) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
refreshRemoteUser(config);
return { status: "completed", result: await videoResultFromUrl(url) };
}
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: state.error?.message || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
@@ -182,7 +168,6 @@ function assertSeedanceAudioReferences(audioReferences: ReferenceAudio[]) {
}
function seedanceApiUrl(config: AiConfig, taskId?: string) {
if (config.channelMode === "remote") return taskId ? `/api/v1/videos/${encodeURIComponent(taskId)}` : "/api/v1/videos";
return buildApiUrl(config.baseUrl, `/contents/generations/tasks${taskId ? `/${encodeURIComponent(taskId)}` : ""}`);
}
@@ -207,9 +192,6 @@ async function resolveSeedanceImageUrl(config: AiConfig, image: ReferenceImage)
if (isPublicMediaUrl(directUrl) || directUrl.startsWith("asset://")) return directUrl;
const dataUrl = await imageToDataUrl(image);
if (!dataUrl) throw new Error("参考图读取失败,请换一张图片或重新上传");
if (config.channelMode === "remote") {
return uploadReferenceMedia(dataUrlToFile({ ...image, dataUrl }));
}
return dataUrl;
}
@@ -219,8 +201,7 @@ async function resolveSeedanceVideoUrl(video: ReferenceVideo) {
if (video.storageKey) blob = await getMediaBlob(video.storageKey);
if (!blob && video.url?.startsWith("blob:")) blob = await (await fetch(video.url)).blob();
if (!blob) throw new Error("参考视频必须是公网 URL、素材 ID,或本地已保存的视频");
const file = new File([blob], video.name || "reference-video.mp4", { type: video.type || blob.type || "video/mp4" });
return uploadReferenceMedia(file);
return blobToDataUrl(blob);
}
async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
@@ -229,19 +210,7 @@ async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
if (audio.storageKey) blob = await getMediaBlob(audio.storageKey);
if (!blob && audio.url?.startsWith("blob:")) blob = await (await fetch(audio.url)).blob();
if (!blob) throw new Error("参考音频必须是公网 URL、素材 ID,或本地已保存的音频");
const file = new File([blob], audio.name || "reference-audio.mp3", { type: audio.type || blob.type || "audio/mpeg" });
return uploadReferenceMedia(file);
}
async function uploadReferenceMedia(file: File) {
const token = useUserStore.getState().token;
if (!token) throw new Error("使用本地参考素材需要先登录,并在服务端配置 PUBLIC_BASE_URL");
const body = new FormData();
body.append("file", file, file.name);
const response = await axios.post<ApiEnvelope<ReferenceMediaUploadResponse>>("/api/v1/media/references", body, { headers: { Authorization: `Bearer ${token}` } });
const payload = unwrapEnvelope(response.data, "参考素材上传失败");
if (!payload.url) throw new Error("参考素材上传后没有返回公网 URL");
return payload.url;
return blobToDataUrl(blob);
}
async function videoResultFromUrl(url: string): Promise<VideoGenerationResult> {
@@ -256,8 +225,8 @@ async function videoResultFromUrl(url: string): Promise<VideoGenerationResult> {
function assertVideoConfig(config: AiConfig, model: string) {
if (!model) throw new Error("请先配置视频模型");
if (config.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
}
function normalizeVideoSeconds(value: string) {
@@ -330,3 +299,12 @@ function isPublicMediaUrl(value: string) {
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取本地素材失败"));
reader.readAsDataURL(blob);
});
}
+20 -40
View File
@@ -4,7 +4,6 @@ import { useMemo } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { apiGet } from "@/services/api/request";
import type { AdminPublicSettings } from "@/services/api/admin";
export type AiConfig = {
@@ -102,40 +101,6 @@ type ConfigStore = {
clearPromptContinue: () => void;
};
function resolveEffectiveConfig(config: AiConfig, modelChannel: AdminPublicSettings["modelChannel"] | null) {
const channelMode = modelChannel?.allowCustomChannel ? config.channelMode : "remote";
if (channelMode === "local" || !modelChannel) return { ...config, channelMode };
const models = modelChannel.availableModels;
const textModels = filterModelsByCapability(models, "text");
const imageModels = filterModelsByCapability(models, "image");
const videoModels = filterModelsByCapability(models, "video");
const audioModels = filterModelsByCapability(models, "audio");
const fallbackTextModel = validDefault(modelChannel.defaultTextModel, textModels) || preferredModel(textModels, isTextModelName);
const fallbackModel = validDefault(modelChannel.defaultModel, textModels) || fallbackTextModel;
const fallbackImageModel = validDefault(modelChannel.defaultImageModel, imageModels) || preferredModel(imageModels, isImageModelName);
const fallbackVideoModel = validDefault(modelChannel.defaultVideoModel, videoModels) || preferredModel(videoModels, isVideoModelName);
const fallbackAudioModel = preferredModel(audioModels, isAudioModelName);
return {
...config,
channelMode,
models,
imageModels,
videoModels,
textModels,
audioModels,
model: textModels.includes(config.model) ? config.model : fallbackModel,
imageModel: imageModels.includes(config.imageModel) ? config.imageModel : fallbackImageModel,
videoModel: videoModels.includes(config.videoModel) ? config.videoModel : fallbackVideoModel,
textModel: textModels.includes(config.textModel) ? config.textModel : fallbackTextModel || fallbackModel,
audioModel: audioModels.includes(config.audioModel) ? config.audioModel : fallbackAudioModel,
systemPrompt: modelChannel.systemPrompt,
};
}
function validDefault(model: string, models: string[]) {
return models.includes(model) ? model : "";
}
function preferredModel(models: string[], predicate: (model: string) => boolean) {
return models.find(predicate) || "";
}
@@ -181,7 +146,7 @@ function modelListKey(capability: ModelCapability) {
}
function isAiConfigReady(config: AiConfig, model: string) {
return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim()));
return Boolean(model.trim() && config.baseUrl.trim() && config.apiKey.trim());
}
export const useConfigStore = create<ConfigStore>()(
@@ -211,7 +176,7 @@ export const useConfigStore = create<ConfigStore>()(
if (get().isPublicSettingsLoading) return;
set({ isPublicSettingsLoading: true });
try {
set({ publicSettings: await apiGet<AdminPublicSettings>("/api/settings") });
set({ publicSettings: defaultPublicSettings(get().config) });
} finally {
set({ isPublicSettingsLoading: false });
}
@@ -234,7 +199,7 @@ export const useConfigStore = create<ConfigStore>()(
webdav: { ...defaultWebdavSyncConfig, ...persistedWebdav },
config: {
...config,
channelMode: config.channelMode || "remote",
channelMode: "local",
imageModel: config.imageModel || config.model,
videoModel: config.videoModel || "grok-imagine-video",
textModel: config.textModel || config.model,
@@ -265,8 +230,23 @@ function normalizeModelList(models: string[]) {
export function useEffectiveConfig() {
const config = useConfigStore((state) => state.config);
const modelChannel = useConfigStore((state) => state.publicSettings?.modelChannel || null);
return useMemo(() => resolveEffectiveConfig(config, modelChannel), [config, modelChannel]);
return useMemo(() => ({ ...config, channelMode: "local" as const }), [config]);
}
function defaultPublicSettings(config: AiConfig): AdminPublicSettings {
return {
modelChannel: {
availableModels: config.models,
modelCosts: [],
defaultModel: config.model,
defaultImageModel: config.imageModel,
defaultVideoModel: config.videoModel,
defaultTextModel: config.textModel,
systemPrompt: config.systemPrompt,
allowCustomChannel: true,
},
auth: { allowRegister: false, linuxDo: { enabled: false } },
};
}
export function buildApiUrl(baseUrl: string, path: string) {
+7 -34
View File
@@ -3,7 +3,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { AUTH_TOKEN_KEY, fetchCurrentUser, login, register, type AuthPayload, type AuthUser } from "@/services/api/auth";
import { AUTH_TOKEN_KEY, type AuthPayload, type AuthUser } from "@/services/api/auth";
type UserStore = {
token: string;
@@ -27,44 +27,17 @@ export const useUserStore = create<UserStore>()(
setSession: (token, user) => set({ token, user, isReady: true }),
clearSession: () => set({ token: "", user: null, isReady: true }),
hydrateUser: async () => {
const token = get().token;
if (!token) {
set({ user: null, isReady: true });
return;
}
set({ isLoading: true });
try {
const user = await fetchCurrentUser(token);
if (user.role === "guest") {
set({ token: "", user: null, isReady: true, isLoading: false });
return;
}
set({ user, isReady: true, isLoading: false });
} catch {
set({ token: "", user: null, isReady: true, isLoading: false });
}
set({ token: "", user: null, isReady: true, isLoading: false });
},
login: async (payload) => {
set({ isLoading: true });
try {
const session = await login(payload);
set({ token: session.token, user: session.user, isReady: true, isLoading: false });
return session.user;
} catch (error) {
set({ isLoading: false });
throw error;
}
const now = new Date().toISOString();
const user: AuthUser = { id: "local-user", username: payload.username || "local", displayName: payload.username || "本地用户", avatarUrl: "", role: "user", credits: 0, createdAt: now, updatedAt: now };
set({ token: "", user, isReady: true, isLoading: false });
return user;
},
register: async (payload) => {
set({ isLoading: true });
try {
const session = await register(payload);
set({ token: session.token, user: session.user, isReady: true, isLoading: false });
return session.user;
} catch (error) {
set({ isLoading: false });
throw error;
}
return get().login(payload);
},
}),
{