feat(admin): 添加管理员用户算力点管理和日志功能
- 在管理员API中新增用户算力点调整功能和信用日志相关接口 - 实现后端用户算力点调整逻辑和信用日志的增删改查操作 - 在管理员界面用户管理页面添加算力点调整功能 - 新增独立的算力点日志管理页面,支持日志查看和编辑 - 添加算力点日志相关的数据模型和服务函数 - 更新数据库文档说明第三方资料存储结构 - 添加dayjs依赖用于时间格式化显示
This commit is contained in:
@@ -44,7 +44,7 @@
|
|||||||
| `wechat_id` | string | 微信用户 ID,规划字段 |
|
| `wechat_id` | string | 微信用户 ID,规划字段 |
|
||||||
| `status` | string | 用户状态:`active`、`ban` |
|
| `status` | string | 用户状态:`active`、`ban` |
|
||||||
| `last_login_at` | string | 最近登录时间 |
|
| `last_login_at` | string | 最近登录时间 |
|
||||||
| `extra` | json | 扩展信息 |
|
| `extra` | json | 扩展信息,第三方资料按平台命名空间保存,如 `linuxDo` |
|
||||||
| `created_at` | string | 创建时间 |
|
| `created_at` | string | 创建时间 |
|
||||||
| `updated_at` | string | 更新时间 |
|
| `updated_at` | string | 更新时间 |
|
||||||
|
|
||||||
|
|||||||
@@ -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` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。
|
- 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `input_reference` 参考图生成视频,并按 OpenAI 视频接口格式提交 `multipart/form-data`、`size`、`seconds`、`vquality` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。
|
||||||
- 画布新增视频节点:工具栏可新建视频节点,上传/拖拽本地视频后可在节点内播放、下载、保存到我的素材;节点下方对话框和生成配置节点支持视频生成,前端会调用 OpenAI 兼容 `videos` 接口并把生成结果保存为本地视频 Blob;后端远程渠道新增 `/api/v1/videos`、`/api/v1/videos/:id`、`/api/v1/videos/:id/content` 代理,需要确认本地渠道和远程渠道都能生成、轮询和下载视频。
|
- 画布新增视频节点:工具栏可新建视频节点,上传/拖拽本地视频后可在节点内播放、下载、保存到我的素材;节点下方对话框和生成配置节点支持视频生成,前端会调用 OpenAI 兼容 `videos` 接口并把生成结果保存为本地视频 Blob;后端远程渠道新增 `/api/v1/videos`、`/api/v1/videos/:id`、`/api/v1/videos/:id/content` 代理,需要确认本地渠道和远程渠道都能生成、轮询和下载视频。
|
||||||
- 图片和视频节点会按素材或生成结果的真实宽高比调整节点尺寸;空图片/视频节点在尺寸设置切换到 16:9、9:16 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。
|
- 图片和视频节点会按素材或生成结果的真实宽高比调整节点尺寸;空图片/视频节点在尺寸设置切换到 16:9、9:16 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。
|
||||||
|
|||||||
+43
-4
@@ -26,12 +26,14 @@ type saveUserRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
AvatarURL string `json:"avatarUrl"`
|
|
||||||
Role model.UserRole `json:"role"`
|
Role model.UserRole `json:"role"`
|
||||||
Credits int `json:"credits"`
|
|
||||||
Status model.UserStatus `json:"status"`
|
Status model.UserStatus `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adjustUserCreditsRequest struct {
|
||||||
|
Credits int `json:"credits"`
|
||||||
|
}
|
||||||
|
|
||||||
func Register(w http.ResponseWriter, r *http.Request) {
|
func Register(w http.ResponseWriter, r *http.Request) {
|
||||||
var request registerRequest
|
var request registerRequest
|
||||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||||
@@ -112,9 +114,7 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
Username: request.Username,
|
Username: request.Username,
|
||||||
Email: request.Email,
|
Email: request.Email,
|
||||||
DisplayName: request.DisplayName,
|
DisplayName: request.DisplayName,
|
||||||
AvatarURL: request.AvatarURL,
|
|
||||||
Role: request.Role,
|
Role: request.Role,
|
||||||
Credits: request.Credits,
|
|
||||||
Status: request.Status,
|
Status: request.Status,
|
||||||
}, request.Password)
|
}, request.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,6 +124,45 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
OK(w, user)
|
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 {
|
func loginRedirect(r *http.Request, redirect string, token string, message string) string {
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
if strings.TrimSpace(token) != "" {
|
if strings.TrimSpace(token) != "" {
|
||||||
|
|||||||
@@ -93,3 +93,8 @@ type CreditLog struct {
|
|||||||
Extra string `json:"extra" gorm:"type:text"`
|
Extra string `json:"extra" gorm:"type:text"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreditLogList struct {
|
||||||
|
Items []CreditLog `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,34 @@ func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
|
|||||||
return log, db.Save(&log).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 删除指定用户。
|
// DeleteUser 删除指定用户。
|
||||||
func DeleteUser(id string) error {
|
func DeleteUser(id string) error {
|
||||||
db, err := DB()
|
db, err := DB()
|
||||||
|
|||||||
@@ -40,9 +40,17 @@ func New() *gin.Engine {
|
|||||||
admin := api.Group("/admin", middleware.AdminAuth)
|
admin := api.Group("/admin", middleware.AdminAuth)
|
||||||
admin.GET("/users", gin.WrapF(handler.AdminUsers))
|
admin.GET("/users", gin.WrapF(handler.AdminUsers))
|
||||||
admin.POST("/users", gin.WrapF(handler.AdminSaveUser))
|
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) {
|
admin.DELETE("/users/:id", func(c *gin.Context) {
|
||||||
handler.AdminDeleteUser(c.Writer, c.Request, c.Param("id"))
|
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.GET("/settings", gin.WrapF(handler.AdminSettings))
|
||||||
admin.POST("/settings", gin.WrapF(handler.AdminSaveSettings))
|
admin.POST("/settings", gin.WrapF(handler.AdminSaveSettings))
|
||||||
admin.POST("/settings/channel-models", gin.WrapF(handler.AdminChannelModels))
|
admin.POST("/settings/channel-models", gin.WrapF(handler.AdminChannelModels))
|
||||||
|
|||||||
+50
-8
@@ -28,6 +28,10 @@ type TokenClaims struct {
|
|||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type userExtra struct {
|
||||||
|
LinuxDo any `json:"linuxDo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func EnsureDefaultAdmin() error {
|
func EnsureDefaultAdmin() error {
|
||||||
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
|
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
|
||||||
return nil
|
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.AvatarURL = firstNonEmpty(linuxDoAvatar(profile.AvatarTemplate), user.AvatarURL)
|
||||||
user.LastLoginAt = now()
|
user.LastLoginAt = now()
|
||||||
user.UpdatedAt = now()
|
user.UpdatedAt = now()
|
||||||
extra, _ := json.Marshal(profile)
|
extra, _ := json.Marshal(userExtra{LinuxDo: profile})
|
||||||
user.Extra = string(extra)
|
user.Extra = string(extra)
|
||||||
user, err = repository.SaveUser(user)
|
user, err = repository.SaveUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -247,17 +251,19 @@ func SaveUser(user model.User, password string) (model.User, error) {
|
|||||||
} else if ok && saved.ID != user.ID {
|
} else if ok && saved.ID != user.ID {
|
||||||
return user, safeMessageError{message: "用户名已存在"}
|
return user, safeMessageError{message: "用户名已存在"}
|
||||||
}
|
}
|
||||||
oldCredits := 0
|
isCreate := user.ID == ""
|
||||||
if user.ID == "" {
|
if isCreate {
|
||||||
user.ID = newID("user")
|
user.ID = newID("user")
|
||||||
user.AffCode = newAffCode()
|
user.AffCode = newAffCode()
|
||||||
user.CreatedAt = now()
|
user.CreatedAt = now()
|
||||||
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
|
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
|
||||||
return user, err
|
return user, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
oldCredits = saved.Credits
|
|
||||||
user.CreatedAt = saved.CreatedAt
|
user.CreatedAt = saved.CreatedAt
|
||||||
user.Password = saved.Password
|
user.Password = saved.Password
|
||||||
|
user.AvatarURL = saved.AvatarURL
|
||||||
|
user.Credits = saved.Credits
|
||||||
|
user.Extra = saved.Extra
|
||||||
if user.AffCode == "" {
|
if user.AffCode == "" {
|
||||||
user.AffCode = saved.AffCode
|
user.AffCode = saved.AffCode
|
||||||
}
|
}
|
||||||
@@ -276,18 +282,34 @@ func SaveUser(user model.User, password string) (model.User, error) {
|
|||||||
}
|
}
|
||||||
user.Password = hash
|
user.Password = hash
|
||||||
}
|
}
|
||||||
if user.Password == "" {
|
if isCreate && user.Password == "" {
|
||||||
return user, safeMessageError{message: "密码不能为空"}
|
return user, safeMessageError{message: "密码不能为空"}
|
||||||
}
|
}
|
||||||
user.UpdatedAt = now()
|
user.UpdatedAt = now()
|
||||||
user, err := repository.SaveUser(user)
|
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{
|
_, err = repository.SaveCreditLog(model.CreditLog{
|
||||||
ID: newID("credit"),
|
ID: newID("credit"),
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Type: model.CreditLogTypeAdminAdjust,
|
Type: model.CreditLogTypeAdminAdjust,
|
||||||
Amount: user.Credits - oldCredits,
|
Amount: credits - oldCredits,
|
||||||
Balance: user.Credits,
|
Balance: credits,
|
||||||
Remark: "后台手动调整",
|
Remark: "后台手动调整",
|
||||||
CreatedAt: now(),
|
CreatedAt: now(),
|
||||||
})
|
})
|
||||||
@@ -296,6 +318,26 @@ func SaveUser(user model.User, password string) (model.User, error) {
|
|||||||
return user, err
|
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 {
|
func DeleteUser(id string) error {
|
||||||
return repository.DeleteUser(id)
|
return repository.DeleteUser(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"copy-to-clipboard": "^4.0.2",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"copy-to-clipboard": "^4.0.2",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"motion": "^12.38.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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { Button, Flex, Layout, Menu, Typography, theme } from "antd";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
@@ -13,6 +13,7 @@ import { useUserStore } from "@/stores/use-user-store";
|
|||||||
|
|
||||||
const adminMenus = [
|
const adminMenus = [
|
||||||
{ key: "/admin/users", icon: <UserOutlined />, label: "用户管理" },
|
{ key: "/admin/users", icon: <UserOutlined />, label: "用户管理" },
|
||||||
|
{ key: "/admin/credit-logs", icon: <TransactionOutlined />, label: "算力点日志" },
|
||||||
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
|
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
|
||||||
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
|
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
|
||||||
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
|
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
|
||||||
@@ -32,10 +33,12 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
? "/admin/assets"
|
? "/admin/assets"
|
||||||
: pathname.startsWith("/admin/prompts")
|
: pathname.startsWith("/admin/prompts")
|
||||||
? "/admin/prompts"
|
? "/admin/prompts"
|
||||||
|
: pathname.startsWith("/admin/credit-logs")
|
||||||
|
? "/admin/credit-logs"
|
||||||
: pathname.startsWith("/admin/users")
|
: pathname.startsWith("/admin/users")
|
||||||
? "/admin/users"
|
? "/admin/users"
|
||||||
: "";
|
: "";
|
||||||
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : pathname.startsWith("/admin/prompts") ? "提示词管理" : "用户管理";
|
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : pathname.startsWith("/admin/prompts") ? "提示词管理" : pathname.startsWith("/admin/credit-logs") ? "算力点日志" : "用户管理";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isReady) return;
|
if (!isReady) return;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
import { ProTable, type ProColumns } from "@ant-design/pro-components";
|
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 { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { AdminUser } from "@/services/api/admin";
|
import type { AdminUser } from "@/services/api/admin";
|
||||||
@@ -21,7 +22,7 @@ const statusOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
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 [form] = Form.useForm<UserFormValues>();
|
||||||
const [keywordText, setKeywordText] = useState(keyword);
|
const [keywordText, setKeywordText] = useState(keyword);
|
||||||
const [editingUser, setEditingUser] = useState<Partial<AdminUser> | null>(null);
|
const [editingUser, setEditingUser] = useState<Partial<AdminUser> | null>(null);
|
||||||
@@ -30,15 +31,22 @@ export default function AdminUsersPage() {
|
|||||||
useEffect(() => setKeywordText(keyword), [keyword]);
|
useEffect(() => setKeywordText(keyword), [keyword]);
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [editingUser, form]);
|
||||||
|
|
||||||
const saveUser = async () => {
|
const saveUser = async () => {
|
||||||
const value = await form.validateFields();
|
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);
|
setEditingUser(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveCredits = async () => {
|
||||||
|
if (!editingUser?.id) return;
|
||||||
|
await adjustCredits(editingUser.id, form.getFieldValue("credits") || 0);
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ProColumns<AdminUser>[] = [
|
const columns: ProColumns<AdminUser>[] = [
|
||||||
{
|
{
|
||||||
title: "用户",
|
title: "用户",
|
||||||
@@ -46,7 +54,7 @@ export default function AdminUsersPage() {
|
|||||||
width: 260,
|
width: 260,
|
||||||
render: (_, item) => (
|
render: (_, item) => (
|
||||||
<Flex align="center" gap={10} style={{ minWidth: 0 }}>
|
<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 }}>
|
<Flex vertical style={{ minWidth: 0 }}>
|
||||||
<Typography.Text strong ellipsis>
|
<Typography.Text strong ellipsis>
|
||||||
{item.displayName || item.username}
|
{item.displayName || item.username}
|
||||||
@@ -86,7 +94,7 @@ export default function AdminUsersPage() {
|
|||||||
title: "最近登录",
|
title: "最近登录",
|
||||||
dataIndex: "lastLoginAt",
|
dataIndex: "lastLoginAt",
|
||||||
width: 180,
|
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: "操作",
|
title: "操作",
|
||||||
@@ -161,7 +169,7 @@ export default function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
options={{ density: true, setting: true, reload: () => void refreshUsers() }}
|
options={{ density: true, setting: true, reload: () => void refreshUsers() }}
|
||||||
toolBarRender={() => [
|
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>,
|
</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>
|
<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}>
|
<Form form={form} layout="vertical" requiredMark={false}>
|
||||||
|
<Typography.Text strong>基础信息</Typography.Text>
|
||||||
<Row gutter={14}>
|
<Row gutter={14}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: "请输入用户名" }]}>
|
<Form.Item name="username" label="用户名" rules={[{ required: true, message: "请输入用户名" }]}>
|
||||||
@@ -210,17 +219,25 @@ export default function AdminUsersPage() {
|
|||||||
<Select options={statusOptions} />
|
<Select options={statusOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{editingUser?.id ? (
|
||||||
|
<>
|
||||||
|
<Divider style={{ margin: "4px 0 16px" }} />
|
||||||
|
<Typography.Text strong>算力点调整</Typography.Text>
|
||||||
|
<Row gutter={14}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="credits" label="算力点">
|
<Form.Item label="算力点">
|
||||||
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
|
<Form.Item name="credits" noStyle>
|
||||||
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
|
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
<Button onClick={() => void saveCredits()}>调整</Button>
|
||||||
<Col span={12}>
|
</Space.Compact>
|
||||||
<Form.Item name="avatarUrl" label="头像 URL">
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { App } from "antd";
|
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";
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
|
|
||||||
const defaultPageSize = 10;
|
const defaultPageSize = 10;
|
||||||
@@ -43,6 +43,15 @@ export function useAdminUsers() {
|
|||||||
onError: (error) => message.error(error instanceof Error ? error.message : "删除失败"),
|
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(() => {
|
useEffect(() => {
|
||||||
if (query.isError) {
|
if (query.isError) {
|
||||||
const errorMessage = query.error instanceof Error ? query.error.message : "读取用户失败";
|
const errorMessage = query.error instanceof Error ? query.error.message : "读取用户失败";
|
||||||
@@ -67,13 +76,14 @@ export function useAdminUsers() {
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
total: data?.total || 0,
|
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 }),
|
searchUsers: (value = keyword) => updateFilters({ keyword: value }),
|
||||||
changePage: (value: number) => updateFilters({ page: value }),
|
changePage: (value: number) => updateFilters({ page: value }),
|
||||||
changePageSize: (value: number) => updateFilters({ pageSize: value }),
|
changePageSize: (value: number) => updateFilters({ pageSize: value }),
|
||||||
resetFilters: () => updateFilters({ keyword: "", page: 1, pageSize: defaultPageSize }),
|
resetFilters: () => updateFilters({ keyword: "", page: 1, pageSize: defaultPageSize }),
|
||||||
refreshUsers: () => query.refetch(),
|
refreshUsers: () => query.refetch(),
|
||||||
saveUser: (user: Partial<AdminUser> & { password?: string }) => saveMutation.mutateAsync(user),
|
saveUser: (user: Partial<AdminUser> & { password?: string }) => saveMutation.mutateAsync(user),
|
||||||
|
adjustCredits: (id: string, credits: number) => creditMutation.mutateAsync({ id, credits }),
|
||||||
deleteUser: (id: string) => deleteMutation.mutateAsync(id),
|
deleteUser: (id: string) => deleteMutation.mutateAsync(id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ export type AdminUserListResponse = {
|
|||||||
total: number;
|
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 = {
|
export type AdminUserQuery = {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -47,10 +64,26 @@ export async function saveAdminUser(token: string, user: Partial<AdminUser> & {
|
|||||||
return apiPost<AdminUser>("/api/admin/users", user, token);
|
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) {
|
export async function deleteAdminUser(token: string, id: string) {
|
||||||
return apiDelete<boolean>(`/api/admin/users/${encodeURIComponent(id)}`, token);
|
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) {
|
export async function fetchAdminPromptCategories(token: string) {
|
||||||
return apiGet<AdminPromptCategory[]>("/api/admin/prompt-categories", undefined, token);
|
return apiGet<AdminPromptCategory[]>("/api/admin/prompt-categories", undefined, token);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user