feat(admin): 添加用户管理和Linux.do登录功能
- 新增AdminUser类型定义及用户管理API接口 - 实现用户列表查询、保存和删除功能 - 添加Linux.do第三方登录支持 - 集成Linux.do OAuth认证流程 - 在系统设置中添加Linux.do登录配置选项 - 实现算力点余额调整记录功能 - 优化搜索组件关键词状态管理 - 更新数据库迁移添加credit_logs表 - 完善用户状态和信用等级验证逻辑
This commit is contained in:
+10
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。
|
||||
|
||||
+2
-36
@@ -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`。
|
||||
+50
-10
@@ -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)
|
||||
|
||||
+23
-2
@@ -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"`
|
||||
|
||||
+61
-16
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func DB() (*gorm.DB, error) {
|
||||
}
|
||||
dbErr = db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.CreditLog{},
|
||||
&model.Prompt{},
|
||||
&model.Asset{},
|
||||
&model.Setting{},
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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))
|
||||
|
||||
+248
-9
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg class="icon" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="color: rgb(233, 84, 32); width: 20px; height: 20px;">
|
||||
<g id="linuxdo_icon" data-name="linuxdo_icon">
|
||||
<path
|
||||
d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z"
|
||||
fill="#EFEFEF"></path>
|
||||
<path d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z" fill="#FEB005"></path>
|
||||
<path d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z" fill="#1D1D1F"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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<AssetFormValues>();
|
||||
const [keywordText, setKeywordText] = useState(keyword);
|
||||
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
|
||||
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
|
||||
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(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() {
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px">
|
||||
<Form.Item label="关键词">
|
||||
<Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} />
|
||||
<Input.Search value={keywordText} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={() => searchAssets(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="180px">
|
||||
@@ -135,8 +138,15 @@ export default function AdminAssetsPage() {
|
||||
<Col flex="none">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeywordText("");
|
||||
resetFilters();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchAssets(keywordText)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -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: <UserOutlined />, label: "用户管理" },
|
||||
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
|
||||
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
|
||||
{ key: "/admin/settings", icon: <SettingOutlined />, 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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/assets");
|
||||
redirect("/admin/users");
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function AdminPromptsPage() {
|
||||
} = useAdminPrompts();
|
||||
const copyText = useCopyText();
|
||||
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
|
||||
const [keywordText, setKeywordText] = useState(keyword);
|
||||
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
|
||||
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
|
||||
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(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() {
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px">
|
||||
<Form.Item label="关键词">
|
||||
<Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} />
|
||||
<Input.Search value={keywordText} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={() => searchPrompts(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="220px">
|
||||
@@ -151,8 +154,15 @@ export default function AdminPromptsPage() {
|
||||
<Col flex="none">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeywordText("");
|
||||
resetFilters();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchPrompts(keywordText)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -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<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
|
||||
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() {
|
||||
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||
<Flex vertical gap={12}>
|
||||
<Alert showIcon type="warning" title="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。" />
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<img src="/icons/linuxdo.svg" alt="" width={18} height={18} />
|
||||
Linux.do 登录
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Flex vertical gap={14}>
|
||||
<Alert showIcon type="info" message={linuxDoCallbackUrl ? `回调 URL 填 ${linuxDoCallbackUrl}` : "回调 URL 使用当前站点的 /api/auth/linux-do/callback"} />
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "auth", "linuxDo", "enabled"]} label="开启 Linux.do 登录" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={9}>
|
||||
<Form.Item name={["private", "auth", "linuxDo", "clientId"]} label="Linux.do Client ID">
|
||||
<Input placeholder="输入 Linux.do OAuth App 的 ID" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={9}>
|
||||
<Form.Item name={["private", "auth", "linuxDo", "clientSecret"]} label="Linux.do Client Secret">
|
||||
<Input.Password placeholder="留空则沿用已保存的密钥" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={18}>
|
||||
<Form.Item name={["private", "auth", "linuxDo", "redirectUri"]} label="回调 URL">
|
||||
<Input placeholder={linuxDoCallbackUrl || "https://your-domain.com/api/auth/linux-do/callback"} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["private", "auth", "linuxDo", "minimumTrustLevel"]} label="最低信任等级">
|
||||
<InputNumber min={0} max={4} precision={0} className="!w-full" placeholder="0" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Flex>
|
||||
</Card>
|
||||
<Card size="small" title="提示词定时同步">
|
||||
<Row gutter={16} align="middle">
|
||||
<Col xs={24} md={8}>
|
||||
@@ -600,6 +653,11 @@ function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}):
|
||||
...(setting.modelChannel || {}),
|
||||
availableModels: setting.modelChannel?.availableModels || [],
|
||||
},
|
||||
auth: {
|
||||
linuxDo: {
|
||||
enabled: setting.auth?.linuxDo?.enabled === true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -610,6 +668,14 @@ function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}
|
||||
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),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AdminUser> & { 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<UserFormValues>();
|
||||
const [keywordText, setKeywordText] = useState(keyword);
|
||||
const [editingUser, setEditingUser] = useState<Partial<AdminUser> | null>(null);
|
||||
const [deletingUser, setDeletingUser] = useState<AdminUser | null>(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<AdminUser>[] = [
|
||||
{
|
||||
title: "用户",
|
||||
dataIndex: "username",
|
||||
width: 260,
|
||||
render: (_, item) => (
|
||||
<Flex align="center" gap={10} style={{ minWidth: 0 }}>
|
||||
<Avatar src={item.avatarUrl}>{(item.displayName || item.username || "U").slice(0, 1).toUpperCase()}</Avatar>
|
||||
<Flex vertical style={{ minWidth: 0 }}>
|
||||
<Typography.Text strong ellipsis>
|
||||
{item.displayName || item.username}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
{item.username}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "角色",
|
||||
dataIndex: "role",
|
||||
width: 100,
|
||||
render: (_, item) => <Tag color={item.role === "admin" ? "gold" : "default"}>{item.role === "admin" ? "管理员" : "用户"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 90,
|
||||
render: (_, item) => <Tag color={item.status === "ban" ? "red" : "green"}>{item.status === "ban" ? "禁用" : "正常"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "算力点",
|
||||
dataIndex: "credits",
|
||||
width: 100,
|
||||
render: (_, item) => <Typography.Text>{item.credits}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "Linux.do",
|
||||
dataIndex: "linuxDoId",
|
||||
width: 140,
|
||||
render: (_, item) => <Typography.Text type="secondary">{item.linuxDoId || "-"}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "最近登录",
|
||||
dataIndex: "lastLoginAt",
|
||||
width: 180,
|
||||
render: (_, item) => <Typography.Text type="secondary">{item.lastLoginAt || "-"}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 96,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingUser(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingUser(item)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ padding: 24 }}>
|
||||
<Flex vertical gap={16}>
|
||||
<Card variant="borderless">
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px">
|
||||
<Form.Item label="关键词">
|
||||
<Input.Search
|
||||
value={keywordText}
|
||||
placeholder="搜索用户名、昵称、邮箱或 Linux.do ID"
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
onSearch={() => searchUsers(keywordText)}
|
||||
onChange={(event) => setKeywordText(event.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeywordText("");
|
||||
resetFilters();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchUsers(keywordText)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
<ProTable<AdminUser>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
defaultSize="middle"
|
||||
tableLayout="fixed"
|
||||
cardProps={{ variant: "borderless" }}
|
||||
headerTitle={
|
||||
<Space>
|
||||
<Typography.Text strong>用户列表</Typography.Text>
|
||||
<Tag>{total} 人</Tag>
|
||||
</Space>
|
||||
}
|
||||
options={{ density: true, setting: true, reload: () => void refreshUsers() }}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingUser({ role: "user", status: "active", credits: 0 })}>
|
||||
新增
|
||||
</Button>,
|
||||
]}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (value) => `共 ${value} 人`,
|
||||
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Modal title={editingUser?.id ? "编辑用户" : "新增用户"} open={Boolean(editingUser)} width={680} onCancel={() => setEditingUser(null)} onOk={() => void saveUser()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Row gutter={14}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: "请输入用户名" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="password" label={editingUser?.id ? "新密码" : "密码"} rules={editingUser?.id ? [] : [{ required: true, message: "请输入密码" }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="displayName" label="昵称">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true, message: "请选择角色" }]}>
|
||||
<Select options={roleOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select options={statusOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="credits" label="算力点">
|
||||
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="avatarUrl" label="头像 URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="删除用户"
|
||||
open={Boolean(deletingUser)}
|
||||
onCancel={() => setDeletingUser(null)}
|
||||
onOk={async () => {
|
||||
if (!deletingUser) return;
|
||||
await deleteUser(deletingUser.id);
|
||||
setDeletingUser(null);
|
||||
}}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定删除「{deletingUser?.displayName || deletingUser?.username}」吗?删除后该账号将无法继续登录。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<AdminUser> & { 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<AdminUser> & { password?: string }) => saveMutation.mutateAsync(user),
|
||||
deleteUser: (id: string) => deleteMutation.mutateAsync(id),
|
||||
};
|
||||
}
|
||||
@@ -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="无限画布"
|
||||
/>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100">管理员登录</h1>
|
||||
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">当前暂时关闭注册,仅允许管理员账号登录。</p>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100">账号登录</h1>
|
||||
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">支持账号密码和 Linux.do 登录。</p>
|
||||
</div>
|
||||
|
||||
<Form<LoginFormValues> layout="vertical" size="large" requiredMark={false} onFinish={submit}>
|
||||
<Form.Item>
|
||||
<Segmented
|
||||
block
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as "login" | "register")}
|
||||
options={[
|
||||
{ label: "登录", value: "login" },
|
||||
{ label: "注册", value: "register" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="username" label={<span className="font-medium text-stone-800 dark:text-stone-200">用户名</span>} rules={[{ required: true, message: "请输入用户名" }]}>
|
||||
<Input prefix={<UserOutlined />} autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label={<span className="font-medium text-stone-800 dark:text-stone-200">密码</span>} rules={[{ required: true, message: "请输入密码" }]}>
|
||||
<Input.Password prefix={<LockOutlined />} autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Button block type="primary" htmlType="submit" loading={isLoading}>
|
||||
登录
|
||||
</Button>
|
||||
{mode === "register" ? (
|
||||
<Form.Item name="confirmPassword" label={<span className="font-medium text-stone-800 dark:text-stone-200">确认密码</span>} rules={[{ required: true, message: "请再次输入密码" }]}>
|
||||
<Input.Password prefix={<LockOutlined />} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
<Button block type="primary" htmlType="submit" loading={isLoading}>
|
||||
{mode === "register" ? "注册" : "登录"}
|
||||
</Button>
|
||||
{linuxDoEnabled ? (
|
||||
<Button block href={`/api/auth/linux-do/authorize?redirect=${encodeURIComponent(redirect)}`} icon={<img src="/icons/linuxdo.svg" alt="" width={18} height={18} />}>
|
||||
使用 Linux.do 登录
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: <span className="font-medium text-current">{userName}</span> },
|
||||
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin">管理后台</Link> }] : []),
|
||||
@@ -60,13 +61,16 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
|
||||
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
|
||||
<div ref={accountRef}>
|
||||
<Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-stone-300 bg-transparent p-0 text-xs font-semibold leading-none text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
|
||||
style={avatarStyle}
|
||||
aria-label="账户菜单"
|
||||
>
|
||||
<span className="leading-none">{avatarText}</span>
|
||||
<button type="button" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-transparent p-0 text-[0] leading-[0] transition" aria-label="账户菜单">
|
||||
<Avatar
|
||||
size={28}
|
||||
src={avatarUrl ? <img src={avatarUrl} alt={userName} referrerPolicy="no-referrer" /> : undefined}
|
||||
alt={userName}
|
||||
className="!flex !items-center !justify-center border border-stone-300 bg-transparent text-xs font-semibold text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
|
||||
style={avatarStyle}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -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<AdminUserListResponse>("/api/admin/users", compactApiParams(query), token);
|
||||
}
|
||||
|
||||
export async function saveAdminUser(token: string, user: Partial<AdminUser> & { password?: string }) {
|
||||
return apiPost<AdminUser>("/api/admin/users", user, token);
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(token: string, id: string) {
|
||||
return apiDelete<boolean>(`/api/admin/users/${encodeURIComponent(id)}`, token);
|
||||
}
|
||||
|
||||
export async function fetchAdminPromptCategories(token: string) {
|
||||
return apiGet<AdminPromptCategory[]>("/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 = {
|
||||
|
||||
@@ -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<AuthSession>("/api/admin/login", payload);
|
||||
return apiPost<AuthSession>("/api/auth/login", payload);
|
||||
}
|
||||
|
||||
export async function register(payload: AuthPayload) {
|
||||
|
||||
Reference in New Issue
Block a user