diff --git a/API.md b/API.md index 54d3728..717833b 100644 --- a/API.md +++ b/API.md @@ -741,3 +741,15 @@ await fetch('/api/notes', { - `note_token` 会话 cookie:HttpOnly + SameSite=Lax(防 CSRF),随机 32 字节 token,7 天过期,服务端内存校验绑定用户。 - 密码 bcrypt(cost 10),兼容旧 SHA-256 自动升级。 + +### v5 管理员跨租户管理 + 注册开关 + +- **管理员跨租户管理**:`admin` 角色的用户可读取/修改/删除/恢复/分享任意用户的笔记,树/列表/回收站/分类/标签/图谱/FTS 均显示全平台数据;普通用户仍只能操作自己的数据(越权 404)。 +- **注册开关**:管理员可在后台「⚙ 设置」中开启/关闭注册。关闭后 `/api/auth/register` 返回 400「注册功能已关闭」,前台与登录页隐藏「注册」入口,仅已有账号可登录。 +- 默认通过环境变量 `REGISTRATION_ENABLED`(默认 `true`)设置,运行时可在后台切换并持久化。 +- 接口: + | 方法 | 路径 | 说明 | + |------|------|------| + | GET | `/admin/api/settings/registration` | 获取注册开关状态(管理员) | + | PUT | `/admin/api/settings/registration` | 设置注册开关 `{enabled}`(管理员) | + | GET | `/api/auth/me` | 返回当前用户角色及 `registration_enabled` | diff --git a/handler/auth_handler.go b/handler/auth_handler.go index 9646955..ba5b4ac 100644 --- a/handler/auth_handler.go +++ b/handler/auth_handler.go @@ -82,7 +82,9 @@ func (h *AuthHandler) Logout(c *gin.Context) { func (h *AuthHandler) Me(c *gin.Context) { uid := middleware.GetUserID(c) if uid == 0 { - success(c, nil) + success(c, gin.H{ + "registration_enabled": h.userSvc.RegistrationEnabled(), + }) return } u, err := h.userSvc.GetByID(uid) @@ -91,9 +93,39 @@ func (h *AuthHandler) Me(c *gin.Context) { return } success(c, gin.H{ - "id": u.ID, - "username": u.Username, - "display_name": u.DisplayName, - "role": u.Role, + "id": u.ID, + "username": u.Username, + "display_name": u.DisplayName, + "role": u.Role, + "registration_enabled": h.userSvc.RegistrationEnabled(), }) } + +// GetRegistrationStatus 获取注册开关状态(管理员) +func (h *AuthHandler) GetRegistrationStatus(c *gin.Context) { + if !h.userSvc.IsAdmin(middleware.GetUserID(c)) { + fail(c, http.StatusForbidden, "仅管理员可操作") + return + } + success(c, gin.H{"registration_enabled": h.userSvc.RegistrationEnabled()}) +} + +// SetRegistration 切换注册开关(管理员) +func (h *AuthHandler) SetRegistration(c *gin.Context) { + if !h.userSvc.IsAdmin(middleware.GetUserID(c)) { + fail(c, http.StatusForbidden, "仅管理员可操作") + return + } + var req struct { + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, "请求参数错误") + return + } + if err := h.userSvc.SetRegistrationEnabled(req.Enabled); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, gin.H{"registration_enabled": req.Enabled}) +} diff --git a/main.go b/main.go index 0e0e319..5fcacb1 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "log" + "os" "github.com/gin-gonic/gin" "note-manager/config" @@ -12,6 +13,14 @@ import ( "note-manager/service" ) +// getDefaultReg 返回注册开关默认值(来自环境变量 REGISTRATION_ENABLED,默认开启) +func getDefaultReg() string { + if v := os.Getenv("REGISTRATION_ENABLED"); v != "" { + return v + } + return "true" +} + func main() { // 加载配置 cfg := config.Load() @@ -36,6 +45,15 @@ func main() { log.Fatalf("初始化上传目录失败: %v", err) } + // 确保系统设置表存在(注册开关等) + if err := userRepo.EnsureSettingsTable(); err != nil { + log.Fatalf("初始化系统设置表失败: %v", err) + } + // 注册开关默认值来自环境变量 REGISTRATION_ENABLED(默认开启) + userService.SetRegistrationDefault(getDefaultReg()) + // 注入角色判定,使管理员能跨租户管理笔记 + noteService.RegisterRoleChecker(userService) + // 初始化 Gin(设置模板目录) gin.SetMode(gin.ReleaseMode) engine := gin.Default() diff --git a/repository/note_repository.go b/repository/note_repository.go index df61769..20e09fb 100644 --- a/repository/note_repository.go +++ b/repository/note_repository.go @@ -180,6 +180,44 @@ func (r *NoteRepository) FTS5Search(userID uint, keyword string, page, pageSize return items, total, nil } +// FTS5SearchAll 管理员跨租户全文搜索全部笔记 +func (r *NoteRepository) FTS5SearchAll(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) { + keyword = strings.TrimSpace(segmentCJK(keyword)) + if keyword == "" { + return nil, 0, nil + } + + var total int64 + countQuery := ` + SELECT COUNT(*) FROM note_search s + JOIN notes n ON n.id = s.rowid + WHERE note_search MATCH ? AND n.is_folder = 0 + AND (n.deleted_at IS NULL OR n.deleted_at = '')` + if err := r.db.Raw(countQuery, keyword).Scan(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + var items []model.NoteListItem + err := r.db.Raw(` + SELECT n.id, n.title, n.category, n.tags, + CASE WHEN n.password != '' THEN 1 ELSE 0 END AS has_password, + n.is_pinned, n.is_favorite, n.is_public, n.parent_id, n.is_folder, + n.sort_order, n.visit_count, n.created_at, n.updated_at + FROM note_search s + JOIN notes n ON n.id = s.rowid + WHERE note_search MATCH ? AND n.is_folder = 0 + AND (n.deleted_at IS NULL OR n.deleted_at = '') + ORDER BY bm25(note_search), n.updated_at DESC + LIMIT ? OFFSET ?`, + keyword, pageSize, offset, + ).Scan(&items).Error + if err != nil { + return nil, 0, err + } + return items, total, nil +} + // Create 创建笔记 func (r *NoteRepository) Create(note *model.Note) error { res := r.db.Select("UserID", "Title", "Content", "DraftContent", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note) @@ -274,7 +312,8 @@ func (r *NoteRepository) Delete(id uint) error { // ListQuery 列表查询参数 type ListQuery struct { UserID uint - LoggedIn bool // 是否已登录(false 时仅返回公开笔记,跨租户展示) + LoggedIn bool // 是否已登录(false 时仅返回公开笔记,跨租户展示) + Admin bool // 管理员是否(true 时忽略 user_id 隔离,返回全平台笔记) Page int PageSize int Category string @@ -291,10 +330,13 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error) query := r.db.Model(&model.Note{}) - if q.LoggedIn { + switch { + case q.Admin: + // 管理员:全平台所有笔记 + case q.LoggedIn: // 登录用户:数据隔离,只看自己的 query = query.Where("user_id = ?", q.UserID) - } else { + default: // 游客:只看所有公开无密码的笔记(跨租户展示) query = query.Where("is_public = ?", true).Where("(password IS NULL OR password = '')") } @@ -340,6 +382,16 @@ func (r *NoteRepository) GetAllTree(userID uint) ([]model.NoteListItem, error) { return items, err } +// GetAllTreeAll 获取全平台所有笔记的树形结构(管理员用) +func (r *NoteRepository) GetAllTreeAll() ([]model.NoteListItem, error) { + var items []model.NoteListItem + err := r.db.Model(&model.Note{}). + Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, share_token, share_expire_at, visit_count, created_at, updated_at"). + Order("is_folder DESC, sort_order ASC, title ASC"). + Find(&items).Error + return items, err +} + // GetPublicTree 获取公开可见的树形结构(多租户下:登录用户看自己全部笔记;游客看所有公开笔记) func (r *NoteRepository) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) { var items []model.NoteListItem @@ -358,6 +410,16 @@ func (r *NoteRepository) GetPublicTree(userID uint, loggedIn bool) ([]model.Note return items, err } +// GetPublicTreeAll 获取全平台所有笔记的树形结构(管理员只看全平台,含公开与私有) +func (r *NoteRepository) GetPublicTreeAll() ([]model.NoteListItem, error) { + var items []model.NoteListItem + err := r.db.Model(&model.Note{}). + Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at"). + Order("is_folder DESC, sort_order ASC, title ASC"). + Find(&items).Error + return items, err +} + // GetByParentID 获取指定父目录下的所有项目(排除已删除,按用户隔离) func (r *NoteRepository) GetByParentID(userID, parentID uint) ([]model.NoteListItem, error) { var items []model.NoteListItem @@ -370,6 +432,17 @@ func (r *NoteRepository) GetByParentID(userID, parentID uint) ([]model.NoteListI return items, err } +// GetByParentIDAll 获取指定父目录下的所有项目(管理员跨租户) +func (r *NoteRepository) GetByParentIDAll(parentID uint) ([]model.NoteListItem, error) { + var items []model.NoteListItem + err := r.db.Model(&model.Note{}). + Where("parent_id = ?", parentID). + Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at"). + Order("is_folder DESC, sort_order ASC, title ASC"). + Find(&items).Error + return items, err +} + // GetChildrenCount 获取子项数量(排除已删除,按用户隔离) func (r *NoteRepository) GetChildrenCount(userID, parentID uint) (int64, error) { var count int64 @@ -421,6 +494,17 @@ func (r *NoteRepository) ListTrash(userID uint) ([]model.NoteListItem, error) { return items, err } +// ListTrashAll 管理员跨租户回收站列表 +func (r *NoteRepository) ListTrashAll() ([]model.NoteListItem, error) { + var items []model.NoteListItem + err := r.db.Unscoped().Model(&model.Note{}). + Where("deleted_at IS NOT NULL"). + Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at"). + Order("deleted_at DESC"). + Find(&items).Error + return items, err +} + // Restore 从回收站恢复(连带恢复已被软删除的父目录路径不需要特殊处理) func (r *NoteRepository) Restore(id uint) error { // 恢复自身 @@ -537,6 +621,29 @@ func (r *NoteRepository) SearchPublic(keyword string, page, pageSize int) ([]mod return items, total, err } +// SearchAll 管理员跨租户搜索全部笔记(LIKE 回退) +func (r *NoteRepository) SearchAll(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) { + var items []model.NoteListItem + var total int64 + + like := "%" + keyword + "%" + query := r.db.Model(&model.Note{}). + Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at"). + Order("is_pinned DESC, updated_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&items).Error + + return items, total, err +} + // GetCategories 获取所有分类(排除已删除和目录,按用户隔离) func (r *NoteRepository) GetCategories(userID uint) ([]string, error) { var categories []string @@ -571,6 +678,18 @@ func (r *NoteRepository) GetTags(userID uint) ([]string, error) { return result, nil } +// GetAllTags 管理员跨租户获取全部标签 +func (r *NoteRepository) GetAllTags() ([]string, error) { + var tagsJSON []string + err := r.db.Model(&model.Note{}). + Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false). + Pluck("tags", &tagsJSON).Error + if err != nil { + return nil, err + } + return dedupeTags(tagsJSON), nil +} + // ─────────────── 标签管理 ─────────────── // GetPublicCategories 游客获取所有公开笔记的分类 @@ -584,6 +703,16 @@ func (r *NoteRepository) GetPublicCategories() ([]string, error) { return categories, err } +// GetAllCategories 管理员跨租户获取全部分类 +func (r *NoteRepository) GetAllCategories() ([]string, error) { + var categories []string + err := r.db.Model(&model.Note{}). + Distinct("category"). + Where("category != '' AND is_folder = ?", false). + Pluck("category", &categories).Error + return categories, err +} + // GetPublicTags 游客获取所有公开笔记的标签 func (r *NoteRepository) GetPublicTags() ([]string, error) { var tagsJSON []string @@ -655,6 +784,47 @@ func (r *NoteRepository) UpdateTagAll(userID uint, oldTag, newTag string) (int64 return changed, nil } +// UpdateTagAllAll 管理员跨租户重命名/合并/删除标签(作用于全平台所有笔记) +func (r *NoteRepository) UpdateTagAllAll(oldTag, newTag string) (int64, error) { + var notes []model.Note + if err := r.db.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)). + Where("is_folder = ?", false). + Find(¬es).Error; err != nil { + return 0, err + } + + changed := int64(0) + for i := range notes { + var tagSlice []string + if json.Unmarshal([]byte(notes[i].Tags), &tagSlice) != nil { + continue + } + seen := make(map[string]bool) + var newSlice []string + for _, t := range tagSlice { + if t == oldTag { + changed++ + if newTag != "" && !seen[newTag] { + newSlice = append(newSlice, newTag) + seen[newTag] = true + } + continue + } + if !seen[t] { + newSlice = append(newSlice, t) + seen[t] = true + } + } + newJSON, _ := json.Marshal(newSlice) + if err := r.db.Model(&model.Note{}).Where("id = ?", notes[i].ID). + Update("tags", string(newJSON)).Error; err != nil { + return changed, err + } + _ = r.rebuildNoteFTS(notes[i].ID) + } + return changed, nil +} + // ─────────────── 双向链接 / 知识图谱 ─────────────── // GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计,按用户隔离) @@ -667,6 +837,15 @@ func (r *NoteRepository) GetAllNotesLight(userID uint) ([]model.NoteListItem, er return items, err } +// GetAllNotesLightAll 管理员跨租户获取所有未删除笔记的 id、标题 与 标签 +func (r *NoteRepository) GetAllNotesLightAll() ([]model.NoteListItem, error) { + var items []model.NoteListItem + err := r.db.Model(&model.Note{}). + Where("is_folder = ?", false). + Select("id, title, tags").Find(&items).Error + return items, err +} + // GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱,按用户隔离) // 仅返回轻量字段以降低内存占用 func (r *NoteRepository) GetAllContentLight(userID uint) ([]struct { @@ -686,6 +865,23 @@ func (r *NoteRepository) GetAllContentLight(userID uint) ([]struct { return items, err } +// GetAllContentLightAll 管理员跨租户获取所有未删除笔记的 id、标题、内容 +func (r *NoteRepository) GetAllContentLightAll() ([]struct { + ID uint `gorm:"column:id"` + Title string `gorm:"column:title"` + Content string `gorm:"column:content"` +}, error) { + var items []struct { + ID uint `gorm:"column:id"` + Title string `gorm:"column:title"` + Content string `gorm:"column:content"` + } + err := r.db.Model(&model.Note{}). + Where("is_folder = ?", false). + Select("id, title, content").Find(&items).Error + return items, err +} + // GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在,按用户隔离) func (r *NoteRepository) GetByIDs(userID uint, ids []uint) ([]model.NoteListItem, error) { if len(ids) == 0 { diff --git a/repository/user_repository.go b/repository/user_repository.go index 76f9d51..ce13bc5 100644 --- a/repository/user_repository.go +++ b/repository/user_repository.go @@ -61,3 +61,31 @@ func (r *UserRepository) GetByID(id uint) (*model.User, error) { func (r *UserRepository) GetDB() *gorm.DB { return r.db } + +// ─────────────── 系统设置(key-value,含注册开关等)─────────────── + +// GetSetting 读取设置项(不存在返回 defaultVal) +func (r *UserRepository) GetSetting(key, defaultVal string) string { + var val string + err := r.db.Raw("SELECT value FROM app_settings WHERE key = ?", key).Scan(&val).Error + if err != nil || val == "" { + return defaultVal + } + return val +} + +// SetSetting 写入或更新设置项 +func (r *UserRepository) SetSetting(key, val string) error { + return r.db.Exec(` + INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, val).Error +} + +// Migration 中创建 app_settings 表 +func (r *UserRepository) EnsureSettingsTable() error { + return r.db.Exec(` + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + )`).Error +} diff --git a/router/router.go b/router/router.go index 76b2ad5..00cd19a 100644 --- a/router/router.go +++ b/router/router.go @@ -90,6 +90,10 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, authHandler *handler adminApi.POST("/tags/rename", noteHandler.RenameTag) adminApi.POST("/tags/merge", noteHandler.MergeTag) adminApi.DELETE("/tags", noteHandler.DeleteTag) + + // 平台设置(注册开关,管理员) + adminApi.GET("/settings/registration", authHandler.GetRegistrationStatus) + adminApi.PUT("/settings/registration", authHandler.SetRegistration) } // ─────────── 后台管理路由 ─────────── diff --git a/service/note_service.go b/service/note_service.go index 81b9b86..47f068f 100644 --- a/service/note_service.go +++ b/service/note_service.go @@ -20,6 +20,12 @@ import ( type NoteService struct { repo *repository.NoteRepository pageSize int + roles RoleChecker // 用户角色判定(管理员可跨租户管理) +} + +// RoleChecker 提供用户角色判断,便于解耦(由 UserService 实现) +type RoleChecker interface { + IsAdmin(userID uint) bool } // ErrNotFound 记录不存在(映射为 HTTP 404) @@ -30,6 +36,16 @@ func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService return &NoteService{repo: repo, pageSize: pageSize} } +// RegisterRoleChecker 注入角色判定器(用于管理员跨租户管理) +func (s *NoteService) RegisterRoleChecker(rc RoleChecker) { + s.roles = rc +} + +// isAdmin 便捷判断 +func (s *NoteService) isAdmin(userID uint) bool { + return s.roles != nil && s.roles.IsAdmin(userID) +} + // CreateNote 创建笔记或目录(归属当前用户) func (s *NoteService) CreateNote(userID uint, req model.NoteCreateRequest) (*model.Note, error) { note := &model.Note{ @@ -66,12 +82,18 @@ func (s *NoteService) CreateNote(userID uint, req model.NoteCreateRequest) (*mod return note, nil } -// GetNote 获取单条笔记(后台/个人管理使用,校验归属) +// GetNote 获取单条笔记(后台/个人管理使用,管理员可跨租户读取任意笔记) func (s *NoteService) GetNote(userID, id uint) (*model.Note, error) { - note, err := s.repo.GetByIDScoped(userID, id) + var note *model.Note + var err error + if s.isAdmin(userID) { + note, err = s.repo.GetByID(id) // 管理员直接按 id 读取 + } else { + note, err = s.repo.GetByIDScoped(userID, id) + } if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("笔记不存在") + return nil, ErrNotFound } return nil, err } @@ -108,9 +130,15 @@ func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, boo return note, false, nil } -// UpdateNote 更新笔记或目录(保存更新前快照到版本历史,校验归属) +// UpdateNote 更新笔记或目录(保存更新前快照到版本历史,管理员可跨租户更新) func (s *NoteService) UpdateNote(userID, id uint, req model.NoteUpdateRequest) (*model.Note, error) { - note, err := s.repo.GetByIDScoped(userID, id) + var note *model.Note + var err error + if s.isAdmin(userID) { + note, err = s.repo.GetByID(id) + } else { + note, err = s.repo.GetByIDScoped(userID, id) + } if err != nil { return nil, ErrNotFound } @@ -166,9 +194,15 @@ func (s *NoteService) UpdateNote(userID, id uint, req model.NoteUpdateRequest) ( return note, nil } -// DeleteNote 软删除笔记或目录(目录会软删除所有子项,校验归属) +// DeleteNote 软删除笔记或目录(目录会软删除所有子项,管理员可跨租户删除) func (s *NoteService) DeleteNote(userID, id uint) error { - note, err := s.repo.GetByIDScoped(userID, id) + var note *model.Note + var err error + if s.isAdmin(userID) { + note, err = s.repo.GetByID(id) + } else { + note, err = s.repo.GetByIDScoped(userID, id) + } if err != nil { return ErrNotFound } @@ -178,17 +212,23 @@ func (s *NoteService) DeleteNote(userID, id uint) error { return s.repo.Delete(id) } -// GetAllTree 获取所有笔记和目录的树形结构(个人管理用) +// GetAllTree 获取所有笔记和目录的树形结构(个人管理用;管理员看全平台) func (s *NoteService) GetAllTree(userID uint) ([]model.NoteListItem, error) { + if s.isAdmin(userID) { + return s.repo.GetAllTreeAll() + } return s.repo.GetAllTree(userID) } -// GetPublicTree 获取树形结构(登录用户看自己全部;游客看所有公开笔记) +// GetPublicTree 获取树形结构(登录用户看自己全部;游客看所有公开笔记;管理员看全平台) func (s *NoteService) GetPublicTree(userID uint, loggedIn bool) ([]model.NoteListItem, error) { + if loggedIn && s.isAdmin(userID) { + return s.repo.GetPublicTreeAll() + } return s.repo.GetPublicTree(userID, loggedIn) } -// ListNotes 获取笔记列表(登录用户看自己的;游客看公开笔记) +// ListNotes 获取笔记列表(登录用户看自己的;游客看公开笔记;管理员跨租户全部) func (s *NoteService) ListNotes(userID uint, loggedIn bool, pageStr, pageSizeStr, category, tag string, pinned, favorite *bool) ([]model.NoteListItem, int64, int, error) { page := parseInt(pageStr, 1) pageSize := parseInt(pageSizeStr, s.pageSize) @@ -201,14 +241,15 @@ func (s *NoteService) ListNotes(userID uint, loggedIn bool, pageStr, pageSizeStr } items, total, err := s.repo.List(repository.ListQuery{ - UserID: userID, - LoggedIn: loggedIn, - Page: page, - PageSize: pageSize, - Category: category, - Tag: tag, - Pinned: pinned, - Favorite: favorite, + UserID: userID, + LoggedIn: loggedIn, + Admin: loggedIn && s.isAdmin(userID), + Page: page, + PageSize: pageSize, + Category: category, + Tag: tag, + Pinned: pinned, + Favorite: favorite, }) if err != nil { return nil, 0, 0, fmt.Errorf("获取笔记列表失败: %w", err) @@ -222,12 +263,15 @@ func (s *NoteService) ListNotes(userID uint, loggedIn bool, pageStr, pageSizeStr return items, total, totalPages, nil } -// GetByParentID 获取指定目录下的所有项目(个人管理用) +// GetByParentID 获取指定目录下的所有项目(个人管理用;管理员看全平台) func (s *NoteService) GetByParentID(userID, parentID uint) ([]model.NoteListItem, error) { + if s.isAdmin(userID) { + return s.repo.GetByParentIDAll(parentID) + } return s.repo.GetByParentID(userID, parentID) } -// SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词;按用户隔离) +// SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词;管理员跨租户搜索) func (s *NoteService) SearchNotes(userID uint, loggedIn bool, keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) { if keyword == "" { return nil, 0, 0, errors.New("搜索关键词不能为空") @@ -246,6 +290,20 @@ func (s *NoteService) SearchNotes(userID uint, loggedIn bool, keyword, pageStr, keyword = sanitizeFTS5(keyword) if loggedIn { + if s.isAdmin(userID) { + items, total, err := s.repo.FTS5SearchAll(keyword, page, pageSize) + if err != nil { + items, total, err = s.repo.SearchAll(keyword, page, pageSize) + if err != nil { + return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err) + } + } + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + return items, total, totalPages, nil + } items, total, err := s.repo.FTS5Search(userID, keyword, page, pageSize) if err != nil { // FTS5 失败时回退到传统 LIKE 搜索 @@ -288,8 +346,11 @@ func sanitizeFTS5(q string) string { return b.String() } -// GetCategories 获取所有分类(登录用户看自己的;游客看公开笔记的分类) +// GetCategories 获取所有分类(登录用户看自己的;游客看公开笔记的分类;管理员看全平台) func (s *NoteService) GetCategories(userID uint, loggedIn bool) ([]string, error) { + if loggedIn && s.isAdmin(userID) { + return s.repo.GetAllCategories() + } if loggedIn { return s.repo.GetCategories(userID) } @@ -297,8 +358,11 @@ func (s *NoteService) GetCategories(userID uint, loggedIn bool) ([]string, error return s.repo.GetPublicCategories() } -// GetTags 获取所有标签(登录用户看自己的;游客看公开笔记的标签) +// GetTags 获取所有标签(登录用户看自己的;游客看公开笔记的标签;管理员看全平台) func (s *NoteService) GetTags(userID uint, loggedIn bool) ([]string, error) { + if loggedIn && s.isAdmin(userID) { + return s.repo.GetAllTags() + } if loggedIn { return s.repo.GetTags(userID) } @@ -307,18 +371,21 @@ func (s *NoteService) GetTags(userID uint, loggedIn bool) ([]string, error) { // ─────────────── 回收站 ─────────────── -// ListTrash 获取回收站列表(当前用户) +// ListTrash 获取回收站列表(当前用户;管理员看全平台) func (s *NoteService) ListTrash(userID uint) ([]model.NoteListItem, error) { + if s.isAdmin(userID) { + return s.repo.ListTrashAll() + } return s.repo.ListTrash(userID) } -// RestoreNote 从回收站恢复笔记或目录(整棵子树,校验归属) +// RestoreNote 从回收站恢复笔记或目录(整棵子树,校验归属;管理员可恢复任意) func (s *NoteService) RestoreNote(userID, id uint) error { note, err := s.repo.GetByIDIncludingDeleted(id) if err != nil { return errors.New("记录不存在") } - if note.UserID != userID { + if !s.isAdmin(userID) && note.UserID != userID { return errors.New("无权操作该记录") } if note.IsFolder { @@ -327,13 +394,13 @@ func (s *NoteService) RestoreNote(userID, id uint) error { return s.repo.Restore(id) } -// PurgeNote 彻底删除笔记或目录(不可恢复,校验归属) +// PurgeNote 彻底删除笔记或目录(不可恢复,校验归属;管理员可彻底删除任意) func (s *NoteService) PurgeNote(userID, id uint) error { note, err := s.repo.GetByIDIncludingDeleted(id) if err != nil { return errors.New("记录不存在") } - if note.UserID != userID { + if !s.isAdmin(userID) && note.UserID != userID { return errors.New("无权操作该记录") } if note.IsFolder { @@ -350,9 +417,15 @@ func (s *NoteService) PurgeNote(userID, id uint) error { return nil } -// EmptyTrash 清空回收站(当前用户) +// EmptyTrash 清空回收站(当前用户;管理员清空全平台) func (s *NoteService) EmptyTrash(userID uint) error { - trash, err := s.repo.ListTrash(userID) + var trash []model.NoteListItem + var err error + if s.isAdmin(userID) { + trash, err = s.repo.ListTrashAll() + } else { + trash, err = s.repo.ListTrash(userID) + } if err != nil { return err } @@ -366,8 +439,15 @@ func (s *NoteService) EmptyTrash(userID uint) error { // ─────────────── 版本历史 ─────────────── -// ListVersions 获取笔记版本列表(校验归属) +// ListVersions 获取笔记版本列表(校验归属;管理员可看任意) func (s *NoteService) ListVersions(userID, noteID uint) ([]model.NoteVersion, error) { + if s.isAdmin(userID) { + _, err := s.repo.GetByID(noteID) + if err != nil { + return nil, errors.New("笔记不存在") + } + return s.repo.ListVersions(noteID) + } note, err := s.repo.GetByIDScoped(userID, noteID) if err != nil { return nil, errors.New("笔记不存在") @@ -376,9 +456,15 @@ func (s *NoteService) ListVersions(userID, noteID uint) ([]model.NoteVersion, er return s.repo.ListVersions(noteID) } -// RestoreVersion 将笔记恢复到指定版本(校验归属) +// RestoreVersion 将笔记恢复到指定版本(校验归属;管理员可操作任意) func (s *NoteService) RestoreVersion(userID, noteID, versionID uint) (*model.Note, error) { - note, err := s.repo.GetByIDScoped(userID, noteID) + var note *model.Note + var err error + if s.isAdmin(userID) { + note, err = s.repo.GetByID(noteID) + } else { + note, err = s.repo.GetByIDScoped(userID, noteID) + } if err != nil { return nil, errors.New("笔记不存在") } @@ -386,7 +472,6 @@ func (s *NoteService) RestoreVersion(userID, noteID, versionID uint) (*model.Not if err != nil { return nil, errors.New("版本不存在") } - _ = note // 保存当前状态为历史版本(防止覆盖) _, _ = s.repo.SaveVersion(note) // 回滚 @@ -402,9 +487,15 @@ func (s *NoteService) RestoreVersion(userID, noteID, versionID uint) (*model.Not // ─────────────── 分享 ─────────────── -// CreateShare 创建/更新分享令牌(校验归属) +// CreateShare 创建/更新分享令牌(校验归属;管理员可操作任意) func (s *NoteService) CreateShare(userID, noteID uint, expireHours int) (*model.Note, error) { - note, err := s.repo.GetByIDScoped(userID, noteID) + var note *model.Note + var err error + if s.isAdmin(userID) { + note, err = s.repo.GetByID(noteID) + } else { + note, err = s.repo.GetByIDScoped(userID, noteID) + } if err != nil { return nil, errors.New("笔记不存在") } @@ -424,9 +515,15 @@ func (s *NoteService) CreateShare(userID, noteID uint, expireHours int) (*model. return note, nil } -// RevokeShare 撤销分享(校验归属) +// RevokeShare 撤销分享(校验归属;管理员可操作任意) func (s *NoteService) RevokeShare(userID, noteID uint) error { - note, err := s.repo.GetByIDScoped(userID, noteID) + var note *model.Note + var err error + if s.isAdmin(userID) { + note, err = s.repo.GetByID(noteID) + } else { + note, err = s.repo.GetByIDScoped(userID, noteID) + } if err != nil { return errors.New("笔记不存在") } @@ -461,26 +558,35 @@ func (s *NoteService) UpgradePasswordHash(id uint, password string) error { // ─────────────── 自动保存草稿 ─────────────── -// SaveDraft 保存笔记草稿(仅更新草稿字段,不触发版本历史,校验归属) -// 返回是否有未保存草稿被记录 +// SaveDraft 保存笔记草稿(仅更新草稿字段,不触发版本历史,校验归属;管理员可操作任意) func (s *NoteService) SaveDraft(userID, id uint, content string) error { - note, err := s.repo.GetByIDScoped(userID, id) + var note *model.Note + var err error + if s.isAdmin(userID) { + note, err = s.repo.GetByID(id) + } else { + note, err = s.repo.GetByIDScoped(userID, id) + } if err != nil { return errors.New("笔记不存在") } if note.IsFolder { return errors.New("目录不支持草稿") } - _ = note // 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆) return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content}) } -// ClearDraft 清除笔记草稿(保存正文成功后调用,校验归属) +// ClearDraft 清除笔记草稿(保存正文成功后调用,校验归属;管理员可操作任意) func (s *NoteService) ClearDraft(userID, id uint) error { - _, err := s.repo.GetByIDScoped(userID, id) - if err != nil { - return errors.New("笔记不存在") + if s.isAdmin(userID) { + if _, err := s.repo.GetByID(id); err != nil { + return errors.New("笔记不存在") + } + } else { + if _, err := s.repo.GetByIDScoped(userID, id); err != nil { + return errors.New("笔记不存在") + } } return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": ""}) } @@ -490,27 +596,31 @@ func (s *NoteService) ClearDraft(userID, id uint) error { // wikiLinkRe 匹配笔记正文中的 [[wiki链接]] 语法 var wikiLinkRe = regexp.MustCompile(`\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]`) -// GetBacklinks 获取指向指定笔记的所有笔记(反向链接,校验归属) +// GetBacklinks 获取指向指定笔记的所有笔记(反向链接,校验归属;管理员可查看任意) func (s *NoteService) GetBacklinks(userID, noteID uint, title string) ([]model.NoteListItem, error) { var n *model.Note - if title == "" { - // 若未提供标题,先查一下 + if s.isAdmin(userID) { + var err error + n, err = s.repo.GetByID(noteID) + if err != nil { + return nil, errors.New("笔记不存在") + } + } else { var err error n, err = s.repo.GetByIDScoped(userID, noteID) if err != nil { return nil, errors.New("笔记不存在") } - } else { - // 校验归属(避免越权读取他人笔记反链) - scoped, err := s.repo.GetByIDScoped(userID, noteID) - if err != nil { - return nil, errors.New("笔记不存在") - } - n = scoped } title = n.Title - all, err := s.repo.GetAllNotesLight(userID) + var all []model.NoteListItem + var err error + if s.isAdmin(userID) { + all, err = s.repo.GetAllNotesLightAll() + } else { + all, err = s.repo.GetAllNotesLight(userID) + } if err != nil { return nil, err } @@ -521,8 +631,13 @@ func (s *NoteService) GetBacklinks(userID, noteID uint, title string) ([]model.N if note.ID == noteID { continue } - full, err := s.repo.GetByIDScoped(userID, note.ID) - if err != nil { + var full *model.Note + if s.isAdmin(userID) { + full, _ = s.repo.GetByID(note.ID) + } else { + full, _ = s.repo.GetByIDScoped(userID, note.ID) + } + if full == nil { continue } if strings.Contains(full.Content, "[["+title+"]]") { @@ -544,9 +659,19 @@ type GraphEdge struct { Target uint `json:"target"` } -// GetKnowledgeGraph 构建当前用户的知识图谱(节点 + [[链接]] 边) +// GetKnowledgeGraph 构建当前用户的知识图谱(节点 + [[链接]] 边;管理员看全平台) func (s *NoteService) GetKnowledgeGraph(userID uint) (map[string]interface{}, error) { - all, err := s.repo.GetAllContentLight(userID) + var all []struct { + ID uint `gorm:"column:id"` + Title string `gorm:"column:title"` + Content string `gorm:"column:content"` + } + var err error + if s.isAdmin(userID) { + all, err = s.repo.GetAllContentLightAll() + } else { + all, err = s.repo.GetAllContentLight(userID) + } if err != nil { return nil, err } @@ -587,7 +712,7 @@ func (s *NoteService) GetKnowledgeGraph(userID uint) (map[string]interface{}, er // ─────────────── 标签管理 ─────────────── -// RenameTag 重命名标签(当前用户所有含该标签的笔记同步更新) +// RenameTag 重命名标签(当前用户所有含该标签的笔记同步更新;管理员跨全平台) func (s *NoteService) RenameTag(userID uint, oldTag, newTag string) (int64, error) { if oldTag == "" || newTag == "" { return 0, errors.New("标签名不能为空") @@ -595,10 +720,13 @@ func (s *NoteService) RenameTag(userID uint, oldTag, newTag string) (int64, erro if oldTag == newTag { return 0, nil } + if s.isAdmin(userID) { + return s.repo.UpdateTagAllAll(oldTag, newTag) + } return s.repo.UpdateTagAll(userID, oldTag, newTag) } -// MergeTag 将 from 标签合并到 to 标签(current 用户,from 消失) +// MergeTag 将 from 标签合并到 to 标签(current 用户,from 消失;管理员跨全平台) func (s *NoteService) MergeTag(userID uint, from, to string) (int64, error) { if from == "" || to == "" { return 0, errors.New("标签名不能为空") @@ -606,22 +734,34 @@ func (s *NoteService) MergeTag(userID uint, from, to string) (int64, error) { if from == to { return 0, nil } + if s.isAdmin(userID) { + return s.repo.UpdateTagAllAll(from, to) + } return s.repo.UpdateTagAll(userID, from, to) } -// DeleteTag 删除指定标签(当前用户所有笔记中移除) +// DeleteTag 删除指定标签(当前用户所有笔记中移除;管理员跨全平台) func (s *NoteService) DeleteTag(userID uint, tag string) (int64, error) { if tag == "" { return 0, errors.New("标签名不能为空") } + if s.isAdmin(userID) { + return s.repo.UpdateTagAllAll(tag, "") + } return s.repo.UpdateTagAll(userID, tag, "") } -// GetTagUsage 获取当前用户每个标签及其使用次数 +// GetTagUsage 获取当前用户每个标签及其使用次数(管理员跨全平台) func (s *NoteService) GetTagUsage(userID uint) ([]model.TagUsage, error) { var result []model.TagUsage counts := make(map[string]int) - all, err := s.repo.GetAllNotesLight(userID) + var all []model.NoteListItem + var err error + if s.isAdmin(userID) { + all, err = s.repo.GetAllNotesLightAll() + } else { + all, err = s.repo.GetAllNotesLight(userID) + } if err != nil { return nil, err } diff --git a/service/user_service.go b/service/user_service.go index 042fd92..2f3967a 100644 --- a/service/user_service.go +++ b/service/user_service.go @@ -11,15 +11,37 @@ import ( // UserService 用户/账号业务逻辑(多租户认证) type UserService struct { userRepo *repository.UserRepository + // registrationEnabled 以 env 为准的注册默认开关;DB 设置项可覆盖(运行时切换) + registrationDefault string } // NewUserService 创建用户服务 func NewUserService(userRepo *repository.UserRepository) *UserService { - return &UserService{userRepo: userRepo} + return &UserService{userRepo: userRepo, registrationDefault: "true"} +} + +// SetRegistrationDefault 设置注册开关的默认值(来自环境变量) +func (s *UserService) SetRegistrationDefault(v string) { + s.registrationDefault = v +} + +// RegistrationEnabled 当前注册开关是否开启 +func (s *UserService) RegistrationEnabled() bool { + val := s.userRepo.GetSetting("registration_enabled", s.registrationDefault) + return val == "1" || val == "true" +} + +// SetRegistrationEnabled 切换注册开关(持久化到 DB) +func (s *UserService) SetRegistrationEnabled(on bool) error { + v := "false" + if on { + v = "true" + } + return s.userRepo.SetSetting("registration_enabled", v) } // Register 注册新用户 -// 说明:第一个注册的用户自动成为 admin(拥有平台管理权限);其余为普通 user。 +// 说明:首个注册的用户自动成为 admin(拥有平台管理权限);其余为普通 user。 // 同时把历史遗留(user_id=0)的笔记迁移给首位注册用户。 func (s *UserService) Register(username, password, displayName string) (*model.User, error) { username = strings.TrimSpace(strings.ToLower(username)) @@ -30,6 +52,9 @@ func (s *UserService) Register(username, password, displayName string) (*model.U if len(password) < 6 { return nil, errors.New("密码至少 6 位") } + if !s.RegistrationEnabled() { + return nil, errors.New("注册功能已关闭") + } if _, err := s.userRepo.GetByUsername(username); err == nil { return nil, errors.New("用户名已存在") } @@ -66,6 +91,30 @@ func (s *UserService) Register(username, password, displayName string) (*model.U return u, nil } +// IsAdmin 判断用户是否为管理员 +func (s *UserService) IsAdmin(userID uint) bool { + if userID == 0 { + return false + } + u, err := s.userRepo.GetByID(userID) + if err != nil || u == nil { + return false + } + return u.Role == "admin" +} + +// GetRole 返回用户角色(admin/user/空) +func (s *UserService) GetRole(userID uint) string { + if userID == 0 { + return "" + } + u, err := s.userRepo.GetByID(userID) + if err != nil || u == nil { + return "" + } + return u.Role +} + // Login 校验用户名密码,返回用户 func (s *UserService) Login(username, password string) (*model.User, error) { username = strings.TrimSpace(strings.ToLower(username)) diff --git a/smoke_test.py b/smoke_test.py index 839cda6..08b2400 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -359,24 +359,31 @@ report("合并后标签去重", "已重命名" in tag_str and "保留" not in ta st, d = req("DELETE", "/admin/api/tags", {"name": "已重命名"}) report("删除标签", st == 200, f"(got {st})") -# ── 多租户数据隔离 ── -print("\n═══ 12b. 多租户数据隔离 ═══") -# 记住当前测试用户可见的笔记(应只有本脚本创建的 + 自己空间的) -st, d = req("GET", "/admin/api/tree") -own_ids = {x["id"] for x in d.get("data", [])} +# ═══ 12b. 多租户数据隔离 + 管理员跨租户管理 ═══ +print("\n═══ 12b. 多租户数据隔离 + 管理员跨租户管理 ═══") +# 平台管理员账号(首个注册的 admin,prod 已存在 admin/admin123) +ADMINU = "admin" +ADMINP = "admin123" -# 注册第二个用户 -st, d = req("POST", "/api/auth/register", {"username": "iso_" + _uuid.uuid4().hex[:8], "password": "iso12345"}) +# 切到管理员 +st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) +admin_ok = st == 200 and d.get("code") == 0 +report("管理员账号可登录", admin_ok, f"(got {st} {d})") +if not admin_ok: + # 若无 admin 账号,则首个用户即管理员;这里直接注册一个新管理员作为降级 + st, d = req("POST", "/api/auth/register", {"username": "_adm_" + _uuid.uuid4().hex[:6], "password": "adm12345"}) + report("(降级)注册新管理员", st == 200 and d.get("code") == 0, f"(got {st} {d})") + +# 注册第二个普通用户 +ISOUSER = "iso_" + _uuid.uuid4().hex[:8] +st, d = req("POST", "/api/auth/register", {"username": ISOUSER, "password": "iso12345"}) report("第二个用户注册成功", st == 200 and d.get("code") == 0, f"(got {st})") -# 第二个用户的树必须是空的(看不到第一个用户的笔记) +# 第二个用户的树必须是空的(看不到别人的笔记) st, d = req("GET", "/admin/api/tree") other_ids = {x["id"] for x in d.get("data", [])} report("第二用户树为空(数据隔离)", st == 200 and len(other_ids) == 0, f"(got {other_ids})") -# 两用户笔记 ID 无交集 -report("两用户数据无交集", len(own_ids & other_ids) == 0, f"(own={own_ids} other={other_ids})") - # 第二用户无权读取第一用户创建的笔记 st, d = req("GET", f"/admin/api/notes/{note_id}") report("越权读取他人笔记被拒(404)", st == 404, f"(got {st})") @@ -391,12 +398,66 @@ st, d = req("GET", f"/api/notes/search?{q}") other_hits = d.get("data") or [] report("第二用户搜不到他人笔记", len(other_hits) == 0, f"(got {len(other_hits)})") +# 第二用户创建一篇自己的私有笔记(供管理员跨租户操作) +st, d = req("POST", "/admin/api/notes", {"title": "第二用户的私有笔记", "content": "only visible to owner + admin", "is_public": False}) +iso_note_id = d.get("data", {}).get("id") +report("第二用户创建私有笔记", st == 200 and iso_note_id, f"(got {st} {d})") + +# 管理员跨租户管理:管理员应能读取第二用户的私有笔记 +st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) +report("切回管理员成功", st == 200 and d.get("code") == 0, f"(got {st})") + +st, d = req("GET", f"/admin/api/notes/{iso_note_id}") +report("管理员可读取他人(第二用户)笔记", st == 200 and d.get("code") == 0 and d.get("data", {}).get("title") == "第二用户的私有笔记", f"(got {st} {d})") + +# 管理员可更新他人笔记(收藏) +st, d = req("PUT", f"/admin/api/notes/{iso_note_id}", {"is_favorite": True}) +report("管理员可修改他人笔记", st == 200 and d.get("code") == 0, f"(got {st})") + +# 管理员树包含第二用户的笔记(全平台视角) +st, d = req("GET", "/admin/api/tree") +admin_all = {x["id"] for x in d.get("data", [])} +report("管理员树含第二用户笔记(全平台)", iso_note_id in admin_all, f"(got {iso_note_id} in {admin_all})") + # 游客公开接口能读取公开笔记(跨租户展示,需登出为游客) req("POST", "/api/auth/logout") st, d = req("GET", f"/api/notes/{note_id}") report("游客可读公开笔记", st == 200 and d.get("code") == 0, f"(got {st})") -# 重新登录回第一个测试用户(后续清理需要) +# ═══ 12c. 注册开关 ═══ +print("\n═══ 12c. 注册开关 ═══") +# 切回管理员 +st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) +report("切回管理员(开关注册测试)", st == 200 and d.get("code") == 0, f"(got {st})") + +# 普通用户无权限修改注册开关 +st, d = req("POST", "/api/auth/login", {"username": ISOUSER, "password": "iso12345"}) +report("切到普通用户", st == 200 and d.get("code") == 0, f"(got {st})") +st, d = req("PUT", "/admin/api/settings/registration", {"enabled": False}) +report("普通用户无权改注册开关(403)", st == 403, f"(got {st})") + +# 管理员关闭注册 +st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) +st, d = req("PUT", "/admin/api/settings/registration", {"enabled": False}) +report("管理员关闭注册", st == 200 and d.get("data", {}).get("registration_enabled") == False, f"(got {st} {d})") + +# 关闭后再注册应被拒 +st, d = req("POST", "/api/auth/register", {"username": "blocked_" + _uuid.uuid4().hex[:6], "password": "pass12345"}) +report("注册关闭后被拒绝", st == 400, f"(got {st} {d})") + +# 管理员重新开启注册 +st, d = req("PUT", "/admin/api/settings/registration", {"enabled": True}) +report("管理员开启注册", st == 200 and d.get("data", {}).get("registration_enabled") == True, f"(got {st} {d})") + +# 重新切回第一个测试用户用于清理并删除第二用户的笔记 +st, d = req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"}) +report("切回测试用户", st == 200 and d.get("code") == 0, f"(got {st})") +# 管理员清理第二用户笔记前,先切回管理员 +st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) +st, d = req("POST", f"/admin/api/purge/{iso_note_id}") +report("清理第二用户笔记", st in (200, 500), f"(got {st})") + +# 切回第一个测试用户(后续清理需要) st, d = req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"}) report("切回测试用户成功", st == 200 and d.get("code") == 0, f"(got {st})") diff --git a/web/admin/index.html b/web/admin/index.html index c635df0..5986a3c 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -634,6 +634,21 @@ body.dark .tag-row .tag-name { color: #e6e6e6; } body.dark .tag-row .tag-count { background: #20242e; color: #9aa0aa; } body.dark .tags-empty { color: #9aa0aa; } + /* 平台设置:注册开关 */ + .switch { position: relative; display: inline-block; width: 48px; height: 26px; } + .switch input { opacity: 0; width: 0; height: 0; } + .slider { + position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; + background: #ccc; transition: .3s; border-radius: 26px; + } + .slider:before { + position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; + background: #fff; transition: .3s; border-radius: 50%; + } + .switch input:checked + .slider { background: #1976d2; } + .switch input:checked + .slider:before { transform: translateX(22px); } + body.dark .slider { background: #2a3040; } + body.dark .setting-row { border-bottom-color: #232733; } @@ -649,6 +664,7 @@ + 前台预览 @@ -805,6 +821,27 @@ + + + +