mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
优化
This commit is contained in:
+73
-11
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+36
-1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -79,6 +79,9 @@ export const adminAPI = {
|
||||
setUserStatus: (id: string, isActive: boolean) =>
|
||||
api.patch<User>(`/admin/users/${id}/status`, { is_active: isActive }).then((r) => r.data),
|
||||
|
||||
updateUserLibraries: (id: string, allowedLibraryIDs: string[] | null) =>
|
||||
api.patch<User>(`/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<Setting[]>('/admin/settings').then((r) => r.data),
|
||||
|
||||
@@ -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<Library[]>([])
|
||||
const [mode, setMode] = useState<'all' | 'custom'>('all')
|
||||
const [selectedIDs, setSelectedIDs] = useState<Set<string>>(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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm animate-in fade-in-0"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-sand-100 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
|
||||
<FolderLock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">
|
||||
配置媒体库访问权限
|
||||
</h3>
|
||||
<p className="text-xs text-sand-500">
|
||||
用户:<span className="font-semibold text-ink-600">{user.username}</span>
|
||||
{user.role === 'admin' && (
|
||||
<span className="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-600 border border-amber-200">
|
||||
管理员拥有所有库访问权限
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-2 text-sand-500 hover:bg-sand-100 hover:text-ink-600 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-4 max-h-[65vh] overflow-y-auto">
|
||||
{/* 模式选择 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('all')}
|
||||
className={`flex items-center gap-3 rounded-2xl border p-3.5 text-left transition-all ${
|
||||
mode === 'all'
|
||||
? 'border-brand-400 bg-brand-50/70 text-brand-800 shadow-sm ring-1 ring-brand-400'
|
||||
: 'border-sand-200 bg-white text-ink-600 hover:border-sand-300 hover:bg-sand-50/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${
|
||||
mode === 'all' ? 'bg-brand-500 text-white' : 'bg-sand-100 text-sand-500'
|
||||
}`}
|
||||
>
|
||||
<Layers size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-bold">全部媒体库 (默认)</div>
|
||||
<div className="text-[11px] text-sand-500">用户可访问系统所有已建媒体库</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('custom')
|
||||
if (selectedIDs.size === 0) {
|
||||
setSelectedIDs(new Set(libraries.map((l) => l.id)))
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-3 rounded-2xl border p-3.5 text-left transition-all ${
|
||||
mode === 'custom'
|
||||
? 'border-brand-400 bg-brand-50/70 text-brand-800 shadow-sm ring-1 ring-brand-400'
|
||||
: 'border-sand-200 bg-white text-ink-600 hover:border-sand-300 hover:bg-sand-50/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${
|
||||
mode === 'custom' ? 'bg-brand-500 text-white' : 'bg-sand-100 text-sand-500'
|
||||
}`}
|
||||
>
|
||||
<FolderLock size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-bold">指定可访问媒体库</div>
|
||||
<div className="text-[11px] text-sand-500">仅允许访问管理员勾选的媒体库</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 媒体库列表 */}
|
||||
{mode === 'custom' && (
|
||||
<div className="space-y-2 pt-2 border-t border-sand-100 animate-in fade-in-0 duration-200">
|
||||
<div className="flex items-center justify-between text-xs text-sand-500 px-1">
|
||||
<span>
|
||||
已选择{' '}
|
||||
<strong className="text-brand-600">{selectedIDs.size}</strong> /{' '}
|
||||
{libraries.length} 个媒体库
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
<CheckSquare size={13} />
|
||||
全选
|
||||
</button>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectNone}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-sand-500 hover:text-sand-700"
|
||||
>
|
||||
<Square size={13} />
|
||||
全不选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sand-500 text-xs gap-2">
|
||||
<Loader2 size={16} className="animate-spin text-brand-500" />
|
||||
正在加载媒体库…
|
||||
</div>
|
||||
) : libraries.length === 0 ? (
|
||||
<div className="py-10 text-center text-xs text-sand-500 bg-sand-50/50 rounded-2xl">
|
||||
暂无可用媒体库
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{libraries.map((lib) => {
|
||||
const isChecked = selectedIDs.has(lib.id)
|
||||
const isTv =
|
||||
lib.type === 'tv' || lib.type === 'anime' || lib.type === 'variety'
|
||||
return (
|
||||
<div
|
||||
key={lib.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
||||
isChecked
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-sand-100 text-sand-500 group-hover:text-sand-700'
|
||||
}`}
|
||||
>
|
||||
{isTv ? <Tv size={15} /> : <Film size={15} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-ink-600">
|
||||
{lib.name}
|
||||
</p>
|
||||
<p
|
||||
className="truncate text-[10px] text-sand-500"
|
||||
title={lib.path}
|
||||
>
|
||||
{lib.type} · {libraryDisplayPath(lib.path)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors ${
|
||||
isChecked
|
||||
? 'border-brand-500 bg-brand-500 text-white'
|
||||
: 'border-sand-300 bg-white group-hover:border-sand-400'
|
||||
}`}
|
||||
>
|
||||
{isChecked && <Check size={12} className="stroke-[3]" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2.5 border-t border-sand-100 bg-sand-50/60 px-5 py-3.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-sand-200 bg-white px-4 py-2 text-xs font-semibold text-ink-600 hover:bg-sand-100 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || loading}
|
||||
onClick={handleSave}
|
||||
className="neon-button inline-flex items-center gap-2 px-5 py-2 text-xs font-semibold disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 size={13} className="animate-spin" />}
|
||||
<span>保存配置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [editingUsername, setEditingUsername] = useState('')
|
||||
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
|
||||
const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState<User | null>(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}
|
||||
/>
|
||||
|
||||
<AdminUserLibrariesDialog
|
||||
user={configuringLibrariesUser}
|
||||
isOpen={Boolean(configuringLibrariesUser)}
|
||||
onClose={() => setConfiguringLibrariesUser(null)}
|
||||
onSaved={async (updated) => {
|
||||
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)))
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<tr>
|
||||
<th className="py-2">用户名</th>
|
||||
<th>角色</th>
|
||||
<th>媒体库权限</th>
|
||||
<th>状态</th>
|
||||
<th>权限说明</th>
|
||||
<th>最近登录</th>
|
||||
@@ -60,6 +73,27 @@ export function AdminUsersTable({
|
||||
)}
|
||||
</td>
|
||||
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
|
||||
<td>
|
||||
{u.role === 'admin' ? (
|
||||
<span className="inline-flex items-center rounded-full bg-sand-100 px-2.5 py-0.5 text-xs text-sand-600 font-medium">
|
||||
全库 (管理员)
|
||||
</span>
|
||||
) : !u.allowed_library_ids || u.allowed_library_ids.length === 0 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-green-50 border border-green-200/80 px-2.5 py-0.5 text-xs font-medium text-green-700">
|
||||
全部媒体库 (默认)
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfigureLibraries(u)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-50 border border-brand-300 px-2.5 py-0.5 text-xs font-semibold text-brand-700 hover:bg-brand-100/70 transition-colors"
|
||||
title="点击修改媒体库访问权限"
|
||||
>
|
||||
<FolderLock size={12} />
|
||||
<span>已指定 {u.allowed_library_ids.length} 个库</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
|
||||
{u.is_active ? '正常' : '已禁用'}
|
||||
</td>
|
||||
@@ -92,11 +126,19 @@ export function AdminUsersTable({
|
||||
) : (
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
title="重命名用户"
|
||||
onClick={() => onStartEdit(u)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg border border-brand-400/40 px-2 py-1 text-xs text-brand-600 hover:bg-brand-400/10"
|
||||
title="配置媒体库访问权限"
|
||||
onClick={() => onConfigureLibraries(u)}
|
||||
>
|
||||
<FolderLock size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
|
||||
title="重置密码"
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface User {
|
||||
hide_adult?: boolean
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
allowed_library_ids?: string[]
|
||||
is_default_admin?: boolean
|
||||
is_protected?: boolean
|
||||
realtime_online?: boolean
|
||||
|
||||
Reference in New Issue
Block a user