feat(admin): 添加用户管理和Linux.do登录功能

- 新增AdminUser类型定义及用户管理API接口
- 实现用户列表查询、保存和删除功能
- 添加Linux.do第三方登录支持
- 集成Linux.do OAuth认证流程
- 在系统设置中添加Linux.do登录配置选项
- 实现算力点余额调整记录功能
- 优化搜索组件关键词状态管理
- 更新数据库迁移添加credit_logs表
- 完善用户状态和信用等级验证逻辑
This commit is contained in:
HouYunFei
2026-05-25 11:21:09 +08:00
parent f871a5d015
commit b2cae2471d
25 changed files with 1023 additions and 117 deletions
+13 -3
View File
@@ -23,6 +23,7 @@ export default function AdminAssetsPage() {
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
const copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>();
const [keywordText, setKeywordText] = useState(keyword);
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
@@ -33,6 +34,8 @@ export default function AdminAssetsPage() {
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
}, [editingAsset, form]);
useEffect(() => setKeywordText(keyword), [keyword]);
const saveAsset = async () => {
const value = await form.validateFields();
const nextType = value.type || "text";
@@ -119,7 +122,7 @@ export default function AdminAssetsPage() {
<Row gutter={16} align="bottom">
<Col flex="360px">
<Form.Item label="关键词">
<Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} />
<Input.Search value={keywordText} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={() => searchAssets(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
</Form.Item>
</Col>
<Col flex="180px">
@@ -135,8 +138,15 @@ export default function AdminAssetsPage() {
<Col flex="none">
<Form.Item>
<Space>
<Button onClick={resetFilters}></Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}>
<Button
onClick={() => {
setKeywordText("");
resetFilters();
}}
>
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchAssets(keywordText)}>
</Button>
</Space>
+12 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined } from "@ant-design/icons";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined, UserOutlined } from "@ant-design/icons";
import { Button, Flex, Layout, Menu, Typography, theme } from "antd";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -12,6 +12,7 @@ import { adminLayoutStyle } from "@/lib/app-theme";
import { useUserStore } from "@/stores/use-user-store";
const adminMenus = [
{ key: "/admin/users", icon: <UserOutlined />, label: "用户管理" },
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
@@ -25,8 +26,16 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
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" : "";
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
const activeKey = pathname.startsWith("/admin/settings")
? "/admin/settings"
: pathname.startsWith("/admin/assets")
? "/admin/assets"
: pathname.startsWith("/admin/prompts")
? "/admin/prompts"
: pathname.startsWith("/admin/users")
? "/admin/users"
: "";
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : pathname.startsWith("/admin/prompts") ? "提示词管理" : "用户管理";
useEffect(() => {
if (!isReady) return;
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/assets");
redirect("/admin/users");
}
+13 -3
View File
@@ -36,6 +36,7 @@ export default function AdminPromptsPage() {
} = useAdminPrompts();
const copyText = useCopyText();
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
const [keywordText, setKeywordText] = useState(keyword);
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null);
@@ -51,6 +52,8 @@ export default function AdminPromptsPage() {
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
}, [editingPrompt, form]);
useEffect(() => setKeywordText(keyword), [keyword]);
const savePrompt = async () => {
const value = await form.validateFields();
await saveAdminPrompt({
@@ -135,7 +138,7 @@ export default function AdminPromptsPage() {
<Row gutter={16} align="bottom">
<Col flex="360px">
<Form.Item label="关键词">
<Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} />
<Input.Search value={keywordText} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={() => searchPrompts(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
</Form.Item>
</Col>
<Col flex="220px">
@@ -151,8 +154,15 @@ export default function AdminPromptsPage() {
<Col flex="none">
<Form.Item>
<Space>
<Button onClick={resetFilters}></Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}>
<Button
onClick={() => {
setKeywordText("");
resetFilters();
}}
>
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchPrompts(keywordText)}>
</Button>
</Space>
+67 -1
View File
@@ -35,8 +35,9 @@ const emptySettings: AdminSettings = {
systemPrompt: "",
allowCustomChannel: true,
},
auth: { linuxDo: { enabled: false } },
},
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" } },
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "", redirectUri: "", minimumTrustLevel: 0 } } },
};
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
@@ -61,7 +62,9 @@ export default function AdminSettingsPage() {
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [linuxDoCallbackUrl, setLinuxDoCallbackUrl] = useState("");
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
const linuxDoRedirectUri = Form.useWatch(["private", "auth", "linuxDo", "redirectUri"], form);
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]);
const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]);
@@ -91,6 +94,16 @@ export default function AdminSettingsPage() {
void loadSettings();
}, [token]);
useEffect(() => {
setLinuxDoCallbackUrl(`${window.location.origin}/api/auth/linux-do/callback`);
}, []);
useEffect(() => {
if (!linuxDoCallbackUrl) return;
if (linuxDoRedirectUri) return;
form.setFieldValue(["private", "auth", "linuxDo", "redirectUri"], linuxDoCallbackUrl);
}, [form, linuxDoCallbackUrl, linuxDoRedirectUri]);
const changeTab = (nextTab: SettingsTabKey) => {
setActiveTab(nextTab);
};
@@ -365,6 +378,46 @@ export default function AdminSettingsPage() {
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Flex vertical gap={12}>
<Alert showIcon type="warning" title="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。" />
<Card
size="small"
title={
<Space>
<img src="/icons/linuxdo.svg" alt="" width={18} height={18} />
Linux.do
</Space>
}
>
<Flex vertical gap={14}>
<Alert showIcon type="info" message={linuxDoCallbackUrl ? `回调 URL 填 ${linuxDoCallbackUrl}` : "回调 URL 使用当前站点的 /api/auth/linux-do/callback"} />
<Row gutter={16}>
<Col xs={24} md={6}>
<Form.Item name={["public", "auth", "linuxDo", "enabled"]} label="开启 Linux.do 登录" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col xs={24} md={9}>
<Form.Item name={["private", "auth", "linuxDo", "clientId"]} label="Linux.do Client ID">
<Input placeholder="输入 Linux.do OAuth App 的 ID" />
</Form.Item>
</Col>
<Col xs={24} md={9}>
<Form.Item name={["private", "auth", "linuxDo", "clientSecret"]} label="Linux.do Client Secret">
<Input.Password placeholder="留空则沿用已保存的密钥" />
</Form.Item>
</Col>
<Col xs={24} md={18}>
<Form.Item name={["private", "auth", "linuxDo", "redirectUri"]} label="回调 URL">
<Input placeholder={linuxDoCallbackUrl || "https://your-domain.com/api/auth/linux-do/callback"} />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name={["private", "auth", "linuxDo", "minimumTrustLevel"]} label="最低信任等级">
<InputNumber min={0} max={4} precision={0} className="!w-full" placeholder="0" />
</Form.Item>
</Col>
</Row>
</Flex>
</Card>
<Card size="small" title="提示词定时同步">
<Row gutter={16} align="middle">
<Col xs={24} md={8}>
@@ -600,6 +653,11 @@ function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}):
...(setting.modelChannel || {}),
availableModels: setting.modelChannel?.availableModels || [],
},
auth: {
linuxDo: {
enabled: setting.auth?.linuxDo?.enabled === true,
},
},
};
}
@@ -610,6 +668,14 @@ function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}
enabled: setting.promptSync?.enabled !== false,
cron: setting.promptSync?.cron || "*/5 * * * *",
},
auth: {
linuxDo: {
clientId: setting.auth?.linuxDo?.clientId || "",
clientSecret: setting.auth?.linuxDo?.clientSecret || "",
redirectUri: setting.auth?.linuxDo?.redirectUri || "",
minimumTrustLevel: Math.max(0, Number(setting.auth?.linuxDo?.minimumTrustLevel) || 0),
},
},
};
}
+244
View File
@@ -0,0 +1,244 @@
"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, Flex, Form, Input, InputNumber, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
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" },
];
export default function AdminUsersPage() {
const { users, keyword, page, pageSize, total, isLoading, searchUsers, changePage, changePageSize, resetFilters, refreshUsers, saveUser: saveAdminUser, 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", credits: 0, ...editingUser, password: "" });
}, [editingUser, form]);
const saveUser = async () => {
const value = await form.validateFields();
await saveAdminUser({ ...editingUser, ...value, password: value.password || undefined });
setEditingUser(null);
};
const columns: ProColumns<AdminUser>[] = [
{
title: "用户",
dataIndex: "username",
width: 260,
render: (_, item) => (
<Flex align="center" gap={10} style={{ minWidth: 0 }}>
<Avatar src={item.avatarUrl}>{(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 || "-"}</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", credits: 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)),
}}
/>
</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}>
<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>
<Col span={12}>
<Form.Item name="credits" label="算力点">
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="avatarUrl" label="头像 URL">
<Input />
</Form.Item>
</Col>
</Row>
</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>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { 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 : "删除失败"),
});
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,
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),
deleteUser: (id: string) => deleteMutation.mutateAsync(id),
};
}
+56 -9
View File
@@ -1,10 +1,12 @@
"use client";
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { App, Button, Form, Input } from "antd";
import { App, Button, Form, Input, Segmented, Space } from "antd";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { Suspense, useEffect, useState } from "react";
import { fetchCurrentUser } from "@/services/api/auth";
import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
type LoginFormValues = {
@@ -26,13 +28,35 @@ function LoginContent() {
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 [mode, setMode] = useState<"login" | "register">("login");
const redirect = 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.startsWith("/") ? redirect : "/");
router.refresh();
});
}, [message, redirect, router, searchParams, setSession]);
const submit = async (values: LoginFormValues) => {
try {
const user = await login({ username: values.username, password: values.password });
message.success("登录成功");
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.startsWith("/") ? redirect : "/");
router.refresh();
if (user.role !== "admin") router.replace("/");
@@ -53,20 +77,43 @@ function LoginContent() {
}}
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"></p>
<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>
</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={[
{ label: "登录", value: "login" },
{ label: "注册", value: "register" },
]}
/>
</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>
<Button block type="primary" htmlType="submit" loading={isLoading}>
</Button>
{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 direction="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>
+1
View File
@@ -35,6 +35,7 @@ async function proxy(request: NextRequest, context: RouteContext) {
headers: proxyHeaders(request),
body: hasBody ? request.body : undefined,
duplex: hasBody ? "half" : undefined,
redirect: "manual",
} as RequestInit & { duplex?: "half" });
return new Response(response.body, {
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties, RefObject } from "react";
import { Dropdown } from "antd";
import { Avatar, Dropdown } from "antd";
import { Keyboard, LogOut, Settings2, Shield } from "lucide-react";
import type { ItemType } from "antd/es/menu/interface";
import Link from "next/link";
@@ -32,14 +32,15 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
const logout = useUserStore((state) => state.clearSession);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const canvasTheme = canvasThemes[theme];
const userName = user?.username || "用户";
const userName = user?.displayName || user?.username || "用户";
const avatarUrl = user?.avatarUrl?.trim();
const avatarText = (userName.trim()[0] || "U").toUpperCase();
const naturalIconClass = "inline-flex size-8 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";
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
const versionStyle = iconStyle;
const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined;
const gitHubStyle = iconStyle;
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined;
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> }] : []),
@@ -60,13 +61,16 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
<div ref={accountRef}>
<Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
<button
type="button"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-stone-300 bg-transparent p-0 text-xs font-semibold leading-none text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
style={avatarStyle}
aria-label="账户菜单"
>
<span className="leading-none">{avatarText}</span>
<button type="button" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-transparent p-0 text-[0] leading-[0] transition" aria-label="账户菜单">
<Avatar
size={28}
src={avatarUrl ? <img src={avatarUrl} alt={userName} referrerPolicy="no-referrer" /> : undefined}
alt={userName}
className="!flex !items-center !justify-center border border-stone-300 bg-transparent text-xs font-semibold text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
style={avatarStyle}
>
{avatarText}
</Avatar>
</button>
</Dropdown>
</div>
+54
View File
@@ -10,6 +10,47 @@ export type AdminPromptCategory = {
remote: boolean;
};
export type AdminUser = {
id: string;
username: string;
email: string;
displayName: string;
avatarUrl: string;
role: "user" | "admin";
credits: number;
affCode: string;
affCount: number;
inviterId: string;
linuxDoId: string;
status: "active" | "ban";
lastLoginAt: string;
createdAt: string;
updatedAt: string;
};
export type AdminUserListResponse = {
items: AdminUser[];
total: number;
};
export type AdminUserQuery = {
keyword?: string;
page?: number;
pageSize?: number;
};
export async function fetchAdminUsers(token: string, query: AdminUserQuery = {}) {
return apiGet<AdminUserListResponse>("/api/admin/users", compactApiParams(query), token);
}
export async function saveAdminUser(token: string, user: Partial<AdminUser> & { password?: string }) {
return apiPost<AdminUser>("/api/admin/users", user, token);
}
export async function deleteAdminUser(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/users/${encodeURIComponent(id)}`, token);
}
export async function fetchAdminPromptCategories(token: string) {
return apiGet<AdminPromptCategory[]>("/api/admin/prompt-categories", undefined, token);
}
@@ -105,6 +146,11 @@ export type AdminPublicModelChannelSettings = {
export type AdminPublicSettings = {
modelChannel: AdminPublicModelChannelSettings;
auth: {
linuxDo: {
enabled: boolean;
};
};
};
export type AdminPrivateSettings = {
@@ -113,6 +159,14 @@ export type AdminPrivateSettings = {
enabled: boolean;
cron: string;
};
auth: {
linuxDo: {
clientId: string;
clientSecret: string;
redirectUri: string;
minimumTrustLevel: number;
};
};
};
export type AdminSettings = {
+4 -1
View File
@@ -7,7 +7,10 @@ export type UserRole = "guest" | "user" | "admin";
export type AuthUser = {
id: string;
username: string;
displayName: string;
avatarUrl: string;
role: UserRole;
credits: number;
createdAt: string;
updatedAt: string;
};
@@ -23,7 +26,7 @@ export type AuthPayload = {
};
export async function login(payload: AuthPayload) {
return apiPost<AuthSession>("/api/admin/login", payload);
return apiPost<AuthSession>("/api/auth/login", payload);
}
export async function register(payload: AuthPayload) {