From 68e5b0f6628bae80b81856a4961be2c3b1cd3b76 Mon Sep 17 00:00:00 2001 From: ahyang <53590964+zAhYAng@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:40:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E6=94=AF=E6=8C=81=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(canvas): 绘图工具 - 支持自定义 tldraw License Key * docs(canvas): 绘图工具 - 添加 License Key 页面截图 * feat(admin): 用户管理 - 支持管理员添加用户 --- backend/internal/handler/auth.go | 19 ++++ backend/internal/service/admin.go | 80 ++++++++++++++ .../internal/service/admin_bulk_user_test.go | 61 ++++++++++- web/src/pages/admin/users/users-drawer.tsx | 103 +++++++++++++++++- web/src/pages/admin/users/users-panel.tsx | 18 ++- web/src/services/api/auth.ts | 4 + 6 files changed, 280 insertions(+), 5 deletions(-) diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 4a61b75c2..552e04bd2 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -179,6 +179,25 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, users) }) + r.POST("/admin/users", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.CreateAdminUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + created, err := svc.CreateAdminUser(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"user": created}) + }) r.GET("/admin/references", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index ced6e9f81..a6ced374a 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -12,6 +12,14 @@ import ( "gorm.io/gorm" ) +type CreateAdminUserRequest struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Password string `json:"password"` + Role model.UserRole `json:"role"` + Status model.UserStatus `json:"status"` +} type UpdateUserRequest struct { DisplayName string `json:"displayName"` Email string `json:"email"` @@ -186,6 +194,78 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error return result, nil } +func (s *Service) CreateAdminUser(actor *model.User, req CreateAdminUserRequest) (*AdminUser, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + username := normalizeUsername(req.Username) + email := normalizeEmail(req.Email) + displayName := normalizeDisplayName(req.DisplayName, username) + if err := validateUsername(username); err != nil { + return nil, err + } + if err := validatePassword(req.Password); err != nil { + return nil, err + } + if email != "" { + if err := validateEmail(email); err != nil { + return nil, err + } + } + if req.Role != model.UserRoleAdmin && req.Role != model.UserRoleUser { + return nil, BadAuthRequest("\u7528\u6237\u89d2\u8272\u65e0\u6548") + } + if req.Status != model.UserStatusActive && req.Status != model.UserStatusDisabled { + return nil, BadAuthRequest("\u7528\u6237\u72b6\u6001\u65e0\u6548") + } + if _, err := s.repo.UserByUsername(username); err == nil { + return nil, BadAuthRequest("\u7528\u6237\u540d\u5df2\u5b58\u5728") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + if email != "" { + if _, err := s.repo.UserByEmail(email); err == nil { + return nil, BadAuthRequest("\u90ae\u7bb1\u5df2\u88ab\u6ce8\u518c") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + passwordHash, err := hashPassword(req.Password) + if err != nil { + return nil, err + } + now := time.Now() + user := &model.User{ + ID: newID(), + Username: username, + Email: email, + DisplayName: displayName, + Role: req.Role, + Status: req.Status, + PasswordHash: passwordHash, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.Create(user); err != nil { + return nil, err + } + if err := s.ensureSignupBonus(user.ID); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "user.create", "user", user.ID, "\u521b\u5efa\u7528\u6237\u8d26\u53f7", map[string]any{"role": user.Role, "status": user.Status}); err != nil { + return nil, err + } + account, err := s.repo.CreditAccount(user.ID) + if err != nil { + return nil, err + } + return &AdminUser{ + User: *user, + AvailableMicrocredits: account.AvailableMicrocredits, + ReservedMicrocredits: account.ReservedMicrocredits, + }, nil +} + func (s *Service) UpdateUser(actor *model.User, userID string, req UpdateUserRequest) (*model.User, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err diff --git a/backend/internal/service/admin_bulk_user_test.go b/backend/internal/service/admin_bulk_user_test.go index 820196090..8be720051 100644 --- a/backend/internal/service/admin_bulk_user_test.go +++ b/backend/internal/service/admin_bulk_user_test.go @@ -7,9 +7,68 @@ import ( "infinite-canvas/backend/internal/repository" "gorm.io/driver/sqlite" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) +func TestCreateAdminUserCreatesActiveUserWithPasswordAndAudit(t *testing.T) { + db := newBulkUserTestDB(t) + actor := model.User{ID: "admin-1", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + if err := db.Create(&actor).Error; err != nil { + t.Fatal(err) + } + + created, err := (&Service{repo: repository.New(db)}).CreateAdminUser(&actor, CreateAdminUserRequest{ + Username: "new-user", + DisplayName: "New User", + Email: "new-user@example.com", + Password: "strong-password", + Role: model.UserRoleUser, + Status: model.UserStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if created.Username != "new-user" || created.DisplayName != "New User" || created.Email != "new-user@example.com" { + t.Fatalf("created user = %+v", created) + } + if created.Role != model.UserRoleUser || created.Status != model.UserStatusActive { + t.Fatalf("created user role/status = %q/%q", created.Role, created.Status) + } + if err := bcrypt.CompareHashAndPassword([]byte(created.PasswordHash), []byte("strong-password")); err != nil { + t.Fatalf("password hash does not match: %v", err) + } + var account model.CreditAccount + if err := db.First(&account, "user_id = ?", created.ID).Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits <= 0 { + t.Fatalf("available credits = %d, want positive signup bonus", account.AvailableMicrocredits) + } + var audit model.AdminAuditEvent + if err := db.Where("action = ? AND target_id = ?", "user.create", created.ID).First(&audit).Error; err != nil { + t.Fatal(err) + } + if audit.ActorUserID != actor.ID { + t.Fatalf("audit actor = %q, want %q", audit.ActorUserID, actor.ID) + } +} + +func TestCreateAdminUserRejectsDuplicateUsername(t *testing.T) { + db := newBulkUserTestDB(t) + actor := model.User{ID: "admin-1", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + if err := db.Create(&actor).Error; err != nil { + t.Fatal(err) + } + svc := &Service{repo: repository.New(db)} + input := CreateAdminUserRequest{Username: "duplicate", DisplayName: "Duplicate", Password: "strong-password", Role: model.UserRoleUser, Status: model.UserStatusActive} + if _, err := svc.CreateAdminUser(&actor, input); err != nil { + t.Fatal(err) + } + if _, err := svc.CreateAdminUser(&actor, input); err == nil { + t.Fatal("CreateAdminUser() duplicate username error = nil") + } +} func TestBulkDisableUsersDisablesUsersSessionsAndWritesAudits(t *testing.T) { db := newBulkUserTestDB(t) actor := model.User{ID: "admin-1", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} @@ -95,7 +154,7 @@ func newBulkUserTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AdminAuditEvent{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AdminAuditEvent{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.SystemSetting{}); err != nil { t.Fatal(err) } return db diff --git a/web/src/pages/admin/users/users-drawer.tsx b/web/src/pages/admin/users/users-drawer.tsx index 77b905225..f4b496306 100644 --- a/web/src/pages/admin/users/users-drawer.tsx +++ b/web/src/pages/admin/users/users-drawer.tsx @@ -1,7 +1,7 @@ import { App, Button, Drawer, Form, Input, Select } from "antd"; import { useEffect, useState } from "react"; -import { updateAdminUser, type AdminUser, type LocalUser } from "@/services/api/auth"; +import { createAdminUser, updateAdminUser, type AdminUser, type LocalUser } from "@/services/api/auth"; type UserFormValues = Pick; @@ -100,3 +100,104 @@ export function AdminUserEditDrawer({ ); } + +type CreateUserFormValues = { + username: string; + displayName: string; + email?: string; + password: string; + role: LocalUser["role"]; + status: LocalUser["status"]; +}; + +export function AdminUserCreateDrawer({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated: (user: AdminUser) => void; +}) { + const { message, modal } = App.useApp(); + const [saving, setSaving] = useState(false); + const [form] = Form.useForm(); + + useEffect(() => { + if (!open) return; + form.resetFields(); + form.setFieldsValue({ role: "user", status: "active" }); + }, [form, open]); + + const close = () => { + if (saving) return; + if (!form.isFieldsTouched()) { + onClose(); + return; + } + modal.confirm({ + title: "\u653e\u5f03\u6dfb\u52a0\u7528\u6237\uff1f", + content: "\u5c1a\u672a\u4fdd\u5b58\u7684\u7528\u6237\u4fe1\u606f\u5c06\u4e22\u5931\u3002", + okText: "\u653e\u5f03\u5e76\u5173\u95ed", + cancelText: "\u7ee7\u7eed\u7f16\u8f91", + okButtonProps: { danger: true }, + onOk: onClose, + }); + }; + + const save = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + const result = await createAdminUser({ + username: values.username.trim(), + displayName: values.displayName.trim(), + email: values.email?.trim() || "", + password: values.password, + role: values.role, + status: values.status, + }); + onCreated(result.user); + form.resetFields(); + onClose(); + message.success("\u7528\u6237\u5df2\u521b\u5efa"); + } catch (error) { + message.error(error instanceof Error ? error.message : "\u521b\u5efa\u7528\u6237\u5931\u8d25"); + } finally { + setSaving(false); + } + }; + + return ( + void save()}>{"\u4fdd\u5b58"}} + > +
+ + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/web/src/pages/admin/users/users-panel.tsx b/web/src/pages/admin/users/users-panel.tsx index 79fe15f81..c43e3f7b0 100644 --- a/web/src/pages/admin/users/users-panel.tsx +++ b/web/src/pages/admin/users/users-panel.tsx @@ -1,5 +1,5 @@ import { App, Button, Checkbox, Dropdown, Input, Table, Tag } from "antd"; -import { Ban, ChevronDown, Search, Settings2 } from "lucide-react"; +import { Ban, ChevronDown, Search, Settings2, UserPlus } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ListToolbar, PaginationBar, TableSurface } from "@/components/layout/workspace-page"; @@ -10,7 +10,7 @@ import { AdminBatchBar, AdminTableEmpty, AdminTableSkeleton } from "../component import { useTableUrlState } from "../lib/use-table-url-state"; import { AdminUserDetailDrawer } from "../components/admin-user-detail-drawer"; import { createUserColumns, userColumnOptions, type UserColumnKey } from "./users-columns"; -import { AdminUserEditDrawer } from "./users-drawer"; +import { AdminUserCreateDrawer, AdminUserEditDrawer } from "./users-drawer"; const columnStorageKey = "admin-users-visible-columns"; const allColumnKeys = userColumnOptions.map((item) => item.key); @@ -25,6 +25,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L const [loading, setLoading] = useState(true); const [detailUserId, setDetailUserId] = useState(null); const [editingUser, setEditingUser] = useState(null); + const [createUserOpen, setCreateUserOpen] = useState(false); const [selectedUserIds, setSelectedUserIds] = useState([]); const [bulkDisabling, setBulkDisabling] = useState(false); const [visibleColumns, setVisibleColumns] = useState>(() => { @@ -74,6 +75,13 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L onUserChanged?.(nextUser); }, [onUserChanged]); + const addUser = useCallback((user: AdminUser) => { + setUsers((items) => [user, ...items].slice(0, state.pageSize)); + setTotal((value) => value + 1); + onUserChanged?.(user); + setCreateUserOpen(false); + }, [onUserChanged, state.pageSize]); + const toggleStatus = useCallback(async (user: AdminUser) => { try { if (user.status === "active") { @@ -94,7 +102,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L actorId: actor?.id, visibleColumns, onView: (user) => setDetailUserId(user.id), - onEdit: setEditingUser, + onEdit: (user) => { setCreateUserOpen(false); setEditingUser(user); }, onToggleStatus: toggleStatus, }), [actor?.id, toggleStatus, visibleColumns]); @@ -129,6 +137,8 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L active={hasFilters} onReset={resetFilters} trailing={ +
+ ( @@ -156,6 +166,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L > +
} > setDetailUserId(null)} /> + setCreateUserOpen(false)} onCreated={addUser} /> setEditingUser(null)} onSaved={replaceUser} /> ); diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 5e0dbc70d..d58a258ec 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -345,6 +345,10 @@ export function listAdminUsers(params: AdminListParams = {}) { return request<{ users: AdminUser[]; total: number; page: number; limit: number }>(api.get("/admin/users", { params })); } +export function createAdminUser(input: { username: string; displayName: string; email?: string; password: string; role: LocalUser["role"]; status: LocalUser["status"] }) { + return request<{ user: AdminUser }>(api.post("/admin/users", input)); +} + export function getAdminReferences() { return request(api.get("/admin/references")); }