feat: announcement system upgrade + user management
- 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:
+6
-1
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,11 +14,12 @@ import (
|
||||
|
||||
// CreateAnnouncementRequest 创建公告请求
|
||||
type CreateAnnouncementRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
TargetType string `json:"targetType"` // agent / user
|
||||
ForcePopup bool `json:"forcePopup"`
|
||||
LoopPopup bool `json:"loopPopup"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// CreateAnnouncement 创建公告
|
||||
@@ -39,13 +40,30 @@ 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,
|
||||
TargetType: req.TargetType,
|
||||
ForcePopup: req.ForcePopup,
|
||||
LoopPopup: req.LoopPopup,
|
||||
CreatedBy: adminID,
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Source: "manual",
|
||||
TargetType: req.TargetType,
|
||||
TargetUserID: req.TargetUserID,
|
||||
ForcePopup: req.ForcePopup,
|
||||
LoopPopup: req.LoopPopup,
|
||||
CreatedBy: adminID,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&announcement).Error; err != nil {
|
||||
@@ -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"`
|
||||
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,
|
||||
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{})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
+148
-23
@@ -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,39 +327,91 @@ 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
|
||||
}
|
||||
|
||||
user = model.SysUser{
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
}
|
||||
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
|
||||
myInviteCode := generateRandomCode(6)
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
// 读取可配置的奖励积分
|
||||
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),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理邀请码奖励
|
||||
if referralCode != "" {
|
||||
var inviter model.SysUser
|
||||
if err := tx.Where("invite_code = ?", referralCode).First(&inviter).Error; err != nil {
|
||||
// 邀请码无效,仅记录日志,不中断注册流程
|
||||
slog.Warn("邀请码无效,跳过奖励", "referralCode", referralCode, "newUserId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 自邀请防护
|
||||
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 inviteCode != "" {
|
||||
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 hasValidReferral {
|
||||
createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward)
|
||||
}
|
||||
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "inviteCode", inviteCode)
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -46,7 +52,9 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
|
||||
// 支持的 key 白名单
|
||||
allowedKeys := map[string]bool{
|
||||
"base_deduction_per_run": true,
|
||||
"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)
|
||||
|
||||
@@ -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
|
||||
@@ -105,8 +107,8 @@ type PendingPopupItem struct {
|
||||
|
||||
// MyAnnouncementInfo 我的公告信息聚合
|
||||
type MyAnnouncementInfo struct {
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
PopupQueue []PendingPopupItem `json:"popupQueue"` // 需要弹窗的公告(依次弹出)
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
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,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"`
|
||||
TargetType string `gorm:"type:varchar(16);not null;index" json:"targetType"` // agent / user
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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])
|
||||
// 尝试 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)
|
||||
}
|
||||
data = data[1+numLenBytes:]
|
||||
} else {
|
||||
data = data[1:]
|
||||
rsaPub, ok := keyAny.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an RSA public key")
|
||||
}
|
||||
pub = rsaPub
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
if lastErr == nil {
|
||||
break
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user