From fa9307e2e100771738cd1da81b0f2ff050c88e75 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:40:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/admin.go | 84 ++++- internal/handler/admin_test.go | 90 ++++- internal/handler/auth.go | 1 + internal/handler/routes_admin.go | 1 + internal/handler/visibility.go | 9 +- internal/model/user.go | 37 +- internal/service/visibility.go | 48 ++- web/src/api/admin.ts | 3 + .../components/AdminUserLibrariesDialog.tsx | 330 ++++++++++++++++++ web/src/pages/AdminUsersPanel.tsx | 14 + web/src/pages/AdminUsersTable.tsx | 44 ++- web/src/types/auth.ts | 1 + 12 files changed, 644 insertions(+), 18 deletions(-) create mode 100644 web/src/components/AdminUserLibrariesDialog.tsx diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 5449f23..5aa66fe 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -3,6 +3,7 @@ package handler import ( "context" + "encoding/json" "errors" "net/http" "strings" @@ -27,6 +28,9 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc { if svc.Sessions != nil { svc.Sessions.ApplyToUsers(c.Request.Context(), users) } + for i := range users { + users[i].PopulateComputedFields() + } c.JSON(http.StatusOK, users) } } @@ -188,19 +192,77 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if req.IsActive { - _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false) - } else { - _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true) + if req.IsActive { + _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false) + } else { + _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true) + } + updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + updated.PopulateComputedFields() + c.JSON(http.StatusOK, updated) + } + } + + type adminUpdateUserLibrariesReq struct { + AllowedLibraryIDs *[]string `json:"allowed_library_ids"` + } + + func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req adminUpdateUserLibrariesReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + userID := c.Param("id") + user, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if user == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + var rawJSON string + if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 { + var cleanIDs []string + for _, id := range *req.AllowedLibraryIDs { + trimmed := strings.TrimSpace(id) + if trimmed != "" { + cleanIDs = append(cleanIDs, trimmed) + } + } + if len(cleanIDs) > 0 { + data, err := json.Marshal(cleanIDs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + rawJSON = string(data) + } + } + + updates := map[string]any{"allowed_library_ids": rawJSON} + if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil || updated == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"}) + return + } + updated.PopulateComputedFields() + c.JSON(http.StatusOK, updated) } - updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, updated) } -} func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error { firstAdmin, err := svc.Repo.User.FirstAdmin(ctx) diff --git a/internal/handler/admin_test.go b/internal/handler/admin_test.go index 5d31a46..0690001 100644 --- a/internal/handler/admin_test.go +++ b/internal/handler/admin_test.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -43,7 +44,92 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) { if w.Code != http.StatusConflict { t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) } - if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil { - t.Fatal("recent realtime user should not be deleted") + if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil { + t.Fatal("recent realtime user should not be deleted") + } + } + +func TestUpdateUserLibraries(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + user := model.User{Base: model.Base{ID: "u1"}, Username: "alice", PasswordHash: "x", Role: "user", IsActive: true} + lib1 := model.Library{Base: model.Base{ID: "lib-1"}, Name: "电影", Type: "movie", Path: "/movie"} + lib2 := model.Library{Base: model.Base{ID: "lib-2"}, Name: "剧集", Type: "tv", Path: "/tv"} + lib3 := model.Library{Base: model.Base{ID: "lib-3"}, Name: "动漫", Type: "anime", Path: "/anime"} + if err := repos.DB.Create(&user).Error; err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&[]model.Library{lib1, lib2, lib3}).Error; err != nil { + t.Fatal(err) + } + + svc := &service.Container{Repo: repos} + router := gin.New() + router.PATCH("/admin/users/:id/libraries", updateUserLibrariesHandler(svc)) + + // 1. 设置限制为 lib-1 和 lib-2 + body := `{"allowed_library_ids":["lib-1","lib-2"]}` + req := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + + found, err := repos.User.FindByID(t.Context(), "u1") + if err != nil || found == nil { + t.Fatal("user not found") + } + allowed := found.DecodeAllowedLibraryIDs() + if len(allowed) != 2 || allowed[0] != "lib-1" || allowed[1] != "lib-2" { + t.Fatalf("expected [lib-1, lib-2], got %v", allowed) + } + + // 验证可见性 + vis := service.UserDefaultMediaVisibility(t.Context(), repos, "u1") + if len(vis.AllowedLibraryIDs) != 2 { + t.Fatalf("expected 2 allowed libraries, got %v", vis.AllowedLibraryIDs) + } + if !service.LibraryVisibleForUser(t.Context(), repos, lib1, vis) { + t.Fatal("lib1 should be visible") + } + if !service.LibraryVisibleForUser(t.Context(), repos, lib2, vis) { + t.Fatal("lib2 should be visible") + } + if service.LibraryVisibleForUser(t.Context(), repos, lib3, vis) { + t.Fatal("lib3 should not be visible") + } + + // 2. 清空限制,恢复全部可见 + bodyEmpty := `{"allowed_library_ids":[]}` + reqEmpty := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(bodyEmpty)) + reqEmpty.Header.Set("Content-Type", "application/json") + wEmpty := httptest.NewRecorder() + router.ServeHTTP(wEmpty, reqEmpty) + + if wEmpty.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", wEmpty.Code, wEmpty.Body.String()) + } + + foundReset, _ := repos.User.FindByID(t.Context(), "u1") + if len(foundReset.DecodeAllowedLibraryIDs()) != 0 { + t.Fatalf("expected nil or empty, got %v", foundReset.DecodeAllowedLibraryIDs()) + } + + visReset := service.UserDefaultMediaVisibility(t.Context(), repos, "u1") + if len(visReset.AllowedLibraryIDs) != 0 { + t.Fatalf("expected no library restrictions, got %v", visReset.AllowedLibraryIDs) + } + if !service.LibraryVisibleForUser(t.Context(), repos, lib3, visReset) { + t.Fatal("lib3 should now be visible") } } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 3bc47c9..5db3c49 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -89,6 +89,7 @@ func meHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } + u.PopulateComputedFields() c.JSON(http.StatusOK, u) } } diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 07d8278..0f5ce22 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -96,6 +96,7 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc)) admin.PATCH("/users/:id/status", updateUserStatusHandler(svc)) admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc)) + admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc)) admin.DELETE("/users/:id", deleteUserHandler(svc)) admin.GET("/settings", listSettingsHandler(svc)) admin.PUT("/settings", updateSettingHandler(svc)) diff --git a/internal/handler/visibility.go b/internal/handler/visibility.go index 5202290..85513c8 100644 --- a/internal/handler/visibility.go +++ b/internal/handler/visibility.go @@ -32,7 +32,14 @@ func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.M return visibility } visibility.IncludeNSFW = adultEnabled && profile.AllowAdult && !userHidesAdult - visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile) + profileAllowed := profileAllowedLibraryIDs(*profile) + if len(profileAllowed) > 0 { + if len(visibility.AllowedLibraryIDs) > 0 { + visibility.AllowedLibraryIDs = service.IntersectStrings(visibility.AllowedLibraryIDs, profileAllowed) + } else { + visibility.AllowedLibraryIDs = profileAllowed + } + } if !visibility.IncludeNSFW { visibility.HiddenLibraryIDs = service.AdultLibraryIDs(c.Request.Context(), svc.Repo) } else { diff --git a/internal/model/user.go b/internal/model/user.go index 9821054..f5084f0 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -1,6 +1,10 @@ package model -import "time" +import ( + "encoding/json" + "strings" + "time" +) // User 是本地账户。第一个注册的管理员(或种子管理员)获得 "admin" 角色; // 其他所有用户默认为 "user"。 @@ -17,6 +21,10 @@ type User struct { ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"` IsActive bool `gorm:"default:true" json:"is_active"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` + // AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。 + // 为空时代表不限制(全库可访问)。 + AllowedLibraryIDs string `gorm:"type:text" json:"-"` + AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"` // ExpiredAt is the account expiry time. Nil means the account never // expires. When set and in the past, the account is treated as expired // (login blocked) until an admin or a redemption code renews it. @@ -31,3 +39,30 @@ type User struct { RealtimeOnline bool `gorm:"-" json:"realtime_online,omitempty"` RealtimeDeviceCount int `gorm:"-" json:"realtime_device_count,omitempty"` } + +// DecodeAllowedLibraryIDs 解析 AllowedLibraryIDs 字段。 +func (u *User) DecodeAllowedLibraryIDs() []string { + if u == nil || strings.TrimSpace(u.AllowedLibraryIDs) == "" { + return nil + } + var ids []string + if err := json.Unmarshal([]byte(u.AllowedLibraryIDs), &ids); err != nil { + return nil + } + var out []string + for _, id := range ids { + trimmed := strings.TrimSpace(id) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// PopulateComputedFields 填充非 DB 虚拟计算字段(如 AllowedLibraryList)。 +func (u *User) PopulateComputedFields() { + if u == nil { + return + } + u.AllowedLibraryList = u.DecodeAllowedLibraryIDs() +} diff --git a/internal/service/visibility.go b/internal/service/visibility.go index 9fd6384..508fe74 100644 --- a/internal/service/visibility.go +++ b/internal/service/visibility.go @@ -50,7 +50,20 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container, visibility.IncludeNSFW = false } visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW) - if userID == "" || repo.PlayProfile == nil { + if userID == "" { + return visibility + } + + if repo.User != nil { + user, err := repo.User.FindByID(ctx, userID) + if err == nil && user != nil && user.Role != "admin" { + if userAllowed := user.DecodeAllowedLibraryIDs(); len(userAllowed) > 0 { + visibility.AllowedLibraryIDs = userAllowed + } + } + } + + if repo.PlayProfile == nil { return visibility } rows, err := repo.PlayProfile.ListByUser(ctx, userID) @@ -62,13 +75,44 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container, continue } visibility.IncludeNSFW = visibility.IncludeNSFW && row.AllowAdult - visibility.AllowedLibraryIDs = DecodeAllowedLibraryIDs(row.AllowedLibraryIDs) + profileAllowed := DecodeAllowedLibraryIDs(row.AllowedLibraryIDs) + if len(profileAllowed) > 0 { + if len(visibility.AllowedLibraryIDs) > 0 { + visibility.AllowedLibraryIDs = IntersectStrings(visibility.AllowedLibraryIDs, profileAllowed) + } else { + visibility.AllowedLibraryIDs = profileAllowed + } + } visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW) break } return visibility } +// IntersectStrings 计算两个字符串切片的交集。 +func IntersectStrings(a, b []string) []string { + if len(a) == 0 { + return b + } + if len(b) == 0 { + return a + } + set := make(map[string]struct{}, len(b)) + for _, s := range b { + set[s] = struct{}{} + } + var out []string + for _, s := range a { + if _, ok := set[s]; ok { + out = append(out, s) + } + } + if len(out) == 0 { + return []string{"__no_access__"} + } + return out +} + // DecodeAllowedLibraryIDs normalises a PlayProfile allowed-library JSON string. func DecodeAllowedLibraryIDs(raw string) []string { if strings.TrimSpace(raw) == "" { diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index 9d3eda2..925d627 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -79,6 +79,9 @@ export const adminAPI = { setUserStatus: (id: string, isActive: boolean) => api.patch(`/admin/users/${id}/status`, { is_active: isActive }).then((r) => r.data), + updateUserLibraries: (id: string, allowedLibraryIDs: string[] | null) => + api.patch(`/admin/users/${id}/libraries`, { allowed_library_ids: allowedLibraryIDs }).then((r) => r.data), + deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data), listSettings: () => api.get('/admin/settings').then((r) => r.data), diff --git a/web/src/components/AdminUserLibrariesDialog.tsx b/web/src/components/AdminUserLibrariesDialog.tsx new file mode 100644 index 0000000..f7ac5a1 --- /dev/null +++ b/web/src/components/AdminUserLibrariesDialog.tsx @@ -0,0 +1,330 @@ +import { useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import { + Check, + CheckSquare, + Film, + FolderLock, + Layers, + Loader2, + Square, + Tv, + X, +} from 'lucide-react' + +import { adminAPI } from '../api/admin' +import { libraryAPI } from '../api/library' +import type { Library, User } from '../types' +import { libraryDisplayPath } from '../pages/libraryDisplayModel' + +type AdminUserLibrariesDialogProps = { + user: User | null + isOpen: boolean + onClose: () => void + onSaved: (updatedUser: User) => void +} + +export function AdminUserLibrariesDialog({ + user, + isOpen, + onClose, + onSaved, +}: AdminUserLibrariesDialogProps) { + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [libraries, setLibraries] = useState([]) + const [mode, setMode] = useState<'all' | 'custom'>('all') + const [selectedIDs, setSelectedIDs] = useState>(new Set()) + + useEffect(() => { + if (!isOpen || !user) return + + setLoading(true) + libraryAPI + .list({ includeHidden: true }) + .then((libs) => { + setLibraries(libs ?? []) + }) + .catch(() => { + toast.error('加载媒体库列表失败') + }) + .finally(() => { + setLoading(false) + }) + + const initialIDs = user.allowed_library_ids ?? [] + if (initialIDs.length > 0) { + setMode('custom') + setSelectedIDs(new Set(initialIDs)) + } else { + setMode('all') + setSelectedIDs(new Set()) + } + }, [isOpen, user]) + + if (!isOpen || !user) return null + + const toggleLibrary = (libID: string) => { + setSelectedIDs((prev) => { + const next = new Set(prev) + if (next.has(libID)) { + next.delete(libID) + } else { + next.add(libID) + } + return next + }) + } + + const handleSelectAll = () => { + setSelectedIDs(new Set(libraries.map((l) => l.id))) + } + + const handleSelectNone = () => { + setSelectedIDs(new Set()) + } + + const handleSave = async () => { + setSaving(true) + try { + let payloadIDs: string[] | null = null + if (mode === 'custom') { + const arr = Array.from(selectedIDs) + // 如果勾选了全部,或者勾选为空 + if (arr.length === 0) { + toast.error('请至少选择一个媒体库,或切换为“允许访问全部媒体库”') + setSaving(false) + return + } + if (arr.length < libraries.length) { + payloadIDs = arr + } + } + + const updated = await adminAPI.updateUserLibraries(user.id, payloadIDs) + toast.success( + payloadIDs && payloadIDs.length > 0 + ? `已成功配置用户【${user.username}】仅可访问 ${payloadIDs.length} 个媒体库` + : `已恢复用户【${user.username}】全库访问权限`, + ) + onSaved(updated) + onClose() + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '保存配置失败' + toast.error(msg) + } finally { + setSaving(false) + } + } + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

+ 配置媒体库访问权限 +

+

+ 用户:{user.username} + {user.role === 'admin' && ( + + 管理员拥有所有库访问权限 + + )} +

+
+
+ +
+ + {/* Content */} +
+ {/* 模式选择 */} +
+ + + +
+ + {/* 媒体库列表 */} + {mode === 'custom' && ( +
+
+ + 已选择{' '} + {selectedIDs.size} /{' '} + {libraries.length} 个媒体库 + +
+ + · + +
+
+ + {loading ? ( +
+ + 正在加载媒体库… +
+ ) : libraries.length === 0 ? ( +
+ 暂无可用媒体库 +
+ ) : ( +
+ {libraries.map((lib) => { + const isChecked = selectedIDs.has(lib.id) + const isTv = + lib.type === 'tv' || lib.type === 'anime' || lib.type === 'variety' + return ( +
toggleLibrary(lib.id)} + className={`group flex cursor-pointer items-center justify-between rounded-xl border p-3 transition-all ${ + isChecked + ? 'border-brand-300 bg-brand-50/40 shadow-xs' + : 'border-sand-200 bg-white hover:border-sand-300 hover:bg-sand-50/40' + }`} + > +
+
+ {isTv ? : } +
+
+

+ {lib.name} +

+

+ {lib.type} · {libraryDisplayPath(lib.path)} +

+
+
+
+ {isChecked && } +
+
+ ) + })} +
+ )} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/web/src/pages/AdminUsersPanel.tsx b/web/src/pages/AdminUsersPanel.tsx index ec1342c..1db5be1 100644 --- a/web/src/pages/AdminUsersPanel.tsx +++ b/web/src/pages/AdminUsersPanel.tsx @@ -5,6 +5,7 @@ import { adminAPI } from '../api/admin' import type { User } from '../types' import { confirmAction } from '../components/confirmAction' import { requestPassword } from '../components/requestPassword' +import { AdminUserLibrariesDialog } from '../components/AdminUserLibrariesDialog' import { AdminUsersForm } from './AdminUsersForm' import { AdminUsersTable } from './AdminUsersTable' @@ -15,6 +16,8 @@ export function AdminUsersPanel() { const [editingID, setEditingID] = useState(null) const [editingUsername, setEditingUsername] = useState('') const [resettingPasswordID, setResettingPasswordID] = useState(null) + const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState(null) + const refresh = async () => { setUsers(await adminAPI.listUsers()) } @@ -148,9 +151,20 @@ export function AdminUsersPanel() { onCancelEdit={() => setEditingID(null)} onStartEdit={startEdit} onResetPassword={resetPassword} + onConfigureLibraries={(u) => setConfiguringLibrariesUser(u)} onToggleStatus={toggleStatus} onDeleteUser={deleteUser} /> + + setConfiguringLibrariesUser(null)} + onSaved={async (updated) => { + setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u))) + await refresh() + }} + /> ) } diff --git a/web/src/pages/AdminUsersTable.tsx b/web/src/pages/AdminUsersTable.tsx index 9bcebb8..e1efbdf 100644 --- a/web/src/pages/AdminUsersTable.tsx +++ b/web/src/pages/AdminUsersTable.tsx @@ -1,4 +1,14 @@ -import { KeyRound, Loader2, Pencil, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react' +import { + FolderLock, + KeyRound, + Loader2, + Pencil, + ShieldCheck, + Trash2, + UserCheck, + UserX, + X, +} from 'lucide-react' import type { User } from '../types' @@ -12,6 +22,7 @@ type AdminUsersTableProps = { onCancelEdit: () => void onStartEdit: (user: User) => void onResetPassword: (user: User) => void + onConfigureLibraries: (user: User) => void onToggleStatus: (user: User) => void onDeleteUser: (user: User) => void } @@ -26,6 +37,7 @@ export function AdminUsersTable({ onCancelEdit, onStartEdit, onResetPassword, + onConfigureLibraries, onToggleStatus, onDeleteUser, }: AdminUsersTableProps) { @@ -36,6 +48,7 @@ export function AdminUsersTable({ 用户名 角色 + 媒体库权限 状态 权限说明 最近登录 @@ -60,6 +73,27 @@ export function AdminUsersTable({ )} {u.role === 'admin' ? '管理员' : '观看用户'} + + {u.role === 'admin' ? ( + + 全库 (管理员) + + ) : !u.allowed_library_ids || u.allowed_library_ids.length === 0 ? ( + + 全部媒体库 (默认) + + ) : ( + + )} + {u.is_active ? '正常' : '已禁用'} @@ -92,11 +126,19 @@ export function AdminUsersTable({ ) : ( )} +