feat: announcement system upgrade + user management
ci/woodpecker/tag/build Pipeline failed
ci/woodpecker/push/build Pipeline was successful

- Add Source/TargetUserID fields to announcements, support individual push
- Add status (active/disabled) and DisabledReason to SysUser
- Add admin user management API (list, detail, enable/disable)
- Add login disabled check with reason prompt
- Add system-generated invitation notifications
- Allow invite reward amount configurable via system settings
- Frontend: AdminUsers page, update AdminAnnouncements with target user support
This commit is contained in:
becomer
2026-07-05 15:23:42 +08:00
parent 9036f1fcc6
commit 63b002d074
20 changed files with 1311 additions and 147 deletions
+6 -1
View File
@@ -2,12 +2,17 @@
backed/server
backed/cpdaily-new
backed/cpdaily-server
backed/cmd/server/cpdaily-new
backed/*.log
backed/*.har
# Frontend build (rebuilt by Docker multi-stage)
backed/dist/
frondend/dist/
# Node
frondend/node_modules/
frondend/dist/
frondend/*.tsbuildinfo
# IDE
.idea/
+5
View File
@@ -96,6 +96,11 @@ func main() {
adminAuth.Get("/all-agents", handler.ListAllAgents)
adminAuth.Get("/agent/list", handler.ListAllAgents)
// 管理后台 - 用户管理(仅admin)
adminAuth.Get("/users", middleware.AdminOnly(), handler.ListUsers)
adminAuth.Get("/users/:id", middleware.AdminOnly(), handler.GetUserDetail)
adminAuth.Put("/users/:id/status", middleware.AdminOnly(), handler.UpdateUserStatus)
// 管理后台 - 公告
adminAuth.Post("/announcements", handler.CreateAnnouncement)
adminAuth.Get("/announcements", handler.ListAnnouncements)
+54 -6
View File
@@ -17,6 +17,7 @@ type CreateAnnouncementRequest struct {
Title string `json:"title"`
Content string `json:"content"`
TargetType string `json:"targetType"` // agent / user
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
ForcePopup bool `json:"forcePopup"`
LoopPopup bool `json:"loopPopup"`
}
@@ -39,10 +40,27 @@ func CreateAnnouncement(c *fiber.Ctx) error {
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
}
// 如果指定了用户,验证用户存在
if req.TargetUserID != nil {
if req.TargetType == "user" {
var user model.SysUser
if err := database.DB.First(&user, *req.TargetUserID).Error; err != nil {
return utils.Fail(c, 404, "指定的用户不存在")
}
} else {
var agent model.AgentUser
if err := database.DB.First(&agent, *req.TargetUserID).Error; err != nil {
return utils.Fail(c, 404, "指定的代理不存在")
}
}
}
announcement := model.Announcement{
Title: req.Title,
Content: req.Content,
Source: "manual",
TargetType: req.TargetType,
TargetUserID: req.TargetUserID,
ForcePopup: req.ForcePopup,
LoopPopup: req.LoopPopup,
CreatedBy: adminID,
@@ -53,16 +71,17 @@ func CreateAnnouncement(c *fiber.Ctx) error {
return utils.FailServer(c, "创建公告失败")
}
slog.Info("创建公告", "id", announcement.ID, "title", req.Title, "targetType", req.TargetType)
slog.Info("创建公告", "id", announcement.ID, "title", req.Title, "targetType", req.TargetType, "targetUserId", req.TargetUserID)
return utils.Success(c, announcement)
}
// AnnouncementListItem 公告列表项(含统计)
// AnnouncementListItem 公告列表项(含统计和来源信息)
type AnnouncementListItem struct {
model.Announcement
ReadCount int `json:"readCount"`
UnreadCount int `json:"unreadCount"`
TotalTargets int64 `json:"totalTargets"`
TargetUserName string `json:"targetUserName,omitempty"` // 指定用户时的用户名
}
// ListAnnouncements 公告列表(含已读/未读统计)
@@ -84,17 +103,37 @@ func ListAnnouncements(c *fiber.Ctx) error {
Count(&readCount)
var totalTargets int64
if a.TargetType == "agent" {
if a.TargetUserID != nil {
// 指定单个用户
totalTargets = 1
} else if a.TargetType == "agent" {
totalTargets = totalAgents
} else {
totalTargets = totalUsers
}
// 查询指定用户的用户名
targetUserName := ""
if a.TargetUserID != nil {
if a.TargetType == "user" {
var u model.SysUser
if err := database.DB.Select("username").First(&u, *a.TargetUserID).Error; err == nil {
targetUserName = u.Username
}
} else {
var ag model.AgentUser
if err := database.DB.Select("username").First(&ag, *a.TargetUserID).Error; err == nil {
targetUserName = ag.Username
}
}
}
items = append(items, AnnouncementListItem{
Announcement: a,
ReadCount: int(readCount),
UnreadCount: int(max(0, totalTargets-readCount)),
TotalTargets: totalTargets,
TargetUserName: targetUserName,
})
}
@@ -154,13 +193,22 @@ func GetAnnouncement(c *fiber.Ctx) error {
func DeleteAnnouncement(c *fiber.Ctx) error {
id := c.Params("id")
// 只能删除手动创建的公告
var announcement model.Announcement
if err := database.DB.First(&announcement, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "公告不存在")
}
return utils.FailServer(c, "查询失败")
}
if announcement.Source == "system" {
return utils.Fail(c, 400, "系统自动生成的公告不能手动删除")
}
result := database.DB.Delete(&model.Announcement{}, id)
if result.Error != nil {
return utils.FailServer(c, "删除失败")
}
if result.RowsAffected == 0 {
return utils.Fail(c, 404, "公告不存在")
}
// 同时删除已读记录
database.DB.Where("announcement_id = ?", id).Delete(&model.AnnouncementRead{})
+215
View File
@@ -0,0 +1,215 @@
package handler
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/utils"
"log/slog"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
// SysUserListItem 用户列表项
type SysUserListItem struct {
ID uint `json:"id"`
Username string `json:"username"`
SchoolID string `json:"schoolId"`
SchoolName string `json:"schoolName"`
StudentID string `json:"studentId"`
RealName string `json:"realName"`
SignCount int `json:"signCount"`
InviteReward float64 `json:"inviteReward"`
InvitedCount int `json:"invitedCount"`
TaskCount int64 `json:"taskCount"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
LastLoginAt string `json:"lastLoginAt"`
}
// ListUsers 获取用户列表(仅admin)
func ListUsers(c *fiber.Ctx) error {
// 分页
page, _ := strconv.Atoi(c.Query("page", "1"))
pageSize, _ := strconv.Atoi(c.Query("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
// 搜索
keyword := strings.TrimSpace(c.Query("keyword", ""))
var total int64
query := database.DB.Model(&model.SysUser{})
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("username LIKE ? OR school_name LIKE ? OR student_id LIKE ? OR real_name LIKE ?",
like, like, like, like)
}
query.Count(&total)
// 查询用户列表
var users []model.SysUser
dataQuery := database.DB
if keyword != "" {
like := "%" + keyword + "%"
dataQuery = dataQuery.Where("username LIKE ? OR school_name LIKE ? OR student_id LIKE ? OR real_name LIKE ?",
like, like, like, like)
}
dataQuery.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&users)
// 统计每个用户的任务数
type TaskCount struct {
UserID uint
Count int64
}
var taskCounts []TaskCount
if len(users) > 0 {
userIDs := make([]uint, len(users))
for i, u := range users {
userIDs[i] = u.ID
}
database.DB.Model(&model.CpTask{}).
Select("user_id, COUNT(*) as count").
Where("user_id IN ?", userIDs).
Group("user_id").
Scan(&taskCounts)
}
taskCountMap := make(map[uint]int64)
for _, tc := range taskCounts {
taskCountMap[tc.UserID] = tc.Count
}
items := make([]SysUserListItem, 0, len(users))
for _, u := range users {
lastLogin := ""
if u.LastLoginAt != nil {
lastLogin = u.LastLoginAt.Format("2006-01-02 15:04:05")
}
items = append(items, SysUserListItem{
ID: u.ID,
Username: u.Username,
SchoolID: u.SchoolID,
SchoolName: u.SchoolName,
StudentID: u.StudentID,
RealName: u.RealName,
SignCount: u.SignCount,
InviteReward: u.InviteReward,
InvitedCount: u.InvitedCount,
TaskCount: taskCountMap[u.ID],
Status: u.Status,
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
LastLoginAt: lastLogin,
})
}
return utils.Success(c, fiber.Map{
"total": total,
"items": items,
"page": page,
"pageSize": pageSize,
})
}
// GetUserDetail 获取用户详情(仅admin)
func GetUserDetail(c *fiber.Ctx) error {
userID := c.Params("id")
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.Fail(c, 404, "用户不存在")
}
// 统计任务数
var taskCount int64
database.DB.Model(&model.CpTask{}).Where("user_id = ?", user.ID).Count(&taskCount)
// 统计被邀请人(通过邀请码查找)
var invitees []model.SysUser
database.DB.Where("invite_code = ?", user.InviteCode).Find(&invitees) // 查找使用该用户邀请码注册的人,不对,应该查找谁是被这个用户邀请的
// 实际上我们无法直接知道谁是被这个用户邀请的,因为没有记录"被谁邀请"的字段
// 可以通过统计该用户的 invited_count 来知道邀请人数
lastLogin := ""
if user.LastLoginAt != nil {
lastLogin = user.LastLoginAt.Format("2006-01-02 15:04:05")
}
// 解密密码
password := "[已加密]"
if user.RawPassword != "" {
decrypted, err := decryptRawPassword(user.RawPassword)
if err == nil && decrypted != "" {
password = decrypted
}
}
return utils.Success(c, fiber.Map{
"id": user.ID,
"username": user.Username,
"schoolId": user.SchoolID,
"schoolName": user.SchoolName,
"studentId": user.StudentID,
"realName": user.RealName,
"password": password,
"signCount": user.SignCount,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"status": user.Status,
"disabledReason": user.DisabledReason,
"taskCount": taskCount,
"mobileCode": user.MobileCode,
"mobileType": user.MobileType,
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
"lastLoginAt": lastLogin,
})
}
// UpdateUserStatusRequest 更新用户状态请求
type UpdateUserStatusRequest struct {
Status string `json:"status"` // active / disabled
DisabledReason string `json:"disabledReason"` // 禁用原因
}
// UpdateUserStatus 启用/禁用用户(仅admin)
func UpdateUserStatus(c *fiber.Ctx) error {
userID := c.Params("id")
var req UpdateUserStatusRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
if req.Status != "active" && req.Status != "disabled" {
return utils.Fail(c, 400, "状态值无效,必须为 active 或 disabled")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.Fail(c, 404, "用户不存在")
}
updates := map[string]interface{}{
"status": req.Status,
}
if req.Status == "disabled" {
updates["disabled_reason"] = req.DisabledReason
} else {
updates["disabled_reason"] = ""
}
if err := database.DB.Model(&user).Updates(updates).Error; err != nil {
slog.Error("更新用户状态失败", "error", err)
return utils.FailServer(c, "操作失败")
}
slog.Info("更新用户状态成功", "userId", user.ID, "status", req.Status)
return utils.Success(c, fiber.Map{
"id": user.ID,
"status": req.Status,
})
}
+140 -15
View File
@@ -8,6 +8,7 @@ import (
"cpdaily-new/internal/utils"
"errors"
"log/slog"
"strconv"
"strings"
"time"
@@ -122,6 +123,19 @@ func Login(c *fiber.Ctx) error {
return utils.FailServer(c, "用户处理失败")
}
// 检查用户是否被管理员禁用
if user.Status == "disabled" {
reason := user.DisabledReason
if reason == "" {
reason = "您的账号已被管理员禁用,如有疑问请联系客服"
}
slog.Warn("被禁用的用户尝试登录", "userId", user.ID, "username", user.Username)
return c.Status(403).JSON(utils.Response{
Code: 403,
Message: reason,
})
}
// 更新 cookie 和最后登录时间
now := time.Now()
database.DB.Model(&user).Updates(map[string]interface{}{
@@ -242,8 +256,67 @@ func buildUsername(school *cpd.SchoolDetailInfo, studentID string) string {
return abbr + studentID
}
// getInviterRewardAmount 获取邀请人每次邀请获得的奖励积分(从系统设置读取,默认 0.5)
func getInviterRewardAmount() float64 {
var cfg model.SystemConfig
result := database.DB.Where("`key` = ?", "inviter_reward_per_invite").First(&cfg)
if result.Error == nil {
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
return v
}
}
return 0.5
}
// getInviteeRewardAmount 获取被邀请人每次被邀请获得的奖励积分(从系统设置读取,默认 0.5)
func getInviteeRewardAmount() float64 {
var cfg model.SystemConfig
result := database.DB.Where("`key` = ?", "invitee_reward_per_invite").First(&cfg)
if result.Error == nil {
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
return v
}
}
return 0.5
}
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
func createSystemAnnouncement(userID uint, userType string, title, content string) {
announcement := model.Announcement{
Title: title,
Content: content,
Source: "system",
TargetType: userType,
TargetUserID: &userID,
ForcePopup: true,
LoopPopup: false,
CreatedBy: 0, // 系统创建
}
if err := database.DB.Create(&announcement).Error; err != nil {
slog.Error("创建系统公告失败", "error", err, "userId", userID)
}
}
// createInviteNotifications 创建邀请相关的系统通知(双方各一条强制弹窗公告)
func createInviteNotifications(inviterID, inviteeID uint, inviterReward, inviteeReward float64) {
// 给邀请人发送通知
if inviterReward > 0 {
createSystemAnnouncement(inviterID, "user",
"邀请奖励到账",
"恭喜!您邀请的好友已成功注册,获得 "+strconv.FormatFloat(inviterReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
)
}
// 给被邀请人发送通知
if inviteeReward > 0 {
createSystemAnnouncement(inviteeID, "user",
"新人注册奖励",
"欢迎注册!您通过邀请链接注册,获得 "+strconv.FormatFloat(inviteeReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
)
}
}
// 查找或创建用户
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, inviteCode string) (*model.SysUser, error) {
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, error) {
var user model.SysUser
err := database.DB.Where("username = ?", username).First(&user).Error
if err == nil {
@@ -254,12 +327,25 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
return nil, err
}
// 自动注册
// 自动注册:生成密码哈希和邀请码
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
myInviteCode := generateRandomCode(6)
// 读取可配置的奖励积分
inviterReward := getInviterRewardAmount()
inviteeReward := getInviteeRewardAmount()
// 记录邀请人ID,在事务外创建通知
var inviterID uint
hasValidReferral := false
// 使用事务包裹:用户创建 + 奖励发放,保证原子性
err = database.DB.Transaction(func(tx *gorm.DB) error {
user = model.SysUser{
Username: username,
Password: string(hashedPwd),
@@ -267,26 +353,65 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
SchoolName: schoolName,
StudentID: studentID,
Cookie: cookie,
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
}
if err := database.DB.Create(&user).Error; err != nil {
return nil, err
if err := tx.Create(&user).Error; err != nil {
return err
}
// 处理邀请码奖励
if inviteCode != "" {
if referralCode != "" {
var inviter model.SysUser
if err := database.DB.Where("invite_code = ?", inviteCode).First(&inviter).Error; err == nil {
// 给邀请人 +0.5 奖励 + 增加邀请计数
database.DB.Model(&inviter).Updates(map[string]interface{}{
"invite_reward": gorm.Expr("invite_reward + 0.5"),
"invited_count": gorm.Expr("invited_count + 1"),
})
slog.Info("邀请奖励已发放", "inviterId", inviter.ID, "newUserId", user.ID)
}
if err := tx.Where("invite_code = ?", referralCode).First(&inviter).Error; err != nil {
// 邀请码无效,仅记录日志,不中断注册流程
slog.Warn("邀请码无效,跳过奖励", "referralCode", referralCode, "newUserId", user.ID)
return nil
}
slog.Info("新用户自动注册", "username", username, "school", schoolName, "inviteCode", inviteCode)
// 自邀请防护
if inviter.ID == user.ID {
slog.Warn("检测到自邀请,跳过奖励", "userId", user.ID)
return nil
}
// 给邀请人发放奖励积分 + 增加邀请计数
if inviterReward > 0 {
tx.Model(&inviter).Updates(map[string]interface{}{
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
"invited_count": gorm.Expr("invited_count + 1"),
})
}
// 给被邀请人(新用户)发放奖励积分
if inviteeReward > 0 {
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
}
slog.Info("邀请奖励已发放",
"inviterId", inviter.ID,
"newUserId", user.ID,
"inviterReward", inviterReward,
"inviteeReward", inviteeReward,
)
inviterID = inviter.ID
hasValidReferral = true
}
return nil
})
if err != nil {
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
return nil, err
}
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
if hasValidReferral {
createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward)
}
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
return &user, nil
}
+8
View File
@@ -24,6 +24,12 @@ func GetSettings(c *fiber.Ctx) error {
if _, ok := settings["base_deduction_per_run"]; !ok {
settings["base_deduction_per_run"] = "1.00"
}
if _, ok := settings["inviter_reward_per_invite"]; !ok {
settings["inviter_reward_per_invite"] = "0.50"
}
if _, ok := settings["invitee_reward_per_invite"]; !ok {
settings["invitee_reward_per_invite"] = "0.50"
}
return utils.Success(c, settings)
}
@@ -47,6 +53,8 @@ func UpdateSetting(c *fiber.Ctx) error {
// 支持的 key 白名单
allowedKeys := map[string]bool{
"base_deduction_per_run": true,
"inviter_reward_per_invite": true,
"invitee_reward_per_invite": true,
}
if !allowedKeys[req.Key] {
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
+19 -13
View File
@@ -29,6 +29,7 @@ type UserAnnouncementItem struct {
}
// ListUserAnnouncements 获取当前用户的公告列表(含已读状态)
// 包含:target_type 匹配且全员推送的 + 指定给当前用户的
func ListUserAnnouncements(c *fiber.Ctx) error {
userID, userType, err := getCurrentUser(c)
if err != nil {
@@ -36,9 +37,10 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
}
var announcements []model.Announcement
database.DB.Where("target_type = ?", userType).
Order("created_at DESC").
Find(&announcements)
database.DB.Where(
"(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?",
userType, userID,
).Order("created_at DESC").Find(&announcements)
// 查询该用户的已读记录
readIDs := make(map[uint]time.Time)
@@ -73,7 +75,7 @@ func GetUnreadCount(c *fiber.Ctx) error {
var totalCount int64
database.DB.Model(&model.Announcement{}).
Where("target_type = ?", userType).
Where("(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?", userType, userID).
Count(&totalCount)
var readCount int64
@@ -106,7 +108,7 @@ type PendingPopupItem struct {
// MyAnnouncementInfo 我的公告信息聚合
type MyAnnouncementInfo struct {
UnreadCount int `json:"unreadCount"`
PopupQueue []PendingPopupItem `json:"popupQueue"` // 需要弹窗的公告(依次弹出)
PopupQueue []PendingPopupItem `json:"popupQueue"`
}
// GetMyAnnouncementInfo 获取当前用户的公告聚合信息(未读数 + 待弹窗队列)
@@ -116,10 +118,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
return utils.FailAuth(c, "未认证")
}
// 1. 总公告数
// 1. 总公告数(包含指定给当前用户的)
var totalCount int64
database.DB.Model(&model.Announcement{}).
Where("target_type = ?", userType).
Where("(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?", userType, userID).
Count(&totalCount)
// 2. 已读数
@@ -135,9 +137,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
// 3. 待弹窗队列:forcePopup=true 且 (loopPopup=true 或 未读)
var forceAnnouncements []model.Announcement
database.DB.Where("target_type = ? AND force_popup = 1", userType).
Order("created_at ASC").
Find(&forceAnnouncements)
database.DB.Where(
"force_popup = 1 AND ((target_type = ? AND target_user_id IS NULL) OR target_user_id = ?)",
userType, userID,
).Order("created_at ASC").Find(&forceAnnouncements)
// 查已读记录
readIDs := make(map[uint]bool)
@@ -176,14 +179,17 @@ func MarkAnnouncementRead(c *fiber.Ctx) error {
announcementID := c.Params("id")
// 验证公告存在
// 验证公告存在且用户有权访问
var announcement model.Announcement
if err := database.DB.First(&announcement, announcementID).Error; err != nil {
return utils.Fail(c, 404, "公告不存在")
}
// 验证公告目标类型匹配
if announcement.TargetType != userType {
// 验证公告目标类型匹配或指定给当前用户
if announcement.TargetType != userType && (announcement.TargetUserID == nil || *announcement.TargetUserID != userID) {
return utils.Fail(c, 403, "无权操作此公告")
}
if announcement.TargetUserID != nil && *announcement.TargetUserID != userID {
return utils.Fail(c, 403, "无权操作此公告")
}
+7 -1
View File
@@ -7,10 +7,16 @@ type Announcement struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Title string `gorm:"type:varchar(255);not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Source string `gorm:"type:varchar(16);not null;default:manual;index" json:"source"` // manual(管理员创建) / system(系统自动)
TargetType string `gorm:"type:varchar(16);not null;index" json:"targetType"` // agent / user
TargetUserID *uint `gorm:"index" json:"targetUserId"` // 指定单个用户(null=推送全部)
ForcePopup bool `gorm:"default:false" json:"forcePopup"`
LoopPopup bool `gorm:"default:false" json:"loopPopup"`
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Announcement) TableName() string {
return "announcements"
}
+5
View File
@@ -44,6 +44,11 @@ type SysUser struct {
// 累计邀请人数
InvitedCount int `gorm:"type:int;default:0" json:"invitedCount"`
// 用户状态 active / disabled
Status string `gorm:"type:varchar(16);not null;default:active;index" json:"status"`
// 禁用原因(Status=disabled 时显示给用户)
DisabledReason string `gorm:"type:varchar(500);default:''" json:"disabledReason"`
// 最后登录时间
LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"`
+20 -54
View File
@@ -6,11 +6,12 @@ import (
"crypto/cipher"
"crypto/des"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
"strings"
)
// ========== AES-128-CBC-PKCS7 ==========
@@ -104,61 +105,26 @@ func NewRSAKeyPairFromPublicKey(base64Key string) (*RSAKeyPair, error) {
if err != nil {
return nil, fmt.Errorf("base64 decode error: %w", err)
}
// 简单的 DER 解析 (PKCS#1 Public Key)
// 跳过 ASN.1 头部,提取 n 和 e
return parsePKCS1PublicKey(der)
// 尝试 PKCS#1 格式 (SEQUENCE { INTEGER n, INTEGER e })
pub, err := x509.ParsePKCS1PublicKey(der)
if err != nil {
// 尝试 PKIX/SubjectPublicKeyInfo 格式
keyAny, err2 := x509.ParsePKIXPublicKey(der)
if err2 != nil {
return nil, fmt.Errorf("parse DER failed (pkcs1: %w, pkix: %w)", err, err2)
}
rsaPub, ok := keyAny.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an RSA public key")
}
pub = rsaPub
}
func parsePKCS1PublicKey(der []byte) (*RSAKeyPair, error) {
// 简化的 PKCS#1 解析
// 实际上应该用 crypto/x509,但为了轻量级,手动解析
// 格式: SEQUENCE { SEQUENCE { OID, NULL }, BIT STRING { SEQUENCE { INTEGER n, INTEGER e } } }
// 或者直接 PKCS#1: SEQUENCE { INTEGER n, INTEGER e }
// 找最后的两个 INTEGER
data := string(der)
nParts := strings.Split(data, "\x02")
if len(nParts) < 3 {
return nil, fmt.Errorf("invalid RSA key format")
}
// 提取 n (倒数第二个 INTEGER)
nBytes := extractIntegerBytes(nParts[len(nParts)-2])
// 提取 e (最后一个 INTEGER)
eBytes := extractIntegerBytes(nParts[len(nParts)-1])
r := &RSAKeyPair{
n: new(big.Int).SetBytes(nBytes),
e: new(big.Int).SetBytes(eBytes),
}
return r, nil
}
func extractIntegerBytes(s string) []byte {
if len(s) == 0 {
return nil
}
// 第一个字节是长度
data := []byte(s)
if len(data) < 2 {
return data
}
length := int(data[0])
if length > 128 {
// 长格式
numLenBytes := length & 0x7F
length = 0
for i := 1; i <= numLenBytes; i++ {
length = length*256 + int(data[i])
}
data = data[1+numLenBytes:]
} else {
data = data[1:]
}
if length > len(data) {
length = len(data)
}
return data[:length]
return &RSAKeyPair{
n: pub.N,
e: big.NewInt(int64(pub.E)),
}, nil
}
func (r *RSAKeyPair) Encrypt(plaintext string) (string, error) {
@@ -26,6 +26,7 @@ type sessionClient struct {
func newSessionClient() *sessionClient {
jar := newCookieJar()
client := resty.New().
SetDoNotParseResponse(true).
SetTimeout(30*time.Second).
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
+38 -8
View File
@@ -22,6 +22,7 @@ import (
func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
sc := newSessionClient()
loginUrl := getIapValidLoginUrl(school)
slog.Info("IAP登录", "school", school.Name, "loginUrl", loginUrl, "ampUrl", school.AmpUrl, "wecloudUrl", school.WecloudUrl, "joinType", school.JoinType)
// 1. 获取 LT (Login Ticket)
lt, err := getIapLT(ctx, loginUrl, sc)
@@ -34,6 +35,7 @@ func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password
if err != nil {
return nil, fmt.Errorf("获取真实LT失败: %w", err)
}
slog.Info("IAP登录凭证", "lt", lt, "realLT", realLT)
// 3. RSA 加密密码
rsa, err := NewRSAKeyPairFromPublicKey(IapRsaPubKey)
@@ -62,27 +64,53 @@ func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password
"password": "{rsa}" + encPwd,
}
submitUrls := []string{
loginUrl + "/iap/doLogin",
loginUrl + "/doLogin",
}
var loginResp *resty.Response
var lastErr error
for _, submitUrl := range submitUrls {
// 使用 FlexibleRedirectPolicy 让 resty 跟随重定向
// 最终会到达 portal/index.html (200), 但过程中 cookies 会被自动保存
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
submitUrl := loginUrl + "/iap/doLogin"
loginResp, lastErr = sc.R().
SetFormData(submitData).
SetContext(ctx).
Post(submitUrl)
if lastErr == nil {
break
statusCode := 0
if loginResp != nil {
statusCode = loginResp.StatusCode()
}
slog.Info("IAP登录第一尝试", "url", submitUrl, "lastErr", lastErr, "status", statusCode)
if lastErr != nil || statusCode == 404 {
// 如果 /iap/doLogin 失败,尝试 /doLogin
submitUrl = loginUrl + "/doLogin"
loginResp, lastErr = sc.R().
SetFormData(submitData).
SetContext(ctx).
Post(submitUrl)
statusCode = 0
if loginResp != nil {
statusCode = loginResp.StatusCode()
}
slog.Info("IAP登录第二尝试", "url", submitUrl, "lastErr", lastErr, "status", statusCode)
}
if lastErr != nil {
return nil, fmt.Errorf("提交IAP登录失败: %w", lastErr)
}
defer loginResp.RawBody().Close()
slog.Info("IAP登录响应", "status", loginResp.StatusCode(), "url", loginResp.RawResponse.Request.URL.String())
// 如果重定向被跟随到了最终页面(200),但 cookies 已设置,视为成功
if loginResp.StatusCode() == 200 && strings.Contains(loginResp.RawResponse.Request.URL.String(), "index.html") {
slog.Info("IAP登录成功(跟随重定向到首页)")
activateSession(ctx, school.WecloudUrl, sc.Client)
return sc.jar.AllCookies(), nil
}
// 6. 处理响应
// 6. 处理响应
switch loginResp.StatusCode() {
case 302:
@@ -200,6 +228,7 @@ func handleIapLoginSuccess(ctx context.Context, resp *resty.Response, school *Sc
if err != nil {
return nil, fmt.Errorf("获取IAP重定向地址失败: %w", err)
}
slog.Info("IAP重定向", "jumpUrl", jumpUrl.String())
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String())
@@ -207,6 +236,7 @@ func handleIapLoginSuccess(ctx context.Context, resp *resty.Response, school *Sc
return nil, fmt.Errorf("IAP跟随重定向失败: %w", err)
}
defer verifyResp.RawBody().Close()
slog.Info("IAP重定向验证", "status", verifyResp.StatusCode(), "finalUrl", verifyResp.RawResponse.Request.URL.String())
activateSession(ctx, school.WecloudUrl, sc.Client)
return sc.jar.AllCookies(), nil
+6 -1
View File
@@ -143,10 +143,15 @@ func GetSchoolDetail(tenantID string) (*SchoolDetailInfo, error) {
return &detail, nil
}
// 硬编码走 IAP 登录的学校 ID 列表(尽管 joinType 不是 cloud,但实际支持 IAP)
var iapSchoolIds = map[string]bool{
"01e270d1-00e2-4ce9-b0af-5d5fbd462b8a": true, // 河北石油职业技术大学
}
// CpdailyLogin 执行 cpdaily 登录
// 返回登录成功后的所有 cookies
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
if school.IsIap() {
if school.IsIap() || iapSchoolIds[school.ID] {
return iapLogin(ctx, school, username, password)
}
return casLogin(ctx, school, username, password)
+68
View File
@@ -189,7 +189,9 @@ export interface Announcement {
id: number
title: string
content: string
source: string
targetType: 'agent' | 'user'
targetUserId: number | null
forcePopup: boolean
loopPopup: boolean
createdBy: number
@@ -201,12 +203,14 @@ export interface AnnouncementListItem extends Announcement {
readCount: number
unreadCount: number
totalTargets: number
targetUserName: string
}
export interface CreateAnnouncementParams {
title: string
content: string
targetType: 'agent' | 'user'
targetUserId?: number
forcePopup: boolean
loopPopup: boolean
}
@@ -241,3 +245,67 @@ export function getAnnouncementDetail(id: number) {
export function deleteAnnouncement(id: number) {
return api.delete(`/admin/announcements/${id}`)
}
// ======== 用户管理(admin only) ========
export interface UserListItem {
id: number
username: string
schoolId: string
schoolName: string
studentId: string
realName: string
signCount: number
inviteReward: number
invitedCount: number
taskCount: number
status: string
createdAt: string
lastLoginAt: string
}
export interface UserListResult {
total: number
items: UserListItem[]
page: number
pageSize: number
}
export interface UserDetail {
id: number
username: string
schoolId: string
schoolName: string
studentId: string
realName: string
password: string
signCount: number
inviteReward: number
invitedCount: number
status: string
disabledReason: string
taskCount: number
mobileCode: string
mobileType: string
createdAt: string
lastLoginAt: string
}
/** 获取用户列表 */
export function getUsers(page = 1, pageSize = 20, keyword = '') {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('pageSize', String(pageSize))
if (keyword) params.set('keyword', keyword)
return api.get<{ data: UserListResult }>(`/admin/users?${params.toString()}`)
}
/** 获取用户详情 */
export function getUserDetail(id: number) {
return api.get<{ data: UserDetail }>(`/admin/users/${id}`)
}
/** 更新用户状态 */
export function updateUserStatus(id: number, status: 'active' | 'disabled', disabledReason = '') {
return api.put(`/admin/users/${id}/status`, { status, disabledReason })
}
+6
View File
@@ -20,6 +20,7 @@ import AdminAnnouncements from '@/views/admin/AdminAnnouncements.vue'
import AdminTicketList from '@/views/admin/AdminTicketList.vue'
import AdminTicketDetail from '@/views/admin/AdminTicketDetail.vue'
import AdminPendingSchools from '@/views/admin/AdminPendingSchools.vue'
import AdminUsers from '@/views/admin/AdminUsers.vue'
const router = createRouter({
history: createWebHistory(),
@@ -61,6 +62,11 @@ const router = createRouter({
name: 'admin-cdks',
component: AdminCdks,
},
{
path: 'users',
name: 'admin-users',
component: AdminUsers,
},
{
path: 'settings',
name: 'admin-settings',
+106 -15
View File
@@ -1,14 +1,22 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Megaphone, Plus, Trash2, X, Bell } from 'lucide-vue-next'
import { Megaphone, Plus, Trash2, X, Bell, Users, User, Bot, Search } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useMessage } from '@/composables/useMessage'
import * as adminApi from '@/api/admin'
const message = useMessage()
const announcements = ref<adminApi.AnnouncementListItem[]>([])
// 公告列表(带来源和指定用户信息)
interface AnnouncementEx extends adminApi.AnnouncementListItem {
source: string
targetUserId: number | null
targetUserName: string
}
const announcements = ref<AnnouncementEx[]>([])
const loading = ref(true)
const showCreateModal = ref(false)
@@ -16,6 +24,8 @@ const showCreateModal = ref(false)
const formTitle = ref('')
const formContent = ref('')
const formTargetType = ref<'agent' | 'user'>('user')
const formTargetMode = ref<'all' | 'specific'>('all') // 推送范围:全部 / 指定用户
const formTargetUserId = ref('') // 指定用户ID
const formForcePopup = ref(false)
const formLoopPopup = ref(false)
const creating = ref(false)
@@ -40,6 +50,8 @@ function openCreate() {
formTitle.value = ''
formContent.value = ''
formTargetType.value = 'user'
formTargetMode.value = 'all'
formTargetUserId.value = ''
formForcePopup.value = false
formLoopPopup.value = false
showCreateModal.value = true
@@ -55,12 +67,27 @@ async function handleCreate() {
return
}
// 校验指定用户
let targetUserId: number | undefined = undefined
if (formTargetMode.value === 'specific') {
if (!formTargetUserId.value.trim()) {
message.info('请输入用户ID')
return
}
targetUserId = parseInt(formTargetUserId.value.trim())
if (isNaN(targetUserId) || targetUserId <= 0) {
message.info('请输入有效的用户ID')
return
}
}
creating.value = true
try {
await adminApi.createAnnouncement({
title: formTitle.value.trim(),
content: formContent.value.trim(),
targetType: formTargetType.value,
targetUserId: targetUserId,
forcePopup: formForcePopup.value,
loopPopup: formLoopPopup.value,
})
@@ -75,6 +102,11 @@ async function handleCreate() {
}
async function handleDelete(id: number) {
const item = announcements.value.find(a => a.id === id)
if (item?.source === 'system') {
message.info('系统自动生成的公告不能删除')
return
}
if (!confirm('确定要删除此公告吗?')) return
try {
await adminApi.deleteAnnouncement(id)
@@ -88,6 +120,17 @@ async function handleDelete(id: number) {
function targetLabel(t: string) {
return t === 'agent' ? '代理' : '普通用户'
}
function sourceLabel(s: string) {
return s === 'system' ? '系统自动' : '手动创建'
}
function scopeLabel(item: AnnouncementEx) {
if (item.targetUserId) {
return `指定用户: ${item.targetUserName || `ID:${item.targetUserId}`}`
}
return `全部${targetLabel(item.targetType)}`
}
</script>
<template>
@@ -126,14 +169,15 @@ function targetLabel(t: string) {
<table v-else class="w-full text-sm table-fixed">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<th class="px-4 py-3 font-medium w-[20%]">标题</th>
<th class="px-4 py-3 font-medium w-[28%]">内容</th>
<th class="px-4 py-3 font-medium w-[10%]">推送对象</th>
<th class="px-4 py-3 font-medium w-[8%]">强制弹窗</th>
<th class="px-4 py-3 font-medium w-[8%]">循环弹窗</th>
<th class="px-4 py-3 font-medium w-[14%]">阅读人数</th>
<th class="px-4 py-3 font-medium w-[18%]">创建时间</th>
<th class="px-4 py-3 font-medium text-center w-[10%]">操作</th>
<th class="px-4 py-3 font-medium w-[16%]">标题</th>
<th class="px-4 py-3 font-medium w-[22%]">内容</th>
<th class="px-4 py-3 font-medium w-[8%]">来源</th>
<th class="px-4 py-3 font-medium w-[12%]">推送范围</th>
<th class="px-4 py-3 font-medium w-[6%]">强制弹窗</th>
<th class="px-4 py-3 font-medium w-[6%]">循环弹窗</th>
<th class="px-4 py-3 font-medium w-[12%]">阅读人数</th>
<th class="px-4 py-3 font-medium w-[16%]">创建时间</th>
<th class="px-4 py-3 font-medium text-center w-[8%]">操作</th>
</tr>
</thead>
<tbody>
@@ -150,13 +194,24 @@ function targetLabel(t: string) {
<td class="px-4 py-3">
<p class="text-gray-500 truncate block">{{ item.content }}</p>
</td>
<!-- 推送对象 -->
<!-- 来源 -->
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-blue-100 text-blue-700'"
>
{{ targetLabel(item.targetType) }}
<Bot v-if="item.source === 'system'" class="w-3 h-3 inline mr-0.5" />
{{ sourceLabel(item.source) }}
</span>
</td>
<!-- 推送范围 -->
<td class="px-4 py-3">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700')"
>
<User v-if="item.targetUserId" class="w-3 h-3 inline mr-0.5" />
<Users v-else class="w-3 h-3 inline mr-0.5" />
{{ scopeLabel(item) }}
</span>
</td>
<!-- 强制弹窗 -->
@@ -195,12 +250,14 @@ function targetLabel(t: string) {
<!-- 操作 -->
<td class="px-4 py-3 text-center whitespace-nowrap">
<button
v-if="item.source !== 'system'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
@click="handleDelete(item.id)"
>
<Trash2 class="w-3.5 h-3.5" />
删除
</button>
<span v-else class="text-xs text-gray-300">--</span>
</td>
</tr>
</tbody>
@@ -238,7 +295,7 @@ function targetLabel(t: string) {
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">推送对象</label>
<label class="block text-xs font-medium text-gray-500 mb-1.5">推送对象类型</label>
<div class="flex gap-2">
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
@@ -253,6 +310,40 @@ function targetLabel(t: string) {
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">推送范围</label>
<div class="flex gap-2">
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetMode === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetMode = 'all'"
>
<Users class="w-3.5 h-3.5 inline mr-1" />
全部
</button>
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetMode === 'specific' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetMode = 'specific'"
>
<User class="w-3.5 h-3.5 inline mr-1" />
指定用户
</button>
</div>
</div>
<!-- 指定用户ID输入 -->
<div v-if="formTargetMode === 'specific'">
<label class="block text-xs font-medium text-gray-500 mb-1.5">用户ID</label>
<Input
v-model="formTargetUserId"
type="number"
placeholder="输入用户ID"
class="h-10 text-sm"
/>
<p class="text-[10px] text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
</div>
<div class="flex items-center gap-3">
<button
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
+2
View File
@@ -17,6 +17,7 @@ import {
Circle,
MessageSquare,
School,
UserCheck,
} from 'lucide-vue-next'
import { useMessage } from '@/composables/useMessage'
import { Button } from '@/components/ui/button'
@@ -35,6 +36,7 @@ const showSidebar = ref(false)
const navItems = [
{ name: 'admin-dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin/dashboard' },
{ name: 'admin-agents', label: '代理管理', icon: Users, path: '/admin/agents' },
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users' },
{ name: 'admin-cdks', label: 'CDK管理', icon: QrCode, path: '/admin/cdks' },
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements' },
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets' },
+73 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Save, LoaderCircle, Euro } from 'lucide-vue-next'
import { Save, LoaderCircle, Euro, Gift, UserPlus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -13,6 +13,8 @@ const message = useMessage()
const loading = ref(false)
const saving = ref(false)
const baseDeduction = ref('')
const inviterReward = ref('')
const inviteeReward = ref('')
onMounted(async () => {
await loadSettings()
@@ -24,6 +26,8 @@ async function loadSettings() {
const res = await adminApi.getSettings()
const settings = res.data?.data || {}
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
} catch {
message.error('加载设置失败')
} finally {
@@ -32,14 +36,27 @@ async function loadSettings() {
}
async function handleSave() {
const val = parseFloat(baseDeduction.value)
if (isNaN(val) || val <= 0) {
const deduction = parseFloat(baseDeduction.value)
if (isNaN(deduction) || deduction <= 0) {
message.info('请输入有效的正数')
return
}
const invReward = parseFloat(inviterReward.value)
if (isNaN(invReward) || invReward < 0) {
message.info('邀请人奖励积分请输入有效的非负数')
return
}
const ineeReward = parseFloat(inviteeReward.value)
if (isNaN(ineeReward) || ineeReward < 0) {
message.info('被邀请人奖励积分请输入有效的非负数')
return
}
saving.value = true
try {
await adminApi.updateSetting('base_deduction_per_run', val.toFixed(2))
await adminApi.updateSetting('base_deduction_per_run', deduction.toFixed(2))
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
message.success('保存成功')
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '保存失败')
@@ -87,5 +104,57 @@ async function handleSave() {
</template>
</div>
</Card>
<!-- 邀请奖励设置 -->
<Card class="max-w-lg mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Gift class="w-4 h-4 text-purple-500" />
<span class="font-semibold text-gray-900 text-sm">邀请奖励设置</span>
</div>
<div class="p-6 space-y-4">
<template v-if="!loading">
<div>
<Label class="mb-1 block text-sm text-gray-600">邀请人每次获奖励积分</Label>
<div class="flex items-center gap-2">
<Input
v-model="inviterReward"
type="number"
step="0.1"
min="0"
class="h-10 text-sm max-w-[200px]"
/>
<span class="text-sm text-gray-500">积分</span>
</div>
<p class="text-xs text-gray-400 mt-1">
每成功邀请一位新用户,邀请人获得的奖励积分数(满1积分可兑换1次签到)
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">被邀请人每次获奖励积分</Label>
<div class="flex items-center gap-2">
<Input
v-model="inviteeReward"
type="number"
step="0.1"
min="0"
class="h-10 text-sm max-w-[200px]"
/>
<span class="text-sm text-gray-500">积分</span>
</div>
<p class="text-xs text-gray-400 mt-1">
新用户通过邀请链接注册时,新用户获得的奖励积分数(满1积分可兑换1次签到)
</p>
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="saving" @click="handleSave">
<Save class="w-4 h-4 mr-1" />
{{ saving ? '保存中...' : '保存设置' }}
</Button>
</div>
</template>
</div>
</Card>
</div>
</template>
+503
View File
@@ -0,0 +1,503 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import {
Users,
Search,
X,
Eye,
EyeOff,
Ban,
CheckCircle,
LoaderCircle,
ChevronLeft,
ChevronRight,
School,
IdCard,
ClipboardCheck,
Gift,
UserPlus,
Lock,
Smartphone,
Clock,
Calendar,
AlertTriangle,
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card } from '@/components/ui/card'
import { useMessage } from '@/composables/useMessage'
import * as adminApi from '@/api/admin'
const message = useMessage()
interface UserItem {
id: number
username: string
schoolId: string
schoolName: string
studentId: string
realName: string
signCount: number
inviteReward: number
invitedCount: number
taskCount: number
status: string
createdAt: string
lastLoginAt: string
}
interface UserDetail {
id: number
username: string
schoolId: string
schoolName: string
studentId: string
realName: string
password: string
signCount: number
inviteReward: number
invitedCount: number
status: string
disabledReason: string
taskCount: number
mobileCode: string
mobileType: string
createdAt: string
lastLoginAt: string
}
const loading = ref(false)
const users = ref<UserItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const searchKeyword = ref('')
const showPasswordMap = ref<Record<number, boolean>>({})
// 详情弹窗
const showDetail = ref(false)
const detailUser = ref<UserDetail | null>(null)
const detailLoading = ref(false)
// 禁用弹窗
const showDisableModal = ref(false)
const disableTarget = ref<UserItem | null>(null)
const disableReason = ref('')
const disabling = ref(false)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
onMounted(() => {
loadUsers()
})
async function loadUsers() {
loading.value = true
try {
const res = await adminApi.getUsers(page.value, pageSize.value, searchKeyword.value)
const data = res.data?.data || {}
users.value = data.items || []
total.value = data.total || 0
} catch {
message.error('加载用户列表失败')
users.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
searchKeyword.value = keyword.value.trim()
loadUsers()
}
function handlePage(p: number) {
if (p < 1 || p > totalPages.value) return
page.value = p
loadUsers()
}
async function openDetail(user: UserItem) {
showDetail.value = true
detailLoading.value = true
try {
const res = await adminApi.getUserDetail(user.id)
detailUser.value = res.data?.data || null
} catch {
message.error('加载用户详情失败')
} finally {
detailLoading.value = false
}
}
function openDisableModal(user: UserItem) {
disableTarget.value = user
disableReason.value = ''
showDisableModal.value = true
}
async function handleDisable() {
if (!disableTarget.value) return
if (!disableReason.value.trim()) {
message.info('请输入禁用原因')
return
}
disabling.value = true
try {
await adminApi.updateUserStatus(disableTarget.value.id, 'disabled', disableReason.value.trim())
message.success('已禁用该用户')
showDisableModal.value = false
// 刷新列表
if (detailUser.value && detailUser.value.id === disableTarget.value.id) {
detailUser.value.status = 'disabled'
detailUser.value.disabledReason = disableReason.value.trim()
}
await loadUsers()
} catch (e: any) {
message.error(e?.response?.data?.message || '操作失败')
} finally {
disabling.value = false
}
}
async function handleEnable(user: UserItem) {
try {
await adminApi.updateUserStatus(user.id, 'active', '')
message.success('已启用该用户')
if (detailUser.value && detailUser.value.id === user.id) {
detailUser.value.status = 'active'
detailUser.value.disabledReason = ''
}
await loadUsers()
} catch (e: any) {
message.error(e?.response?.data?.message || '操作失败')
}
}
function togglePassword(id: number) {
showPasswordMap.value[id] = !showPasswordMap.value[id]
}
function statusLabel(s: string) {
return s === 'disabled' ? '已禁用' : '正常'
}
</script>
<template>
<div class="p-6">
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
<Users class="w-5 h-5 text-green-600" />
</div>
<div>
<h1 class="text-lg font-bold text-gray-900">用户管理</h1>
<p class="text-sm text-gray-400">管理今日校园用户</p>
</div>
</div>
</div>
<!-- 搜索栏 -->
<div class="mb-4 flex gap-2">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
v-model="keyword"
placeholder="搜索用户名/学校/学号/姓名..."
class="pl-9 h-10 text-sm"
@keyup.enter="handleSearch"
/>
</div>
<Button @click="handleSearch" class="h-10 text-sm">
<Search class="w-4 h-4 mr-1" />
搜索
</Button>
</div>
<!-- 表格 -->
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<!-- 加载骨架 -->
<div v-if="loading" class="p-6 space-y-4">
<div v-for="i in 5" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
</div>
<!-- 空状态 -->
<div v-else-if="users.length === 0" class="text-center py-20 text-gray-400">
<Users class="w-12 h-12 mx-auto mb-3 opacity-30" />
<p class="text-sm">暂无用户</p>
</div>
<!-- 表格 -->
<table v-else class="w-full text-sm table-fixed">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<th class="px-4 py-3 font-medium w-[12%]">用户名</th>
<th class="px-4 py-3 font-medium w-[12%]">学校</th>
<th class="px-4 py-3 font-medium w-[10%]">学工号</th>
<th class="px-4 py-3 font-medium w-[8%]">真实姓名</th>
<th class="px-4 py-3 font-medium w-[6%]">次数</th>
<th class="px-4 py-3 font-medium w-[8%]">邀请积分</th>
<th class="px-4 py-3 font-medium w-[6%]">邀请人数</th>
<th class="px-4 py-3 font-medium w-[6%]">任务数</th>
<th class="px-4 py-3 font-medium w-[6%]">状态</th>
<th class="px-4 py-3 font-medium w-[14%]">创建时间</th>
<th class="px-4 py-3 font-medium text-center w-[12%]">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
>
<td class="px-4 py-3 truncate">{{ user.username }}</td>
<td class="px-4 py-3 truncate" :title="user.schoolName">{{ user.schoolName }}</td>
<td class="px-4 py-3 truncate font-mono text-xs">{{ user.studentId }}</td>
<td class="px-4 py-3 truncate">{{ user.realName || '--' }}</td>
<td class="px-4 py-3">{{ user.signCount }}</td>
<td class="px-4 py-3">{{ user.inviteReward }}</td>
<td class="px-4 py-3">{{ user.invitedCount }}</td>
<td class="px-4 py-3">{{ user.taskCount }}</td>
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="user.status === 'disabled' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'"
>
{{ statusLabel(user.status) }}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ user.createdAt }}</td>
<td class="px-4 py-3 text-center whitespace-nowrap">
<div class="flex items-center justify-center gap-1">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
@click="openDetail(user)"
>
<Eye class="w-3.5 h-3.5" />
详情
</button>
<button
v-if="user.status === 'active'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
@click="openDisableModal(user)"
>
<Ban class="w-3.5 h-3.5" />
禁用
</button>
<button
v-else
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-500 hover:bg-green-50 transition-colors"
@click="handleEnable(user)"
>
<CheckCircle class="w-3.5 h-3.5" />
启用
</button>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div
v-if="total > pageSize"
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
>
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="page <= 1"
@click="handlePage(page - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="page >= totalPages"
@click="handlePage(page + 1)"
>
<ChevronRight class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- 详情弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showDetail" class="fixed inset-0 z-50 flex items-center justify-center px-4">
<div class="absolute inset-0 bg-black/40" @click="showDetail = false" />
<div class="relative w-[90%] sm:max-w-lg bg-white rounded-2xl shadow-xl overflow-hidden max-h-[90vh] flex flex-col">
<div class="h-1.5 bg-gradient-to-r from-green-500 to-green-400" />
<div class="flex items-center justify-between px-5 pt-5 pb-3">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-xl bg-green-100 flex items-center justify-center">
<Users class="w-5 h-5 text-green-500" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">用户详情</h3>
<p class="text-xs text-gray-400">{{ detailUser?.username }}</p>
</div>
</div>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="showDetail = false">
<X class="w-4 h-4" />
</button>
</div>
<div v-if="detailLoading" class="flex items-center justify-center py-16">
<LoaderCircle class="w-6 h-6 animate-spin text-green-500" />
</div>
<div v-else-if="detailUser" class="px-5 pb-5 overflow-y-auto space-y-3">
<!-- 状态 -->
<div class="flex items-center gap-2 px-3 py-2 rounded-lg"
:class="detailUser.status === 'disabled' ? 'bg-red-50' : 'bg-green-50'">
<AlertTriangle v-if="detailUser.status === 'disabled'" class="w-4 h-4 text-red-500" />
<CheckCircle v-else class="w-4 h-4 text-green-500" />
<span class="text-sm font-medium" :class="detailUser.status === 'disabled' ? 'text-red-700' : 'text-green-700'">
{{ detailUser.status === 'disabled' ? '已禁用' : '正常' }}
</span>
<span v-if="detailUser.disabledReason" class="text-xs text-red-500 ml-2">原因:{{ detailUser.disabledReason }}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<School class="w-3.5 h-3.5" /> 学校
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.schoolName || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<IdCard class="w-3.5 h-3.5" /> 学工号
</div>
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.studentId || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Lock class="w-3.5 h-3.5" /> 密码
</div>
<div class="flex items-center gap-1">
<p class="text-sm font-medium text-gray-900 font-mono truncate">
{{ showPasswordMap[detailUser.id] ? detailUser.password : '••••••••' }}
</p>
<button class="flex-shrink-0 text-gray-400 hover:text-gray-600" @click="togglePassword(detailUser.id)">
<EyeOff v-if="!showPasswordMap[detailUser.id]" class="w-3.5 h-3.5" />
<Eye v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 签到次数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.signCount }} 次</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Gift class="w-3.5 h-3.5" /> 邀请积分
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.inviteReward }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<UserPlus class="w-3.5 h-3.5" /> 邀请人数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.invitedCount }} 人</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 任务数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.taskCount }} 个</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Smartphone class="w-3.5 h-3.5" /> 设备码
</div>
<p class="text-sm font-medium text-gray-900 font-mono text-xs truncate">{{ detailUser.mobileCode || '未绑定' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Calendar class="w-3.5 h-3.5" /> 注册时间
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.createdAt }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Clock class="w-3.5 h-3.5" /> 最后登录
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.lastLoginAt || '--' }}</p>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 禁用弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showDisableModal" class="fixed inset-0 z-50 flex items-center justify-center px-4">
<div class="absolute inset-0 bg-black/40" @click="showDisableModal = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="h-1.5 bg-gradient-to-r from-red-500 to-orange-400" />
<div class="p-6 space-y-4">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-xl bg-red-100 flex items-center justify-center">
<Ban class="w-5 h-5 text-red-500" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">禁用用户</h3>
<p class="text-xs text-gray-400">{{ disableTarget?.username }}</p>
</div>
</div>
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">禁用原因</Label>
<textarea
v-model="disableReason"
placeholder="请输入禁用原因,用户登录时将看到此信息"
class="w-full h-20 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-400"
/>
</div>
<div class="flex gap-2">
<Button
variant="outline"
class="flex-1 h-10 text-sm"
@click="showDisableModal = false"
>取消</Button>
<Button
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
:disabled="disabling || !disableReason.trim()"
@click="handleDisable"
>
{{ disabling ? '处理中...' : '确认禁用' }}
</Button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+1 -1
View File
@@ -240,7 +240,7 @@ async function handleRedeemInvite() {
复制
</button>
</div>
<p class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请1位新用户登录可获 0.5 奖励积分,满 1 可兑换 1 次签到次数</p>
<p class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请好友可获得奖励积分,满 1 积分可兑换 1 次签到次数</p>
</div>
<div v-else class="mt-1">
<button