feat(admin): 添加管理员用户算力点管理和日志功能
- 在管理员API中新增用户算力点调整功能和信用日志相关接口 - 实现后端用户算力点调整逻辑和信用日志的增删改查操作 - 在管理员界面用户管理页面添加算力点调整功能 - 新增独立的算力点日志管理页面,支持日志查看和编辑 - 添加算力点日志相关的数据模型和服务函数 - 更新数据库文档说明第三方资料存储结构 - 添加dayjs依赖用于时间格式化显示
This commit is contained in:
@@ -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,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
@@ -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) != "" {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user