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
+1 -1
View File
@@ -44,7 +44,7 @@
| `wechat_id` | string | 微信用户 ID,规划字段 |
| `status` | string | 用户状态:`active``ban` |
| `last_login_at` | string | 最近登录时间 |
| `extra` | json | 扩展信息 |
| `extra` | json | 扩展信息,第三方资料按平台命名空间保存,如 `linuxDo` |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
+1 -1
View File
@@ -1,6 +1,6 @@
# 待测试
- 用户模块新增 `users` 完整字段和 `credit_logs` 表;账号密码注册/登录已开放,后端 `/api/v1` 模型渠道接口需要登录后调用;Linux.do OAuth 登录参数移到后台系统设置,登录页按开关展示 Linux.do 按钮并使用 `public/icons/linuxdo.svg` 图标,后台 `/admin/users` 可新增、编辑、删除用户并手动调整算力点,算力点变化会写入 `credit_logs`,需要确认注册、登录、Linux.do 配置保存和回调、后台筛选分页、用户保存删除、登录态下生图/对话/视频和算力点流水都正常。
- 用户模块新增 `users` 完整字段和 `credit_logs` 表;账号密码注册/登录已开放,后端 `/api/v1` 模型渠道接口需要登录后调用;Linux.do OAuth 登录参数移到后台系统设置,登录页按开关展示 Linux.do 按钮并使用 `public/icons/linuxdo.svg` 图标,Linux.do 原始资料写入 `users.extra.linuxDo`;后台 `/admin/users` 可新增、编辑、删除用户并在编辑弹窗内调整算力点,编辑用户不修改头像和算力点,算力点变化会写入 `credit_logs`;后台新增 `/admin/credit-logs` 算力点日志表格和增删改接口,需要确认注册、登录、Linux.do 配置保存和回调、后台筛选分页、用户保存删除、算力点调整、日志增删改查、登录态下生图/对话/视频和算力点流水都正常。
- 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `input_reference` 参考图生成视频,并按 OpenAI 视频接口格式提交 `multipart/form-data``size``seconds``vquality` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。
- 画布新增视频节点:工具栏可新建视频节点,上传/拖拽本地视频后可在节点内播放、下载、保存到我的素材;节点下方对话框和生成配置节点支持视频生成,前端会调用 OpenAI 兼容 `videos` 接口并把生成结果保存为本地视频 Blob;后端远程渠道新增 `/api/v1/videos``/api/v1/videos/:id``/api/v1/videos/:id/content` 代理,需要确认本地渠道和远程渠道都能生成、轮询和下载视频。
- 图片和视频节点会按素材或生成结果的真实宽高比调整节点尺寸;空图片/视频节点在尺寸设置切换到 16:9、9:16 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。
+43 -4
View File
@@ -26,12 +26,14 @@ type saveUserRequest struct {
Password string `json:"password"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
AvatarURL string `json:"avatarUrl"`
Role model.UserRole `json:"role"`
Credits int `json:"credits"`
Status model.UserStatus `json:"status"`
}
type adjustUserCreditsRequest struct {
Credits int `json:"credits"`
}
func Register(w http.ResponseWriter, r *http.Request) {
var request registerRequest
_ = json.NewDecoder(r.Body).Decode(&request)
@@ -112,9 +114,7 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
Username: request.Username,
Email: request.Email,
DisplayName: request.DisplayName,
AvatarURL: request.AvatarURL,
Role: request.Role,
Credits: request.Credits,
Status: request.Status,
}, request.Password)
if err != nil {
@@ -124,6 +124,45 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
OK(w, user)
}
func AdminAdjustUserCredits(w http.ResponseWriter, r *http.Request, id string) {
var request adjustUserCreditsRequest
_ = json.NewDecoder(r.Body).Decode(&request)
user, err := service.AdjustUserCredits(id, request.Credits)
if err != nil {
FailError(w, err)
return
}
OK(w, user)
}
func AdminCreditLogs(w http.ResponseWriter, r *http.Request) {
logs, err := service.ListCreditLogs(parseQuery(r))
if err != nil {
FailError(w, err)
return
}
OK(w, logs)
}
func AdminSaveCreditLog(w http.ResponseWriter, r *http.Request) {
var log model.CreditLog
_ = json.NewDecoder(r.Body).Decode(&log)
result, err := service.SaveCreditLog(log)
if err != nil {
FailError(w, err)
return
}
OK(w, result)
}
func AdminDeleteCreditLog(w http.ResponseWriter, r *http.Request, id string) {
if err := service.DeleteCreditLog(id); err != nil {
FailError(w, err)
return
}
OK(w, true)
}
func loginRedirect(r *http.Request, redirect string, token string, message string) string {
values := url.Values{}
if strings.TrimSpace(token) != "" {
+5
View File
@@ -93,3 +93,8 @@ type CreditLog struct {
Extra string `json:"extra" gorm:"type:text"`
CreatedAt string `json:"createdAt"`
}
type CreditLogList struct {
Items []CreditLog `json:"items"`
Total int `json:"total"`
}
+28
View File
@@ -88,6 +88,34 @@ func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
return log, db.Save(&log).Error
}
func ListCreditLogs(q model.Query) ([]model.CreditLog, int64, error) {
db, err := DB()
if err != nil {
return nil, 0, err
}
q.Normalize()
tx := db.Model(&model.CreditLog{})
if keyword := strings.TrimSpace(q.Keyword); keyword != "" {
like := "%" + keyword + "%"
tx = tx.Where("user_id LIKE ? OR type LIKE ? OR remark LIKE ? OR related_id LIKE ?", like, like, like, like)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var logs []model.CreditLog
err = tx.Order("created_at desc").Offset(q.Offset()).Limit(q.PageSize).Find(&logs).Error
return logs, total, err
}
func DeleteCreditLog(id string) error {
db, err := DB()
if err != nil {
return err
}
return db.Delete(&model.CreditLog{}, "id = ?", id).Error
}
// DeleteUser 删除指定用户。
func DeleteUser(id string) error {
db, err := DB()
+8
View File
@@ -40,9 +40,17 @@ func New() *gin.Engine {
admin := api.Group("/admin", middleware.AdminAuth)
admin.GET("/users", gin.WrapF(handler.AdminUsers))
admin.POST("/users", gin.WrapF(handler.AdminSaveUser))
admin.POST("/users/:id/credits", func(c *gin.Context) {
handler.AdminAdjustUserCredits(c.Writer, c.Request, c.Param("id"))
})
admin.DELETE("/users/:id", func(c *gin.Context) {
handler.AdminDeleteUser(c.Writer, c.Request, c.Param("id"))
})
admin.GET("/credit-logs", gin.WrapF(handler.AdminCreditLogs))
admin.POST("/credit-logs", gin.WrapF(handler.AdminSaveCreditLog))
admin.DELETE("/credit-logs/:id", func(c *gin.Context) {
handler.AdminDeleteCreditLog(c.Writer, c.Request, c.Param("id"))
})
admin.GET("/settings", gin.WrapF(handler.AdminSettings))
admin.POST("/settings", gin.WrapF(handler.AdminSaveSettings))
admin.POST("/settings/channel-models", gin.WrapF(handler.AdminChannelModels))
+50 -8
View File
@@ -28,6 +28,10 @@ type TokenClaims struct {
jwt.RegisteredClaims
}
type userExtra struct {
LinuxDo any `json:"linuxDo,omitempty"`
}
func EnsureDefaultAdmin() error {
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
return nil
@@ -177,7 +181,7 @@ func LoginWithLinuxDo(r *http.Request, code string, state string) (model.AuthSes
user.AvatarURL = firstNonEmpty(linuxDoAvatar(profile.AvatarTemplate), user.AvatarURL)
user.LastLoginAt = now()
user.UpdatedAt = now()
extra, _ := json.Marshal(profile)
extra, _ := json.Marshal(userExtra{LinuxDo: profile})
user.Extra = string(extra)
user, err = repository.SaveUser(user)
if err != nil {
@@ -247,17 +251,19 @@ func SaveUser(user model.User, password string) (model.User, error) {
} else if ok && saved.ID != user.ID {
return user, safeMessageError{message: "用户名已存在"}
}
oldCredits := 0
if user.ID == "" {
isCreate := user.ID == ""
if isCreate {
user.ID = newID("user")
user.AffCode = newAffCode()
user.CreatedAt = now()
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
return user, err
} else if ok {
oldCredits = saved.Credits
user.CreatedAt = saved.CreatedAt
user.Password = saved.Password
user.AvatarURL = saved.AvatarURL
user.Credits = saved.Credits
user.Extra = saved.Extra
if user.AffCode == "" {
user.AffCode = saved.AffCode
}
@@ -276,18 +282,34 @@ func SaveUser(user model.User, password string) (model.User, error) {
}
user.Password = hash
}
if user.Password == "" {
if isCreate && user.Password == "" {
return user, safeMessageError{message: "密码不能为空"}
}
user.UpdatedAt = now()
user, err := repository.SaveUser(user)
if err == nil && user.Credits != oldCredits {
user.Password = ""
return user, err
}
func AdjustUserCredits(id string, credits int) (model.User, error) {
user, ok, err := repository.GetUserByID(id)
if err != nil || !ok {
if err != nil {
return user, err
}
return user, safeMessageError{message: "用户不存在"}
}
oldCredits := user.Credits
user.Credits = credits
user.UpdatedAt = now()
user, err = repository.SaveUser(user)
if err == nil && oldCredits != credits {
_, err = repository.SaveCreditLog(model.CreditLog{
ID: newID("credit"),
UserID: user.ID,
Type: model.CreditLogTypeAdminAdjust,
Amount: user.Credits - oldCredits,
Balance: user.Credits,
Amount: credits - oldCredits,
Balance: credits,
Remark: "后台手动调整",
CreatedAt: now(),
})
@@ -296,6 +318,26 @@ func SaveUser(user model.User, password string) (model.User, error) {
return user, err
}
func ListCreditLogs(q model.Query) (model.CreditLogList, error) {
logs, total, err := repository.ListCreditLogs(q)
if err != nil {
return model.CreditLogList{}, err
}
return model.CreditLogList{Items: logs, Total: int(total)}, nil
}
func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
if log.ID == "" {
log.ID = newID("credit")
log.CreatedAt = now()
}
return repository.SaveCreditLog(log)
}
func DeleteCreditLog(id string) error {
return repository.DeleteCreditLog(id)
}
func DeleteUser(id string) error {
return repository.DeleteUser(id)
}
+1
View File
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"dayjs": "^1.11.20",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
+1
View File
@@ -22,6 +22,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"dayjs": "^1.11.20",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
@@ -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),
};
}
+8 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined, UserOutlined } from "@ant-design/icons";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined, TransactionOutlined, 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";
@@ -13,6 +13,7 @@ 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: "系统设置" },
@@ -32,10 +33,12 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
? "/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") ? "提示词管理" : "用户管理";
: 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;
+34 -17
View File
@@ -2,7 +2,8 @@
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 { 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";
@@ -21,7 +22,7 @@ const statusOptions = [
];
export default function AdminUsersPage() {
const { users, keyword, page, pageSize, total, isLoading, searchUsers, changePage, changePageSize, resetFilters, refreshUsers, saveUser: saveAdminUser, deleteUser } = useAdminUsers();
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);
@@ -30,15 +31,22 @@ export default function AdminUsersPage() {
useEffect(() => setKeywordText(keyword), [keyword]);
useEffect(() => {
if (editingUser) form.setFieldsValue({ role: "user", status: "active", credits: 0, ...editingUser, password: "" });
if (editingUser) form.setFieldsValue({ role: "user", status: "active", ...editingUser, password: "" });
}, [editingUser, form]);
const saveUser = async () => {
const value = await form.validateFields();
await saveAdminUser({ ...editingUser, ...value, password: value.password || undefined });
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: "用户",
@@ -46,7 +54,7 @@ export default function AdminUsersPage() {
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>
<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}
@@ -86,7 +94,7 @@ export default function AdminUsersPage() {
title: "最近登录",
dataIndex: "lastLoginAt",
width: 180,
render: (_, item) => <Typography.Text type="secondary">{item.lastLoginAt || "-"}</Typography.Text>,
render: (_, item) => <Typography.Text type="secondary">{item.lastLoginAt ? dayjs(item.lastLoginAt).format("YYYY-MM-DD HH:mm:ss") : "-"}</Typography.Text>,
},
{
title: "操作",
@@ -161,7 +169,7 @@ export default function AdminUsersPage() {
}
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 key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingUser({ role: "user", status: "active" })}>
</Button>,
]}
@@ -179,6 +187,7 @@ export default function AdminUsersPage() {
<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: "请输入用户名" }]}>
@@ -210,17 +219,25 @@ export default function AdminUsersPage() {
<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>
{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>
@@ -4,7 +4,7 @@ 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 { adjustAdminUserCredits, deleteAdminUser, fetchAdminUsers, saveAdminUser, type AdminUser } from "@/services/api/admin";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
@@ -43,6 +43,15 @@ export function useAdminUsers() {
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 : "读取用户失败";
@@ -67,13 +76,14 @@ export function useAdminUsers() {
page,
pageSize,
total: data?.total || 0,
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
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),
};
}
+33
View File
@@ -33,6 +33,23 @@ export type AdminUserListResponse = {
total: number;
};
export type AdminCreditLog = {
id: string;
userId: string;
type: string;
amount: number;
balance: number;
relatedId: string;
remark: string;
extra: string;
createdAt: string;
};
export type AdminCreditLogListResponse = {
items: AdminCreditLog[];
total: number;
};
export type AdminUserQuery = {
keyword?: string;
page?: number;
@@ -47,10 +64,26 @@ export async function saveAdminUser(token: string, user: Partial<AdminUser> & {
return apiPost<AdminUser>("/api/admin/users", user, token);
}
export async function adjustAdminUserCredits(token: string, id: string, credits: number) {
return apiPost<AdminUser>(`/api/admin/users/${encodeURIComponent(id)}/credits`, { credits }, token);
}
export async function deleteAdminUser(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/users/${encodeURIComponent(id)}`, token);
}
export async function fetchAdminCreditLogs(token: string, query: AdminUserQuery = {}) {
return apiGet<AdminCreditLogListResponse>("/api/admin/credit-logs", compactApiParams(query), token);
}
export async function saveAdminCreditLog(token: string, log: Partial<AdminCreditLog>) {
return apiPost<AdminCreditLog>("/api/admin/credit-logs", log, token);
}
export async function deleteAdminCreditLog(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/credit-logs/${encodeURIComponent(id)}`, token);
}
export async function fetchAdminPromptCategories(token: string) {
return apiGet<AdminPromptCategory[]>("/api/admin/prompt-categories", undefined, token);
}