feat(admin): 添加管理员用户算力点管理和日志功能

- 在管理员API中新增用户算力点调整功能和信用日志相关接口
- 实现后端用户算力点调整逻辑和信用日志的增删改查操作
- 在管理员界面用户管理页面添加算力点调整功能
- 新增独立的算力点日志管理页面,支持日志查看和编辑
- 添加算力点日志相关的数据模型和服务函数
- 更新数据库文档说明第三方资料存储结构
- 添加dayjs依赖用于时间格式化显示
This commit is contained in:
HouYunFei
2026-05-25 12:57:27 +08:00
parent fe3294ed60
commit fff7074218
15 changed files with 519 additions and 38 deletions
@@ -0,0 +1,215 @@
"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>;
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>{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>
);
}
@@ -0,0 +1,79 @@
"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),
};
}