From b2cae2471d42b97b3047ce9f7cdadedb82a858fa Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Mon, 25 May 2026 11:21:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=BB=E5=8A=A0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E5=92=8CLinux.do=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增AdminUser类型定义及用户管理API接口 - 实现用户列表查询、保存和删除功能 - 添加Linux.do第三方登录支持 - 集成Linux.do OAuth认证流程 - 在系统设置中添加Linux.do登录配置选项 - 实现算力点余额调整记录功能 - 优化搜索组件关键词状态管理 - 更新数据库迁移添加credit_logs表 - 完善用户状态和信用等级验证逻辑 --- config/config.go | 17 +- docs/backend-database.md | 30 +- docs/pending-test.md | 1 + docs/todo.md | 38 +-- handler/auth.go | 60 +++- model/setting.go | 25 +- model/user.go | 77 ++++-- repository/db.go | 1 + repository/user.go | 23 ++ router/router.go | 2 + service/auth.go | 257 +++++++++++++++++- service/settings.go | 11 + web/public/icons/linuxdo.svg | 9 + web/src/app/(admin)/admin/assets/page.tsx | 16 +- web/src/app/(admin)/admin/layout.tsx | 15 +- web/src/app/(admin)/admin/page.tsx | 2 +- web/src/app/(admin)/admin/prompts/page.tsx | 16 +- web/src/app/(admin)/admin/settings/page.tsx | 68 ++++- web/src/app/(admin)/admin/users/page.tsx | 244 +++++++++++++++++ .../(admin)/admin/users/use-admin-users.ts | 79 ++++++ web/src/app/(user)/login/page.tsx | 65 ++++- web/src/app/api/[...path]/route.ts | 1 + .../components/layout/user-status-actions.tsx | 24 +- web/src/services/api/admin.ts | 54 ++++ web/src/services/api/auth.ts | 5 +- 25 files changed, 1023 insertions(+), 117 deletions(-) create mode 100644 web/public/icons/linuxdo.svg create mode 100644 web/src/app/(admin)/admin/users/page.tsx create mode 100644 web/src/app/(admin)/admin/users/use-admin-users.ts diff --git a/config/config.go b/config/config.go index bcee3f0..f91f51f 100644 --- a/config/config.go +++ b/config/config.go @@ -10,13 +10,16 @@ import ( ) type Config struct { - Port string `env:"PORT" envDefault:"8080"` - AdminUsername string `env:"ADMIN_USERNAME" envDefault:"admin"` - AdminPassword string `env:"ADMIN_PASSWORD" envDefault:"infinite-canvas"` - JWTSecret string `env:"JWT_SECRET" envDefault:"infinite-canvas"` - JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"` - StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"` - DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"` + Port string `env:"PORT" envDefault:"8080"` + AdminUsername string `env:"ADMIN_USERNAME" envDefault:"admin"` + AdminPassword string `env:"ADMIN_PASSWORD" envDefault:"infinite-canvas"` + JWTSecret string `env:"JWT_SECRET" envDefault:"infinite-canvas"` + JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"` + StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"` + DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"` + LinuxDoAuthorizeURL string `env:"LINUX_DO_AUTHORIZE_URL" envDefault:"https://connect.linux.do/oauth2/authorize"` + LinuxDoTokenURL string `env:"LINUX_DO_TOKEN_URL" envDefault:"https://connect.linux.do/oauth2/token"` + LinuxDoUserInfoURL string `env:"LINUX_DO_USERINFO_URL" envDefault:"https://connect.linux.do/api/user"` } var Cfg Config diff --git a/docs/backend-database.md b/docs/backend-database.md index f91a3eb..016dc32 100644 --- a/docs/backend-database.md +++ b/docs/backend-database.md @@ -15,6 +15,7 @@ 当前启动时执行 `AutoMigrate`,自动维护以下表: - `users` +- `credit_logs` - `prompts` - `assets` - `settings` @@ -34,14 +35,14 @@ | `display_name` | string | 昵称 | | `avatar_url` | string | 头像地址 | | `role` | string | 角色:`user`、`admin` | -| `credits` | number | 算力点余额,规划字段 | -| `aff_code` | string | 用户自己的邀请码,唯一索引,规划字段 | -| `aff_count` | number | 已邀请用户数量,冗余统计字段,规划字段 | -| `inviter_id` | string | 邀请人用户 ID,规划字段 | +| `credits` | number | 算力点余额 | +| `aff_code` | string | 用户自己的邀请码,唯一索引 | +| `aff_count` | number | 已邀请用户数量,冗余统计字段 | +| `inviter_id` | string | 邀请人用户 ID | | `github_id` | string | GitHub 用户 ID,规划字段 | -| `linux_do_id` | string | Linux.do 用户 ID,规划字段 | +| `linux_do_id` | string | Linux.do 用户 ID | | `wechat_id` | string | 微信用户 ID,规划字段 | -| `status` | string | 用户状态:`active`、`ban`,规划字段 | +| `status` | string | 用户状态:`active`、`ban` | | `last_login_at` | string | 最近登录时间 | | `extra` | json | 扩展信息 | | `created_at` | string | 创建时间 | @@ -112,6 +113,7 @@ | 字段 | 类型 | 说明 | |-------------------|----------|----------------| | `modelChannel` | object | 模型渠道公开配置组 | +| `auth` | object | 公开登录配置 | `modelChannel` 当前字段: @@ -125,12 +127,19 @@ | `systemPrompt` | string | 系统提示词 | | `allowCustomChannel` | bool | 是否允许用户自定义渠道,默认允许,关闭后前端只提供走后端渠道的模式 | +`auth.linuxDo` 当前字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `enabled` | bool | 是否开启 Linux.do 登录 | + `private.value` 当前字段: | 字段 | 类型 | 说明 | |------------|----------|----------| | `channels` | object[] | 模型渠道配置列表 | | `promptSync` | object | GitHub 远程提示词定时同步配置 | +| `auth` | object | 私有登录配置 | `channels` 每项字段: @@ -152,6 +161,15 @@ | `enabled` | bool | 是否开启定时同步,默认开启 | | `cron` | string | Cron 表达式,默认每 5 分钟 | +`auth.linuxDo` 当前字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `clientId` | string | Linux.do OAuth App Client ID | +| `clientSecret` | string | Linux.do OAuth App Client Secret,后台返回时隐藏 | +| `redirectUri` | string | Linux.do OAuth 回调 URL | +| `minimumTrustLevel` | number | 允许登录的最低信任等级 | + 后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。 ### dicts diff --git a/docs/pending-test.md b/docs/pending-test.md index 3f742ba..1a976d5 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,5 +1,6 @@ # 待测试 +- 用户模块新增 `users` 完整字段和 `credit_logs` 表;账号密码注册/登录已开放,Linux.do OAuth 登录参数移到后台系统设置,登录页按开关展示 Linux.do 按钮并使用 `public/icons/linuxdo.svg` 图标,后台 `/admin/users` 可新增、编辑、删除用户并手动调整算力点,算力点变化会写入 `credit_logs`,需要确认注册、登录、Linux.do 配置保存和回调、后台筛选分页、用户保存删除和算力点流水都正常。 - 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `input_reference` 参考图生成视频,并按 OpenAI 视频接口格式提交 `multipart/form-data`、`size`、`seconds`、`vquality` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。 - 画布新增视频节点:工具栏可新建视频节点,上传/拖拽本地视频后可在节点内播放、下载、保存到我的素材;节点下方对话框和生成配置节点支持视频生成,前端会调用 OpenAI 兼容 `videos` 接口并把生成结果保存为本地视频 Blob;后端远程渠道新增 `/api/v1/videos`、`/api/v1/videos/:id`、`/api/v1/videos/:id/content` 代理,需要确认本地渠道和远程渠道都能生成、轮询和下载视频。 - 图片和视频节点会按素材或生成结果的真实宽高比调整节点尺寸;空图片/视频节点在尺寸设置切换到 16:9、9:16 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。 diff --git a/docs/todo.md b/docs/todo.md index 6a1ee7b..e500ef3 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -4,41 +4,7 @@ ## P0 近期优先 -暂无。 - -## P1 核心账号和后台 - - 登录注册和用户模块:新增 `users` 表,用户角色、算力点余额、邀请码、邀请人、邀请人数和第三方平台用户 ID 等直接放在用户表字段里;补齐登录、注册、第三方登录、用户信息、管理员用户管理等基础能力。 -- 字典管理:新增 `dicts` 表,一个字典一行,具体字典项用 JSON 保存;用于维护分类、标签、业务枚举、模型分类、日志类型等可配置项。 -- 日志管理:新增 `credit_logs` 记录用户算力点变更流水,新增 `api_logs` 记录第三方大模型 API 调用情况。 -- 对话工具调用:在助手对话中增加类似工具调用的形式,展示模型调用、生成图片、插入画布等步骤。 -- 画布助手对话框优化:把当前对话面板调整得更简洁,减少干扰,突出输入、引用和结果。 - -## P1 算力点和商业化 - -- 算力点模块:使用 `users` 表里的算力点余额字段,用于调用后端生图、文本等接口时扣除;扣除记录写入 `credit_logs`。 -- 算力点订阅模块:订阅套餐配置先放 `settings.public.value`,用户订阅记录新增 `subscriptions` - 表;用户购买订阅后,模型调用优先从可用订阅额度扣除,不足时再扣用户余额。 -- 支付模块:新增 `orders` 表,统一记录充值、订阅购买等订单,预留 Linux LDC 支付和第三方聚合支付;该模块优先级较低,先只设计结构,暂不重点实现。 -- 邀请奖励模块:邀请码、邀请人和邀请人数放 `users`,注册奖励、充值奖励配置放 `settings.private.value`,奖励发放记录写入 - `credit_logs`。 - -## P1 内容、素材和画布 - -- 文件存储管理:新增 `files` 表和存储接口,文件表只记录最终可访问 URL 等基础信息;后续再考虑图片缩略图、文件清理等能力,后台可查询所有图片和文件。 -- 提示词管理:新增 `prompts` 表,公开提示词、内置 GitHub 系统提示词、分类和扩展信息等尽量放字段或 JSON,支持后台维护和随时爬取更新。 -- 提示词分类:已移除 `prompt_groups` 表;分类数据很少,并且必须和抓取代码一一对应,直接在代码内存里写死维护。 -- 画布管理:新增 `canvases` 表,后台管理员可以查看每个人的画布数据,普通用户可以查看自己的画布列表。 -- 公开画布:在 `canvases` 表增加公开状态、审核状态、公开信息等字段;管理员可将自己的画布设为公开,用户也可以申请公开并由管理员审核。 -- 画布分享与模板:在云端画布基础上支持只读分享、复制为模板、团队内共享,分享配置和模板标记优先放在 `canvases` 字段或 JSON 中。 -- 项目库:基于公开画布增加系统公开项目库,用于筛选、打开、复制公开画布项目。 -- 画布协作能力:后续如果支持多人协作,协作成员、节点操作事件、锁定/光标、冲突处理和增量同步优先放在 `canvases` 的协作 JSON - 中,确实不够用时再拆表。 -- 素材管理和我的素材:新增 `assets` 表,用 `user_id`、`visibility`、`type` 区分系统公开素材和用户私有素材;管理员可管理公开素材,也可以查看每个用户自己的素材。 -- 素材互动:公开素材增加点赞量、收藏量、查看量等统计字段;用户侧我的收藏、我的点赞先放在 `assets` 或用户偏好 JSON - 中,后续数据量大了再拆互动表。 - -## P2 体验和工程 - -- 接口调用队列:图片/文本生成接口增加队列、并发限制、后台状态和基础失败处理,避免大量请求直接打到模型渠道。 +- 日志管理:新增 `credit_logs` 记录用户算力点变更流水。 +- 算力点模块:使用 `users` 表里的算力点余额字段,用于调用后端生图、文本等接口时扣除;扣除记录写入 `credit_logs`。 \ No newline at end of file diff --git a/handler/auth.go b/handler/auth.go index 209f715..50e7171 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -3,6 +3,8 @@ package handler import ( "encoding/json" "net/http" + "net/url" + "strings" "github.com/basketikun/infinite-canvas/model" "github.com/basketikun/infinite-canvas/service" @@ -19,10 +21,15 @@ type registerRequest struct { } type saveUserRequest struct { - ID string `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - Role model.UserRole `json:"role"` + ID string `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + AvatarURL string `json:"avatarUrl"` + Role model.UserRole `json:"role"` + Credits int `json:"credits"` + Status model.UserStatus `json:"status"` } func Register(w http.ResponseWriter, r *http.Request) { @@ -44,11 +51,25 @@ func Login(w http.ResponseWriter, r *http.Request) { FailError(w, err) return } - if session.User.Role != model.UserRoleAdmin { - Fail(w, "需要管理员权限") + OK(w, session) +} + +func LinuxDoAuthorize(w http.ResponseWriter, r *http.Request) { + authURL, err := service.LinuxDoAuthorizeURL(r.URL.Query().Get("redirect")) + if err != nil { + FailError(w, err) return } - OK(w, session) + http.Redirect(w, r, authURL, http.StatusFound) +} + +func LinuxDoCallback(w http.ResponseWriter, r *http.Request) { + session, redirect, err := service.LoginWithLinuxDo(r.URL.Query().Get("code"), r.URL.Query().Get("state")) + if err != nil { + http.Redirect(w, r, loginRedirect(redirect, "", err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, loginRedirect(redirect, session.Token, ""), http.StatusFound) } func AdminLogin(w http.ResponseWriter, r *http.Request) { @@ -87,9 +108,14 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) { var request saveUserRequest _ = json.NewDecoder(r.Body).Decode(&request) user, err := service.SaveUser(model.User{ - ID: request.ID, - Username: request.Username, - Role: request.Role, + ID: request.ID, + Username: request.Username, + Email: request.Email, + DisplayName: request.DisplayName, + AvatarURL: request.AvatarURL, + Role: request.Role, + Credits: request.Credits, + Status: request.Status, }, request.Password) if err != nil { FailError(w, err) @@ -98,6 +124,20 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) { OK(w, user) } +func loginRedirect(redirect string, token string, message string) string { + values := url.Values{} + if strings.TrimSpace(token) != "" { + values.Set("token", token) + } + if strings.TrimSpace(message) != "" { + values.Set("error", message) + } + if strings.TrimSpace(redirect) != "" { + values.Set("redirect", redirect) + } + return "/login?" + values.Encode() +} + func AdminDeleteUser(w http.ResponseWriter, r *http.Request, id string) { if err := service.DeleteUser(id); err != nil { FailError(w, err) diff --git a/model/setting.go b/model/setting.go index 701d4a1..3b3a3b3 100644 --- a/model/setting.go +++ b/model/setting.go @@ -35,12 +35,22 @@ type PublicModelChannelSetting struct { // PublicSetting 公开配置。 type PublicSetting struct { ModelChannel PublicModelChannelSetting `json:"modelChannel"` + Auth PublicAuthSetting `json:"auth"` +} + +type PublicAuthSetting struct { + LinuxDo PublicLinuxDoAuthSetting `json:"linuxDo"` +} + +type PublicLinuxDoAuthSetting struct { + Enabled bool `json:"enabled"` } // PrivateSetting 私有配置。 type PrivateSetting struct { - Channels []ModelChannel `json:"channels"` - PromptSync PromptSyncSetting `json:"promptSync"` + Channels []ModelChannel `json:"channels"` + PromptSync PromptSyncSetting `json:"promptSync"` + Auth PrivateAuthSetting `json:"auth"` } // PromptSyncSetting 提示词定时同步配置。 @@ -49,6 +59,17 @@ type PromptSyncSetting struct { Cron string `json:"cron"` } +type PrivateAuthSetting struct { + LinuxDo PrivateLinuxDoAuthSetting `json:"linuxDo"` +} + +type PrivateLinuxDoAuthSetting struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectUri"` + MinimumTrustLevel int `json:"minimumTrustLevel"` +} + // Setting 系统配置。 type Setting struct { Key SettingKey `json:"key" gorm:"primaryKey"` diff --git a/model/user.go b/model/user.go index 770643c..17fa781 100644 --- a/model/user.go +++ b/model/user.go @@ -8,14 +8,34 @@ const ( UserRoleAdmin UserRole = "admin" ) +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusBan UserStatus = "ban" +) + // User 系统用户。 type User struct { - ID string `json:"id" gorm:"primaryKey"` - Username string `json:"username" gorm:"uniqueIndex"` - Password string `json:"password,omitempty"` - Role UserRole `json:"role"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id" gorm:"primaryKey"` + Username string `json:"username" gorm:"uniqueIndex"` + Password string `json:"password,omitempty"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + AvatarURL string `json:"avatarUrl"` + Role UserRole `json:"role"` + Credits int `json:"credits"` + AffCode string `json:"affCode" gorm:"uniqueIndex"` + AffCount int `json:"affCount"` + InviterID string `json:"inviterId"` + GithubID string `json:"githubId"` + LinuxDoID string `json:"linuxDoId" gorm:"index"` + WechatID string `json:"wechatId"` + Status UserStatus `json:"status"` + LastLoginAt string `json:"lastLoginAt"` + Extra string `json:"extra" gorm:"type:text"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // UserList 用户分页结果。 @@ -26,11 +46,14 @@ type UserList struct { // AuthUser 用户公开信息。 type AuthUser struct { - ID string `json:"id"` - Username string `json:"username"` - Role UserRole `json:"role"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + AvatarURL string `json:"avatarUrl"` + Role UserRole `json:"role"` + Credits int `json:"credits"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // AuthSession 登录会话信息。 @@ -41,10 +64,32 @@ type AuthSession struct { func PublicUser(user User) AuthUser { return AuthUser{ - ID: user.ID, - Username: user.Username, - Role: user.Role, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, + ID: user.ID, + Username: user.Username, + DisplayName: user.DisplayName, + AvatarURL: user.AvatarURL, + Role: user.Role, + Credits: user.Credits, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, } } + +type CreditLogType string + +const ( + CreditLogTypeAdminAdjust CreditLogType = "admin_adjust" +) + +// CreditLog 用户算力点变更流水。 +type CreditLog struct { + ID string `json:"id" gorm:"primaryKey"` + UserID string `json:"userId" gorm:"index"` + Type CreditLogType `json:"type"` + Amount int `json:"amount"` + Balance int `json:"balance"` + RelatedID string `json:"relatedId"` + Remark string `json:"remark"` + Extra string `json:"extra" gorm:"type:text"` + CreatedAt string `json:"createdAt"` +} diff --git a/repository/db.go b/repository/db.go index c7f4a08..aed850a 100644 --- a/repository/db.go +++ b/repository/db.go @@ -47,6 +47,7 @@ func DB() (*gorm.DB, error) { } dbErr = db.AutoMigrate( &model.User{}, + &model.CreditLog{}, &model.Prompt{}, &model.Asset{}, &model.Setting{}, diff --git a/repository/user.go b/repository/user.go index 1ef2615..cf77894 100644 --- a/repository/user.go +++ b/repository/user.go @@ -2,6 +2,7 @@ package repository import ( "errors" + "strings" "github.com/basketikun/infinite-canvas/model" "gorm.io/gorm" @@ -15,6 +16,10 @@ func ListUsers(q model.Query) ([]model.User, int64, error) { } q.Normalize() tx := db.Model(&model.User{}) + if keyword := strings.TrimSpace(q.Keyword); keyword != "" { + like := "%" + keyword + "%" + tx = tx.Where("username LIKE ? OR display_name LIKE ? OR email LIKE ? OR linux_do_id LIKE ?", like, like, like, like) + } var total int64 if err := tx.Count(&total).Error; err != nil { @@ -74,6 +79,15 @@ func SaveUser(user model.User) (model.User, error) { return user, db.Save(&user).Error } +// SaveCreditLog 保存算力点变更流水。 +func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) { + db, err := DB() + if err != nil { + return log, err + } + return log, db.Save(&log).Error +} + // DeleteUser 删除指定用户。 func DeleteUser(id string) error { db, err := DB() @@ -83,6 +97,15 @@ func DeleteUser(id string) error { return db.Delete(&model.User{}, "id = ?", id).Error } +// GetUserByLinuxDoID 根据 Linux.do ID 查询用户。 +func GetUserByLinuxDoID(id string) (model.User, bool, error) { + db, err := DB() + if err != nil { + return model.User{}, false, err + } + return findUser(db, "linux_do_id = ?", id) +} + // findUser 查询单个用户,并将未命中转换为 ok=false。 func findUser(db *gorm.DB, query string, args ...any) (model.User, bool, error) { user := model.User{} diff --git a/router/router.go b/router/router.go index b4f6c6a..f80350f 100644 --- a/router/router.go +++ b/router/router.go @@ -18,6 +18,8 @@ func New() *gin.Engine { }) api.POST("/auth/register", gin.WrapF(handler.Register)) api.POST("/auth/login", gin.WrapF(handler.Login)) + api.GET("/auth/linux-do/authorize", gin.WrapF(handler.LinuxDoAuthorize)) + api.GET("/auth/linux-do/callback", gin.WrapF(handler.LinuxDoCallback)) api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser)) api.GET("/settings", gin.WrapF(handler.Settings)) api.POST("/v1/images/generations", gin.WrapF(handler.AIImagesGenerations)) diff --git a/service/auth.go b/service/auth.go index 0d059d1..d789de7 100644 --- a/service/auth.go +++ b/service/auth.go @@ -1,8 +1,15 @@ package service import ( + "bytes" + "encoding/base64" + "encoding/json" "errors" + "fmt" + "io" "log" + "net/http" + "net/url" "strings" "time" @@ -39,6 +46,8 @@ func EnsureDefaultAdmin() error { Username: strings.TrimSpace(config.Cfg.AdminUsername), Password: hash, Role: model.UserRoleAdmin, + AffCode: newAffCode(), + Status: model.UserStatusActive, CreatedAt: now(), UpdatedAt: now(), }) @@ -46,19 +55,18 @@ func EnsureDefaultAdmin() error { } func Register(username string, password string) (model.AuthSession, error) { - return model.AuthSession{}, errors.New("注册功能暂时关闭") username = strings.TrimSpace(username) if strings.ContainsAny(username, " \t\r\n") { - return model.AuthSession{}, errors.New("用户名不能包含空格") + return model.AuthSession{}, safeMessageError{message: "用户名不能包含空格"} } if username == "" || password == "" { - return model.AuthSession{}, errors.New("用户名和密码不能为空") + return model.AuthSession{}, safeMessageError{message: "用户名和密码不能为空"} } if _, ok, err := repository.GetUserByUsername(username); err != nil || ok { if err != nil { return model.AuthSession{}, err } - return model.AuthSession{}, errors.New("用户名已存在") + return model.AuthSession{}, safeMessageError{message: "用户名已存在"} } hash, err := hashPassword(password) if err != nil { @@ -69,6 +77,8 @@ func Register(username string, password string) (model.AuthSession, error) { Username: username, Password: hash, Role: model.UserRoleUser, + AffCode: newAffCode(), + Status: model.UserStatusActive, CreatedAt: now(), UpdatedAt: now(), }) @@ -84,11 +94,102 @@ func Login(username string, password string) (model.AuthSession, error) { return model.AuthSession{}, err } if !ok || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { - return model.AuthSession{}, errors.New("用户名或密码错误") + return model.AuthSession{}, safeMessageError{message: "用户名或密码错误"} + } + if user.Status == model.UserStatusBan { + return model.AuthSession{}, safeMessageError{message: "账号已被禁用"} + } + normalizeUserDefaults(&user) + user.LastLoginAt = now() + user.UpdatedAt = now() + user, err = repository.SaveUser(user) + if err != nil { + return model.AuthSession{}, err } return newSession(user) } +func LinuxDoAuthorizeURL(redirect string) (string, error) { + settings, err := repository.GetSettings() + if err != nil { + return "", err + } + settings = normalizeSettings(settings) + linuxDo := settings.Private.Auth.LinuxDo + if !settings.Public.Auth.LinuxDo.Enabled { + return "", safeMessageError{message: "Linux.do 登录未开启"} + } + if strings.TrimSpace(linuxDo.ClientID) == "" || strings.TrimSpace(linuxDo.ClientSecret) == "" || strings.TrimSpace(linuxDo.RedirectURI) == "" { + return "", safeMessageError{message: "Linux.do 登录未配置"} + } + values := url.Values{} + values.Set("client_id", linuxDo.ClientID) + values.Set("redirect_uri", linuxDo.RedirectURI) + values.Set("response_type", "code") + values.Set("scope", "read") + values.Set("state", base64.RawURLEncoding.EncodeToString([]byte(redirect))) + return config.Cfg.LinuxDoAuthorizeURL + "?" + values.Encode(), nil +} + +func LoginWithLinuxDo(code string, state string) (model.AuthSession, string, error) { + redirect := decodeState(state) + settings, err := repository.GetSettings() + if err != nil { + return model.AuthSession{}, redirect, err + } + settings = normalizeSettings(settings) + linuxDo := settings.Private.Auth.LinuxDo + if !settings.Public.Auth.LinuxDo.Enabled { + return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 登录未开启"} + } + token, err := linuxDoAccessToken(code, linuxDo) + if err != nil { + return model.AuthSession{}, redirect, err + } + profile, err := linuxDoProfile(token) + if err != nil { + return model.AuthSession{}, redirect, err + } + linuxDoID := fmt.Sprint(profile.ID) + if strings.TrimSpace(linuxDoID) == "" || linuxDoID == "0" { + return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 用户信息无效"} + } + if profile.TrustLevel < linuxDo.MinimumTrustLevel { + return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 账号信任等级不足"} + } + user, ok, err := repository.GetUserByLinuxDoID(linuxDoID) + if err != nil { + return model.AuthSession{}, redirect, err + } + if !ok { + user = model.User{ + ID: newID("user"), + Username: linuxDoUsername(profile.Username, linuxDoID), + DisplayName: strings.TrimSpace(profile.Name), + AvatarURL: linuxDoAvatar(profile.AvatarTemplate), + Role: model.UserRoleUser, + AffCode: newAffCode(), + LinuxDoID: linuxDoID, + Status: model.UserStatusActive, + CreatedAt: now(), + } + } else if user.Status == model.UserStatusBan { + return model.AuthSession{}, redirect, safeMessageError{message: "账号已被禁用"} + } + user.DisplayName = firstNonEmpty(profile.Name, user.DisplayName) + user.AvatarURL = firstNonEmpty(linuxDoAvatar(profile.AvatarTemplate), user.AvatarURL) + user.LastLoginAt = now() + user.UpdatedAt = now() + extra, _ := json.Marshal(profile) + user.Extra = string(extra) + user, err = repository.SaveUser(user) + if err != nil { + return model.AuthSession{}, redirect, err + } + session, err := newSession(user) + return session, redirect, err +} + func ParseToken(tokenText string) (TokenClaims, error) { claims := TokenClaims{} token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) { @@ -122,6 +223,7 @@ func ListUsers(q model.Query) (model.UserList, error) { } for i := range users { users[i].Password = "" + normalizeUserDefaults(&users[i]) } return model.UserList{Items: users, Total: int(total)}, nil } @@ -129,27 +231,43 @@ func ListUsers(q model.Query) (model.UserList, error) { func SaveUser(user model.User, password string) (model.User, error) { user.Username = strings.TrimSpace(user.Username) if strings.ContainsAny(user.Username, " \t\r\n") { - return user, errors.New("用户名不能包含空格") + return user, safeMessageError{message: "用户名不能包含空格"} } if user.Username == "" { - return user, errors.New("用户名不能为空") + return user, safeMessageError{message: "用户名不能为空"} } if user.Role == "" || user.Role == model.UserRoleGuest { user.Role = model.UserRoleUser } + if user.Status == "" { + user.Status = model.UserStatusActive + } if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil { return user, err } else if ok && saved.ID != user.ID { - return user, errors.New("用户名已存在") + return user, safeMessageError{message: "用户名已存在"} } + oldCredits := 0 if user.ID == "" { user.ID = newID("user") + user.AffCode = newAffCode() user.CreatedAt = now() } else if saved, ok, err := repository.GetUserByID(user.ID); err != nil { return user, err } else if ok { + oldCredits = saved.Credits user.CreatedAt = saved.CreatedAt user.Password = saved.Password + if user.AffCode == "" { + user.AffCode = saved.AffCode + } + if user.AffCode == "" { + user.AffCode = newAffCode() + } + if user.LinuxDoID == "" { + user.LinuxDoID = saved.LinuxDoID + } + user.LastLoginAt = saved.LastLoginAt } if password != "" { hash, err := hashPassword(password) @@ -159,10 +277,21 @@ func SaveUser(user model.User, password string) (model.User, error) { user.Password = hash } if user.Password == "" { - return user, errors.New("密码不能为空") + return user, safeMessageError{message: "密码不能为空"} } user.UpdatedAt = now() user, err := repository.SaveUser(user) + if err == nil && user.Credits != oldCredits { + _, err = repository.SaveCreditLog(model.CreditLog{ + ID: newID("credit"), + UserID: user.ID, + Type: model.CreditLogTypeAdminAdjust, + Amount: user.Credits - oldCredits, + Balance: user.Credits, + Remark: "后台手动调整", + CreatedAt: now(), + }) + } user.Password = "" return user, err } @@ -214,6 +343,116 @@ func newID(prefix string) string { return prefix + "-" + uuid.NewString() } +func newAffCode() string { + return strings.ToUpper(strings.ReplaceAll(uuid.NewString()[:8], "-", "")) +} + +func normalizeUserDefaults(user *model.User) { + if user.Status == "" { + user.Status = model.UserStatusActive + } + if user.AffCode == "" { + user.AffCode = newAffCode() + } +} + +type linuxDoTokenResponse struct { + AccessToken string `json:"access_token"` +} + +type linuxDoUserResponse struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + AvatarTemplate string `json:"avatar_template"` + TrustLevel int `json:"trust_level"` +} + +func linuxDoAccessToken(code string, setting model.PrivateLinuxDoAuthSetting) (string, error) { + values := url.Values{} + values.Set("client_id", setting.ClientID) + values.Set("client_secret", setting.ClientSecret) + values.Set("grant_type", "authorization_code") + values.Set("code", code) + values.Set("redirect_uri", setting.RedirectURI) + req, _ := http.NewRequest(http.MethodPost, config.Cfg.LinuxDoTokenURL, strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + var payload linuxDoTokenResponse + if err := doLinuxDoJSON(req, &payload); err != nil { + return "", err + } + if strings.TrimSpace(payload.AccessToken) == "" { + return "", safeMessageError{message: "Linux.do 登录失败"} + } + return payload.AccessToken, nil +} + +func linuxDoProfile(token string) (linuxDoUserResponse, error) { + req, _ := http.NewRequest(http.MethodGet, config.Cfg.LinuxDoUserInfoURL, nil) + req.Header.Set("Authorization", "Bearer "+token) + var payload linuxDoUserResponse + err := doLinuxDoJSON(req, &payload) + return payload, err +} + +func doLinuxDoJSON(req *http.Request, payload any) error { + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return safeMessageError{message: "Linux.do 登录失败"} + } + return json.NewDecoder(bytes.NewReader(body)).Decode(payload) +} + +func linuxDoUsername(username string, id string) string { + base := strings.TrimSpace(username) + if base == "" { + base = "linuxdo-" + id + } + if _, ok, err := repository.GetUserByUsername(base); err != nil || !ok { + return base + } + return base + "-" + id +} + +func linuxDoAvatar(template string) string { + if strings.TrimSpace(template) == "" { + return "" + } + if strings.HasPrefix(template, "//") { + template = "https:" + template + } + if strings.HasPrefix(template, "/") { + template = "https://linux.do" + template + } + return strings.ReplaceAll(template, "{size}", "120") +} + +func decodeState(state string) string { + data, err := base64.RawURLEncoding.DecodeString(state) + if err != nil { + return "/" + } + redirect := string(data) + if !strings.HasPrefix(redirect, "/") { + return "/" + } + return redirect +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + func WarnDefaultSecurityConfig() { if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" { log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment") diff --git a/service/settings.go b/service/settings.go index 1b9a3f2..7267b14 100644 --- a/service/settings.go +++ b/service/settings.go @@ -31,6 +31,7 @@ func SaveSettings(settings model.Settings) (model.Settings, error) { } settings = normalizeSettings(settings) keepPrivateAPIKeys(&settings, normalizeSettings(saved)) + keepPrivateAuthSecrets(&settings, normalizeSettings(saved)) result, err := repository.SaveSettings(settings, now()) if err == nil { RefreshPromptSyncScheduler() @@ -76,6 +77,9 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting setting.Channels = []model.ModelChannel{} } setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync) + if setting.Auth.LinuxDo.MinimumTrustLevel < 0 { + setting.Auth.LinuxDo.MinimumTrustLevel = 0 + } for i := range setting.Channels { if setting.Channels[i].Protocol == "" { setting.Channels[i].Protocol = "openai" @@ -94,6 +98,7 @@ func hidePrivateAPIKeys(settings model.Settings) model.Settings { for i := range settings.Private.Channels { settings.Private.Channels[i].APIKey = "" } + settings.Private.Auth.LinuxDo.ClientSecret = "" return settings } @@ -108,6 +113,12 @@ func keepPrivateAPIKeys(settings *model.Settings, saved model.Settings) { } } +func keepPrivateAuthSecrets(settings *model.Settings, saved model.Settings) { + if strings.TrimSpace(settings.Private.Auth.LinuxDo.ClientSecret) == "" { + settings.Private.Auth.LinuxDo.ClientSecret = saved.Private.Auth.LinuxDo.ClientSecret + } +} + func findSavedChannel(channel model.ModelChannel, saved []model.ModelChannel, index int) (model.ModelChannel, bool) { for _, item := range saved { if item.Name == channel.Name && item.BaseURL == channel.BaseURL { diff --git a/web/public/icons/linuxdo.svg b/web/public/icons/linuxdo.svg new file mode 100644 index 0000000..e79667b --- /dev/null +++ b/web/public/icons/linuxdo.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/web/src/app/(admin)/admin/assets/page.tsx b/web/src/app/(admin)/admin/assets/page.tsx index 5fdfefa..ae9a7ba 100644 --- a/web/src/app/(admin)/admin/assets/page.tsx +++ b/web/src/app/(admin)/admin/assets/page.tsx @@ -23,6 +23,7 @@ export default function AdminAssetsPage() { const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets(); const copyText = useCopyText(); const [form] = Form.useForm(); + const [keywordText, setKeywordText] = useState(keyword); const [editingAsset, setEditingAsset] = useState | null>(null); const [detailAsset, setDetailAsset] = useState(null); const [deletingAsset, setDeletingAsset] = useState(null); @@ -33,6 +34,8 @@ export default function AdminAssetsPage() { if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" }); }, [editingAsset, form]); + useEffect(() => setKeywordText(keyword), [keyword]); + const saveAsset = async () => { const value = await form.validateFields(); const nextType = value.type || "text"; @@ -119,7 +122,7 @@ export default function AdminAssetsPage() { - } onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} /> + } onSearch={() => searchAssets(keywordText)} onChange={(event) => setKeywordText(event.target.value)} /> @@ -135,8 +138,15 @@ export default function AdminAssetsPage() { - - + diff --git a/web/src/app/(admin)/admin/layout.tsx b/web/src/app/(admin)/admin/layout.tsx index 8ebbbff..3882625 100644 --- a/web/src/app/(admin)/admin/layout.tsx +++ b/web/src/app/(admin)/admin/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined } from "@ant-design/icons"; +import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined, UserOutlined } from "@ant-design/icons"; import { Button, Flex, Layout, Menu, Typography, theme } from "antd"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; @@ -12,6 +12,7 @@ import { adminLayoutStyle } from "@/lib/app-theme"; import { useUserStore } from "@/stores/use-user-store"; const adminMenus = [ + { key: "/admin/users", icon: , label: "用户管理" }, { key: "/admin/prompts", icon: , label: "提示词管理" }, { key: "/admin/assets", icon: , label: "素材库" }, { key: "/admin/settings", icon: , label: "系统设置" }, @@ -25,8 +26,16 @@ export default function AdminLayout({ children }: { children: ReactNode }) { const user = useUserStore((state) => state.user); const isReady = useUserStore((state) => state.isReady); const logout = useUserStore((state) => state.clearSession); - const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : ""; - const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理"; + const activeKey = pathname.startsWith("/admin/settings") + ? "/admin/settings" + : pathname.startsWith("/admin/assets") + ? "/admin/assets" + : pathname.startsWith("/admin/prompts") + ? "/admin/prompts" + : pathname.startsWith("/admin/users") + ? "/admin/users" + : ""; + const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : pathname.startsWith("/admin/prompts") ? "提示词管理" : "用户管理"; useEffect(() => { if (!isReady) return; diff --git a/web/src/app/(admin)/admin/page.tsx b/web/src/app/(admin)/admin/page.tsx index ace3dbf..5593850 100644 --- a/web/src/app/(admin)/admin/page.tsx +++ b/web/src/app/(admin)/admin/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function AdminPage() { - redirect("/admin/assets"); + redirect("/admin/users"); } diff --git a/web/src/app/(admin)/admin/prompts/page.tsx b/web/src/app/(admin)/admin/prompts/page.tsx index d604376..6d9baa7 100644 --- a/web/src/app/(admin)/admin/prompts/page.tsx +++ b/web/src/app/(admin)/admin/prompts/page.tsx @@ -36,6 +36,7 @@ export default function AdminPromptsPage() { } = useAdminPrompts(); const copyText = useCopyText(); const [form] = Form.useForm & { tagText?: string }>(); + const [keywordText, setKeywordText] = useState(keyword); const [editingPrompt, setEditingPrompt] = useState | null>(null); const [detailPrompt, setDetailPrompt] = useState(null); const [deletingPrompt, setDeletingPrompt] = useState(null); @@ -51,6 +52,8 @@ export default function AdminPromptsPage() { if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" }); }, [editingPrompt, form]); + useEffect(() => setKeywordText(keyword), [keyword]); + const savePrompt = async () => { const value = await form.validateFields(); await saveAdminPrompt({ @@ -135,7 +138,7 @@ export default function AdminPromptsPage() { - } onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} /> + } onSearch={() => searchPrompts(keywordText)} onChange={(event) => setKeywordText(event.target.value)} /> @@ -151,8 +154,15 @@ export default function AdminPromptsPage() { - - + diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 456763a..51502eb 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -35,8 +35,9 @@ const emptySettings: AdminSettings = { systemPrompt: "", allowCustomChannel: true, }, + auth: { linuxDo: { enabled: false } }, }, - private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" } }, + private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "", redirectUri: "", minimumTrustLevel: 0 } } }, }; const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" }; @@ -61,7 +62,9 @@ export default function AdminSettingsPage() { const [testResults, setTestResults] = useState>({}); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [linuxDoCallbackUrl, setLinuxDoCallbackUrl] = useState(""); const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || []; + const linuxDoRedirectUri = Form.useWatch(["private", "auth", "linuxDo", "redirectUri"], form); const channelModels = useMemo(() => collectChannelModels(channels), [channels]); const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]); const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]); @@ -91,6 +94,16 @@ export default function AdminSettingsPage() { void loadSettings(); }, [token]); + useEffect(() => { + setLinuxDoCallbackUrl(`${window.location.origin}/api/auth/linux-do/callback`); + }, []); + + useEffect(() => { + if (!linuxDoCallbackUrl) return; + if (linuxDoRedirectUri) return; + form.setFieldValue(["private", "auth", "linuxDo", "redirectUri"], linuxDoCallbackUrl); + }, [form, linuxDoCallbackUrl, linuxDoRedirectUri]); + const changeTab = (nextTab: SettingsTabKey) => { setActiveTab(nextTab); }; @@ -365,6 +378,46 @@ export default function AdminSettingsPage() {
+ + + Linux.do 登录 + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -600,6 +653,11 @@ function normalizePublicSetting(setting: Partial = {}): ...(setting.modelChannel || {}), availableModels: setting.modelChannel?.availableModels || [], }, + auth: { + linuxDo: { + enabled: setting.auth?.linuxDo?.enabled === true, + }, + }, }; } @@ -610,6 +668,14 @@ function normalizePrivateSetting(setting: Partial = {} enabled: setting.promptSync?.enabled !== false, cron: setting.promptSync?.cron || "*/5 * * * *", }, + auth: { + linuxDo: { + clientId: setting.auth?.linuxDo?.clientId || "", + clientSecret: setting.auth?.linuxDo?.clientSecret || "", + redirectUri: setting.auth?.linuxDo?.redirectUri || "", + minimumTrustLevel: Math.max(0, Number(setting.auth?.linuxDo?.minimumTrustLevel) || 0), + }, + }, }; } diff --git a/web/src/app/(admin)/admin/users/page.tsx b/web/src/app/(admin)/admin/users/page.tsx new file mode 100644 index 0000000..a62ee62 --- /dev/null +++ b/web/src/app/(admin)/admin/users/page.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; +import { ProTable, type ProColumns } from "@ant-design/pro-components"; +import { Avatar, Button, Card, Col, Flex, Form, Input, InputNumber, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd"; +import { useEffect, useState } from "react"; + +import type { AdminUser } from "@/services/api/admin"; +import { useAdminUsers } from "./use-admin-users"; + +type UserFormValues = Partial & { password?: string }; + +const roleOptions = [ + { label: "普通用户", value: "user" }, + { label: "管理员", value: "admin" }, +]; + +const statusOptions = [ + { label: "正常", value: "active" }, + { label: "禁用", value: "ban" }, +]; + +export default function AdminUsersPage() { + const { users, keyword, page, pageSize, total, isLoading, searchUsers, changePage, changePageSize, resetFilters, refreshUsers, saveUser: saveAdminUser, deleteUser } = useAdminUsers(); + const [form] = Form.useForm(); + const [keywordText, setKeywordText] = useState(keyword); + const [editingUser, setEditingUser] = useState | null>(null); + const [deletingUser, setDeletingUser] = useState(null); + + useEffect(() => setKeywordText(keyword), [keyword]); + + useEffect(() => { + if (editingUser) form.setFieldsValue({ role: "user", status: "active", credits: 0, ...editingUser, password: "" }); + }, [editingUser, form]); + + const saveUser = async () => { + const value = await form.validateFields(); + await saveAdminUser({ ...editingUser, ...value, password: value.password || undefined }); + setEditingUser(null); + }; + + const columns: ProColumns[] = [ + { + title: "用户", + dataIndex: "username", + width: 260, + render: (_, item) => ( + + {(item.displayName || item.username || "U").slice(0, 1).toUpperCase()} + + + {item.displayName || item.username} + + + {item.username} + + + + ), + }, + { + title: "角色", + dataIndex: "role", + width: 100, + render: (_, item) => {item.role === "admin" ? "管理员" : "用户"}, + }, + { + title: "状态", + dataIndex: "status", + width: 90, + render: (_, item) => {item.status === "ban" ? "禁用" : "正常"}, + }, + { + title: "算力点", + dataIndex: "credits", + width: 100, + render: (_, item) => {item.credits}, + }, + { + title: "Linux.do", + dataIndex: "linuxDoId", + width: 140, + render: (_, item) => {item.linuxDoId || "-"}, + }, + { + title: "最近登录", + dataIndex: "lastLoginAt", + width: 180, + render: (_, item) => {item.lastLoginAt || "-"}, + }, + { + title: "操作", + key: "actions", + width: 96, + align: "right", + render: (_, item) => ( + + + + + + + + + + + + rowKey="id" + columns={columns} + dataSource={users} + loading={isLoading} + search={false} + defaultSize="middle" + tableLayout="fixed" + cardProps={{ variant: "borderless" }} + headerTitle={ + + 用户列表 + {total} 人 + + } + options={{ density: true, setting: true, reload: () => void refreshUsers() }} + toolBarRender={() => [ + , + ]} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (value) => `共 ${value} 人`, + onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)), + }} + /> +
+ + setEditingUser(null)} onOk={() => void saveUser()} okText="保存" cancelText="取消" destroyOnHidden> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + setDeletingUser(null)} + onOk={async () => { + if (!deletingUser) return; + await deleteUser(deletingUser.id); + setDeletingUser(null); + }} + okText="删除" + okButtonProps={{ danger: true }} + cancelText="取消" + > + 确定删除「{deletingUser?.displayName || deletingUser?.username}」吗?删除后该账号将无法继续登录。 + + + ); +} diff --git a/web/src/app/(admin)/admin/users/use-admin-users.ts b/web/src/app/(admin)/admin/users/use-admin-users.ts new file mode 100644 index 0000000..ce4a746 --- /dev/null +++ b/web/src/app/(admin)/admin/users/use-admin-users.ts @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { App } from "antd"; + +import { deleteAdminUser, fetchAdminUsers, saveAdminUser, type AdminUser } from "@/services/api/admin"; +import { useUserStore } from "@/stores/use-user-store"; + +const defaultPageSize = 10; + +export function useAdminUsers() { + const { message } = App.useApp(); + const queryClient = useQueryClient(); + const token = useUserStore((state) => state.token); + const clearSession = useUserStore((state) => state.clearSession); + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(defaultPageSize); + + const query = useQuery({ + queryKey: ["admin", "users", token, keyword, page, pageSize], + queryFn: () => fetchAdminUsers(token, { keyword, page, pageSize }), + enabled: Boolean(token), + retry: false, + }); + + const saveMutation = useMutation({ + mutationFn: (user: Partial & { password?: string }) => saveAdminUser(token, user), + onSuccess: async (_, user) => { + await queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); + message.success(user.id ? "用户已保存" : "用户已新增"); + }, + onError: (error) => message.error(error instanceof Error ? error.message : "保存失败"), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteAdminUser(token, id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); + message.success("用户已删除"); + }, + onError: (error) => message.error(error instanceof Error ? error.message : "删除失败"), + }); + + useEffect(() => { + if (query.isError) { + const errorMessage = query.error instanceof Error ? query.error.message : "读取用户失败"; + message.error(errorMessage); + if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession(); + } + }, [clearSession, message, query.error, query.isError]); + + const updateFilters = (next: Partial<{ keyword: string; page: number; pageSize: number }>) => { + const queryState = { keyword, page, pageSize, ...next }; + if (next.keyword !== undefined || next.pageSize !== undefined) queryState.page = 1; + setKeyword(queryState.keyword); + setPage(queryState.page); + setPageSize(queryState.pageSize); + }; + + const data = query.data; + + return { + users: data?.items || [], + keyword, + page, + pageSize, + total: data?.total || 0, + isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending, + searchUsers: (value = keyword) => updateFilters({ keyword: value }), + changePage: (value: number) => updateFilters({ page: value }), + changePageSize: (value: number) => updateFilters({ pageSize: value }), + resetFilters: () => updateFilters({ keyword: "", page: 1, pageSize: defaultPageSize }), + refreshUsers: () => query.refetch(), + saveUser: (user: Partial & { password?: string }) => saveMutation.mutateAsync(user), + deleteUser: (id: string) => deleteMutation.mutateAsync(id), + }; +} diff --git a/web/src/app/(user)/login/page.tsx b/web/src/app/(user)/login/page.tsx index 1897b29..9ca4cdf 100644 --- a/web/src/app/(user)/login/page.tsx +++ b/web/src/app/(user)/login/page.tsx @@ -1,10 +1,12 @@ "use client"; import { LockOutlined, UserOutlined } from "@ant-design/icons"; -import { App, Button, Form, Input } from "antd"; +import { App, Button, Form, Input, Segmented, Space } from "antd"; import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense } from "react"; +import { Suspense, useEffect, useState } from "react"; +import { fetchCurrentUser } from "@/services/api/auth"; +import { useConfigStore } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; type LoginFormValues = { @@ -26,13 +28,35 @@ function LoginContent() { const router = useRouter(); const searchParams = useSearchParams(); const login = useUserStore((state) => state.login); + const register = useUserStore((state) => state.register); + const setSession = useUserStore((state) => state.setSession); const isLoading = useUserStore((state) => state.isLoading); + const linuxDoEnabled = useConfigStore((state) => state.publicSettings?.auth?.linuxDo?.enabled === true); + const [mode, setMode] = useState<"login" | "register">("login"); const redirect = searchParams.get("redirect") || "/"; + useEffect(() => { + const token = searchParams.get("token"); + const error = searchParams.get("error"); + if (error) message.error(error); + if (!token) return; + void fetchCurrentUser(token).then((user) => { + setSession(token, user); + message.success("登录成功"); + router.replace(redirect.startsWith("/") ? redirect : "/"); + router.refresh(); + }); + }, [message, redirect, router, searchParams, setSession]); + const submit = async (values: LoginFormValues) => { try { - const user = await login({ username: values.username, password: values.password }); - message.success("登录成功"); + if (mode === "register" && values.password !== values.confirmPassword) { + message.error("两次输入的密码不一致"); + return; + } + const action = mode === "register" ? register : login; + const user = await action({ username: values.username, password: values.password }); + message.success(mode === "register" ? "注册成功" : "登录成功"); router.replace(redirect.startsWith("/") ? redirect : "/"); router.refresh(); if (user.role !== "admin") router.replace("/"); @@ -53,20 +77,43 @@ function LoginContent() { }} aria-label="无限画布" /> -

管理员登录

-

当前暂时关闭注册,仅允许管理员账号登录。

+

账号登录

+

支持账号密码和 Linux.do 登录。

layout="vertical" size="large" requiredMark={false} onFinish={submit}> + + setMode(value as "login" | "register")} + options={[ + { label: "登录", value: "login" }, + { label: "注册", value: "register" }, + ]} + /> + 用户名} rules={[{ required: true, message: "请输入用户名" }]}> } autoComplete="username" /> 密码} rules={[{ required: true, message: "请输入密码" }]}> } autoComplete="current-password" /> - + {mode === "register" ? ( + 确认密码} rules={[{ required: true, message: "请再次输入密码" }]}> + } autoComplete="new-password" /> + + ) : null} + + + {linuxDoEnabled ? ( + + ) : null} + diff --git a/web/src/app/api/[...path]/route.ts b/web/src/app/api/[...path]/route.ts index 398313d..f932727 100644 --- a/web/src/app/api/[...path]/route.ts +++ b/web/src/app/api/[...path]/route.ts @@ -35,6 +35,7 @@ async function proxy(request: NextRequest, context: RouteContext) { headers: proxyHeaders(request), body: hasBody ? request.body : undefined, duplex: hasBody ? "half" : undefined, + redirect: "manual", } as RequestInit & { duplex?: "half" }); return new Response(response.body, { diff --git a/web/src/components/layout/user-status-actions.tsx b/web/src/components/layout/user-status-actions.tsx index e7a7d0f..d48b867 100644 --- a/web/src/components/layout/user-status-actions.tsx +++ b/web/src/components/layout/user-status-actions.tsx @@ -1,7 +1,7 @@ "use client"; import type { CSSProperties, RefObject } from "react"; -import { Dropdown } from "antd"; +import { Avatar, Dropdown } from "antd"; import { Keyboard, LogOut, Settings2, Shield } from "lucide-react"; import type { ItemType } from "antd/es/menu/interface"; import Link from "next/link"; @@ -32,14 +32,15 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp const logout = useUserStore((state) => state.clearSession); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const canvasTheme = canvasThemes[theme]; - const userName = user?.username || "用户"; + const userName = user?.displayName || user?.username || "用户"; + const avatarUrl = user?.avatarUrl?.trim(); const avatarText = (userName.trim()[0] || "U").toUpperCase(); const naturalIconClass = "inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"; const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined; const versionStyle = iconStyle; const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined; const gitHubStyle = iconStyle; - const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined; + const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text, background: "transparent" } : undefined; const menuItems: ItemType[] = [ { key: "user", disabled: true, label: {userName} }, ...(user?.role === "admin" ? [{ key: "admin", icon: , label: 管理后台 }] : []), @@ -60,13 +61,16 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
-
diff --git a/web/src/services/api/admin.ts b/web/src/services/api/admin.ts index 40b9974..1d5f5ed 100644 --- a/web/src/services/api/admin.ts +++ b/web/src/services/api/admin.ts @@ -10,6 +10,47 @@ export type AdminPromptCategory = { remote: boolean; }; +export type AdminUser = { + id: string; + username: string; + email: string; + displayName: string; + avatarUrl: string; + role: "user" | "admin"; + credits: number; + affCode: string; + affCount: number; + inviterId: string; + linuxDoId: string; + status: "active" | "ban"; + lastLoginAt: string; + createdAt: string; + updatedAt: string; +}; + +export type AdminUserListResponse = { + items: AdminUser[]; + total: number; +}; + +export type AdminUserQuery = { + keyword?: string; + page?: number; + pageSize?: number; +}; + +export async function fetchAdminUsers(token: string, query: AdminUserQuery = {}) { + return apiGet("/api/admin/users", compactApiParams(query), token); +} + +export async function saveAdminUser(token: string, user: Partial & { password?: string }) { + return apiPost("/api/admin/users", user, token); +} + +export async function deleteAdminUser(token: string, id: string) { + return apiDelete(`/api/admin/users/${encodeURIComponent(id)}`, token); +} + export async function fetchAdminPromptCategories(token: string) { return apiGet("/api/admin/prompt-categories", undefined, token); } @@ -105,6 +146,11 @@ export type AdminPublicModelChannelSettings = { export type AdminPublicSettings = { modelChannel: AdminPublicModelChannelSettings; + auth: { + linuxDo: { + enabled: boolean; + }; + }; }; export type AdminPrivateSettings = { @@ -113,6 +159,14 @@ export type AdminPrivateSettings = { enabled: boolean; cron: string; }; + auth: { + linuxDo: { + clientId: string; + clientSecret: string; + redirectUri: string; + minimumTrustLevel: number; + }; + }; }; export type AdminSettings = { diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index afe1dee..35b5685 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -7,7 +7,10 @@ export type UserRole = "guest" | "user" | "admin"; export type AuthUser = { id: string; username: string; + displayName: string; + avatarUrl: string; role: UserRole; + credits: number; createdAt: string; updatedAt: string; }; @@ -23,7 +26,7 @@ export type AuthPayload = { }; export async function login(payload: AuthPayload) { - return apiPost("/api/admin/login", payload); + return apiPost("/api/auth/login", payload); } export async function register(payload: AuthPayload) {