From fff70742182b2665003d112382dff911f3097c15 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Mon, 25 May 2026 12:57:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=BB=E5=8A=A0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E7=94=A8=E6=88=B7=E7=AE=97=E5=8A=9B=E7=82=B9?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=92=8C=E6=97=A5=E5=BF=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在管理员API中新增用户算力点调整功能和信用日志相关接口 - 实现后端用户算力点调整逻辑和信用日志的增删改查操作 - 在管理员界面用户管理页面添加算力点调整功能 - 新增独立的算力点日志管理页面,支持日志查看和编辑 - 添加算力点日志相关的数据模型和服务函数 - 更新数据库文档说明第三方资料存储结构 - 添加dayjs依赖用于时间格式化显示 --- docs/backend-database.md | 2 +- docs/pending-test.md | 2 +- handler/auth.go | 47 +++- model/user.go | 5 + repository/user.go | 28 +++ router/router.go | 8 + service/auth.go | 58 ++++- web/bun.lock | 1 + web/package.json | 1 + .../app/(admin)/admin/credit-logs/page.tsx | 215 ++++++++++++++++++ .../credit-logs/use-admin-credit-logs.ts | 79 +++++++ web/src/app/(admin)/admin/layout.tsx | 13 +- web/src/app/(admin)/admin/users/page.tsx | 51 +++-- .../(admin)/admin/users/use-admin-users.ts | 14 +- web/src/services/api/admin.ts | 33 +++ 15 files changed, 519 insertions(+), 38 deletions(-) create mode 100644 web/src/app/(admin)/admin/credit-logs/page.tsx create mode 100644 web/src/app/(admin)/admin/credit-logs/use-admin-credit-logs.ts diff --git a/docs/backend-database.md b/docs/backend-database.md index 2acc30b..569ab66 100644 --- a/docs/backend-database.md +++ b/docs/backend-database.md @@ -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 | 更新时间 | diff --git a/docs/pending-test.md b/docs/pending-test.md index 1828110..258613e 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -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 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。 diff --git a/handler/auth.go b/handler/auth.go index da0f635..1b7d971 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -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) != "" { diff --git a/model/user.go b/model/user.go index 17fa781..91c1872 100644 --- a/model/user.go +++ b/model/user.go @@ -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"` +} diff --git a/repository/user.go b/repository/user.go index cf77894..fcee7d8 100644 --- a/repository/user.go +++ b/repository/user.go @@ -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() diff --git a/router/router.go b/router/router.go index c1cebbd..acf5bb7 100644 --- a/router/router.go +++ b/router/router.go @@ -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)) diff --git a/service/auth.go b/service/auth.go index e8ed52d..a9b2d38 100644 --- a/service/auth.go +++ b/service/auth.go @@ -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) } diff --git a/web/bun.lock b/web/bun.lock index 2342673..d14f1aa 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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", diff --git a/web/package.json b/web/package.json index 0c50c76..6eaca4f 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/app/(admin)/admin/credit-logs/page.tsx b/web/src/app/(admin)/admin/credit-logs/page.tsx new file mode 100644 index 0000000..7e93d78 --- /dev/null +++ b/web/src/app/(admin)/admin/credit-logs/page.tsx @@ -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; + +export default function AdminCreditLogsPage() { + const { logs, keyword, page, pageSize, total, isLoading, searchLogs, changePage, changePageSize, resetFilters, refreshLogs, saveLog: saveAdminLog, deleteLog } = useAdminCreditLogs(); + const [form] = Form.useForm(); + const [keywordText, setKeywordText] = useState(keyword); + const [editingLog, setEditingLog] = useState | null>(null); + const [deletingLog, setDeletingLog] = useState(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[] = [ + { + title: "用户 ID", + dataIndex: "userId", + width: 220, + render: (_, item) => {item.userId}, + }, + { + title: "类型", + dataIndex: "type", + width: 140, + render: (_, item) => {item.type || "-"}, + }, + { + title: "变动", + dataIndex: "amount", + width: 100, + render: (_, item) => = 0 ? "success" : "danger"}>{item.amount}, + }, + { + title: "余额", + dataIndex: "balance", + width: 100, + }, + { + title: "备注", + dataIndex: "remark", + ellipsis: true, + render: (_, item) => {item.remark || "-"}, + }, + { + title: "创建时间", + dataIndex: "createdAt", + width: 180, + render: (_, item) => {item.createdAt ? dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss") : "-"}, + }, + { + title: "操作", + key: "actions", + width: 96, + align: "right", + render: (_, item) => ( + + + + + + + + + + + + rowKey="id" + columns={columns} + dataSource={logs} + loading={isLoading} + search={false} + defaultSize="middle" + tableLayout="fixed" + cardProps={{ variant: "borderless" }} + headerTitle={ + + 算力点日志 + {total} 条 + + } + options={{ density: true, setting: true, reload: () => void refreshLogs() }} + toolBarRender={() => [ + , + ]} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (value) => `共 ${value} 条`, + onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)), + }} + /> + + + setEditingLog(null)} onOk={() => void saveLog()} okText="保存" cancelText="取消" destroyOnHidden> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + setDeletingLog(null)} + onOk={async () => { + if (!deletingLog) return; + await deleteLog(deletingLog.id); + setDeletingLog(null); + }} + okText="删除" + okButtonProps={{ danger: true }} + cancelText="取消" + > + 确定删除这条算力点日志吗? + + + ); +} diff --git a/web/src/app/(admin)/admin/credit-logs/use-admin-credit-logs.ts b/web/src/app/(admin)/admin/credit-logs/use-admin-credit-logs.ts new file mode 100644 index 0000000..bd75603 --- /dev/null +++ b/web/src/app/(admin)/admin/credit-logs/use-admin-credit-logs.ts @@ -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) => 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) => saveMutation.mutateAsync(log), + deleteLog: (id: string) => deleteMutation.mutateAsync(id), + }; +} diff --git a/web/src/app/(admin)/admin/layout.tsx b/web/src/app/(admin)/admin/layout.tsx index 3882625..415098d 100644 --- a/web/src/app/(admin)/admin/layout.tsx +++ b/web/src/app/(admin)/admin/layout.tsx @@ -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: , label: "用户管理" }, + { key: "/admin/credit-logs", icon: , label: "算力点日志" }, { key: "/admin/prompts", icon: , label: "提示词管理" }, { key: "/admin/assets", icon: , label: "素材库" }, { key: "/admin/settings", icon: , 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; diff --git a/web/src/app/(admin)/admin/users/page.tsx b/web/src/app/(admin)/admin/users/page.tsx index a62ee62..82019d7 100644 --- a/web/src/app/(admin)/admin/users/page.tsx +++ b/web/src/app/(admin)/admin/users/page.tsx @@ -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(); const [keywordText, setKeywordText] = useState(keyword); const [editingUser, setEditingUser] = useState | 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[] = [ { title: "用户", @@ -46,7 +54,7 @@ export default function AdminUsersPage() { width: 260, render: (_, item) => ( - {(item.displayName || item.username || "U").slice(0, 1).toUpperCase()} + {(item.displayName || item.username || "U").slice(0, 1).toUpperCase()} {item.displayName || item.username} @@ -86,7 +94,7 @@ export default function AdminUsersPage() { title: "最近登录", dataIndex: "lastLoginAt", width: 180, - render: (_, item) => {item.lastLoginAt || "-"}, + render: (_, item) => {item.lastLoginAt ? dayjs(item.lastLoginAt).format("YYYY-MM-DD HH:mm:ss") : "-"}, }, { title: "操作", @@ -161,7 +169,7 @@ export default function AdminUsersPage() { } options={{ density: true, setting: true, reload: () => void refreshUsers() }} toolBarRender={() => [ - , ]} @@ -179,6 +187,7 @@ export default function AdminUsersPage() { setEditingUser(null)} onOk={() => void saveUser()} okText="保存" cancelText="取消" destroyOnHidden>
+ 基础信息 @@ -210,17 +219,25 @@ export default function AdminUsersPage() { - - + {editingUser?.id ? ( + <> + + 算力点调整 + + + + + + + + + + + + + + ) : null}
diff --git a/web/src/app/(admin)/admin/users/use-admin-users.ts b/web/src/app/(admin)/admin/users/use-admin-users.ts index ce4a746..2987de8 100644 --- a/web/src/app/(admin)/admin/users/use-admin-users.ts +++ b/web/src/app/(admin)/admin/users/use-admin-users.ts @@ -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 & { password?: string }) => saveMutation.mutateAsync(user), + adjustCredits: (id: string, credits: number) => creditMutation.mutateAsync({ id, credits }), deleteUser: (id: string) => deleteMutation.mutateAsync(id), }; } diff --git a/web/src/services/api/admin.ts b/web/src/services/api/admin.ts index b72f967..5838bd6 100644 --- a/web/src/services/api/admin.ts +++ b/web/src/services/api/admin.ts @@ -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 & { return apiPost("/api/admin/users", user, token); } +export async function adjustAdminUserCredits(token: string, id: string, credits: number) { + return apiPost(`/api/admin/users/${encodeURIComponent(id)}/credits`, { credits }, token); +} + export async function deleteAdminUser(token: string, id: string) { return apiDelete(`/api/admin/users/${encodeURIComponent(id)}`, token); } +export async function fetchAdminCreditLogs(token: string, query: AdminUserQuery = {}) { + return apiGet("/api/admin/credit-logs", compactApiParams(query), token); +} + +export async function saveAdminCreditLog(token: string, log: Partial) { + return apiPost("/api/admin/credit-logs", log, token); +} + +export async function deleteAdminCreditLog(token: string, id: string) { + return apiDelete(`/api/admin/credit-logs/${encodeURIComponent(id)}`, token); +} + export async function fetchAdminPromptCategories(token: string) { return apiGet("/api/admin/prompt-categories", undefined, token); }