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/server
|
||||||
backed/cpdaily-new
|
backed/cpdaily-new
|
||||||
backed/cpdaily-server
|
backed/cpdaily-server
|
||||||
|
backed/cmd/server/cpdaily-new
|
||||||
backed/*.log
|
backed/*.log
|
||||||
backed/*.har
|
backed/*.har
|
||||||
|
|
||||||
|
# Frontend build (rebuilt by Docker multi-stage)
|
||||||
|
backed/dist/
|
||||||
|
frondend/dist/
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
frondend/node_modules/
|
frondend/node_modules/
|
||||||
frondend/dist/
|
frondend/*.tsbuildinfo
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ func main() {
|
|||||||
adminAuth.Get("/all-agents", handler.ListAllAgents)
|
adminAuth.Get("/all-agents", handler.ListAllAgents)
|
||||||
adminAuth.Get("/agent/list", 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.Post("/announcements", handler.CreateAnnouncement)
|
||||||
adminAuth.Get("/announcements", handler.ListAnnouncements)
|
adminAuth.Get("/announcements", handler.ListAnnouncements)
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ import (
|
|||||||
|
|
||||||
// CreateAnnouncementRequest 创建公告请求
|
// CreateAnnouncementRequest 创建公告请求
|
||||||
type CreateAnnouncementRequest struct {
|
type CreateAnnouncementRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
TargetType string `json:"targetType"` // agent / user
|
TargetType string `json:"targetType"` // agent / user
|
||||||
ForcePopup bool `json:"forcePopup"`
|
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
|
||||||
LoopPopup bool `json:"loopPopup"`
|
ForcePopup bool `json:"forcePopup"`
|
||||||
|
LoopPopup bool `json:"loopPopup"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAnnouncement 创建公告
|
// CreateAnnouncement 创建公告
|
||||||
@@ -39,13 +40,30 @@ func CreateAnnouncement(c *fiber.Ctx) error {
|
|||||||
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
|
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{
|
announcement := model.Announcement{
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
TargetType: req.TargetType,
|
Source: "manual",
|
||||||
ForcePopup: req.ForcePopup,
|
TargetType: req.TargetType,
|
||||||
LoopPopup: req.LoopPopup,
|
TargetUserID: req.TargetUserID,
|
||||||
CreatedBy: adminID,
|
ForcePopup: req.ForcePopup,
|
||||||
|
LoopPopup: req.LoopPopup,
|
||||||
|
CreatedBy: adminID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.DB.Create(&announcement).Error; err != nil {
|
if err := database.DB.Create(&announcement).Error; err != nil {
|
||||||
@@ -53,16 +71,17 @@ func CreateAnnouncement(c *fiber.Ctx) error {
|
|||||||
return utils.FailServer(c, "创建公告失败")
|
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)
|
return utils.Success(c, announcement)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnnouncementListItem 公告列表项(含统计)
|
// AnnouncementListItem 公告列表项(含统计和来源信息)
|
||||||
type AnnouncementListItem struct {
|
type AnnouncementListItem struct {
|
||||||
model.Announcement
|
model.Announcement
|
||||||
ReadCount int `json:"readCount"`
|
ReadCount int `json:"readCount"`
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
TotalTargets int64 `json:"totalTargets"`
|
TotalTargets int64 `json:"totalTargets"`
|
||||||
|
TargetUserName string `json:"targetUserName,omitempty"` // 指定用户时的用户名
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAnnouncements 公告列表(含已读/未读统计)
|
// ListAnnouncements 公告列表(含已读/未读统计)
|
||||||
@@ -84,17 +103,37 @@ func ListAnnouncements(c *fiber.Ctx) error {
|
|||||||
Count(&readCount)
|
Count(&readCount)
|
||||||
|
|
||||||
var totalTargets int64
|
var totalTargets int64
|
||||||
if a.TargetType == "agent" {
|
if a.TargetUserID != nil {
|
||||||
|
// 指定单个用户
|
||||||
|
totalTargets = 1
|
||||||
|
} else if a.TargetType == "agent" {
|
||||||
totalTargets = totalAgents
|
totalTargets = totalAgents
|
||||||
} else {
|
} else {
|
||||||
totalTargets = totalUsers
|
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{
|
items = append(items, AnnouncementListItem{
|
||||||
Announcement: a,
|
Announcement: a,
|
||||||
ReadCount: int(readCount),
|
ReadCount: int(readCount),
|
||||||
UnreadCount: int(max(0, totalTargets-readCount)),
|
UnreadCount: int(max(0, totalTargets-readCount)),
|
||||||
TotalTargets: totalTargets,
|
TotalTargets: totalTargets,
|
||||||
|
TargetUserName: targetUserName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +193,22 @@ func GetAnnouncement(c *fiber.Ctx) error {
|
|||||||
func DeleteAnnouncement(c *fiber.Ctx) error {
|
func DeleteAnnouncement(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
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)
|
result := database.DB.Delete(&model.Announcement{}, id)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return utils.FailServer(c, "删除失败")
|
return utils.FailServer(c, "删除失败")
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return utils.Fail(c, 404, "公告不存在")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同时删除已读记录
|
// 同时删除已读记录
|
||||||
database.DB.Where("announcement_id = ?", id).Delete(&model.AnnouncementRead{})
|
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"
|
"cpdaily-new/internal/utils"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -122,6 +123,19 @@ func Login(c *fiber.Ctx) error {
|
|||||||
return utils.FailServer(c, "用户处理失败")
|
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 和最后登录时间
|
// 更新 cookie 和最后登录时间
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
database.DB.Model(&user).Updates(map[string]interface{}{
|
database.DB.Model(&user).Updates(map[string]interface{}{
|
||||||
@@ -242,8 +256,67 @@ func buildUsername(school *cpd.SchoolDetailInfo, studentID string) string {
|
|||||||
return abbr + studentID
|
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
|
var user model.SysUser
|
||||||
err := database.DB.Where("username = ?", username).First(&user).Error
|
err := database.DB.Where("username = ?", username).First(&user).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -254,39 +327,91 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动注册
|
// 自动注册:生成密码哈希和邀请码
|
||||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user = model.SysUser{
|
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
|
||||||
Username: username,
|
myInviteCode := generateRandomCode(6)
|
||||||
Password: string(hashedPwd),
|
|
||||||
SchoolID: schoolID,
|
|
||||||
SchoolName: schoolName,
|
|
||||||
StudentID: studentID,
|
|
||||||
Cookie: cookie,
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理邀请码奖励
|
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
|
||||||
if inviteCode != "" {
|
if hasValidReferral {
|
||||||
var inviter model.SysUser
|
createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "inviteCode", inviteCode)
|
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ func GetSettings(c *fiber.Ctx) error {
|
|||||||
if _, ok := settings["base_deduction_per_run"]; !ok {
|
if _, ok := settings["base_deduction_per_run"]; !ok {
|
||||||
settings["base_deduction_per_run"] = "1.00"
|
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)
|
return utils.Success(c, settings)
|
||||||
}
|
}
|
||||||
@@ -46,7 +52,9 @@ func UpdateSetting(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// 支持的 key 白名单
|
// 支持的 key 白名单
|
||||||
allowedKeys := map[string]bool{
|
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] {
|
if !allowedKeys[req.Key] {
|
||||||
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
|
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type UserAnnouncementItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListUserAnnouncements 获取当前用户的公告列表(含已读状态)
|
// ListUserAnnouncements 获取当前用户的公告列表(含已读状态)
|
||||||
|
// 包含:target_type 匹配且全员推送的 + 指定给当前用户的
|
||||||
func ListUserAnnouncements(c *fiber.Ctx) error {
|
func ListUserAnnouncements(c *fiber.Ctx) error {
|
||||||
userID, userType, err := getCurrentUser(c)
|
userID, userType, err := getCurrentUser(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -36,9 +37,10 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var announcements []model.Announcement
|
var announcements []model.Announcement
|
||||||
database.DB.Where("target_type = ?", userType).
|
database.DB.Where(
|
||||||
Order("created_at DESC").
|
"(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?",
|
||||||
Find(&announcements)
|
userType, userID,
|
||||||
|
).Order("created_at DESC").Find(&announcements)
|
||||||
|
|
||||||
// 查询该用户的已读记录
|
// 查询该用户的已读记录
|
||||||
readIDs := make(map[uint]time.Time)
|
readIDs := make(map[uint]time.Time)
|
||||||
@@ -73,7 +75,7 @@ func GetUnreadCount(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var totalCount int64
|
var totalCount int64
|
||||||
database.DB.Model(&model.Announcement{}).
|
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)
|
Count(&totalCount)
|
||||||
|
|
||||||
var readCount int64
|
var readCount int64
|
||||||
@@ -105,8 +107,8 @@ type PendingPopupItem struct {
|
|||||||
|
|
||||||
// MyAnnouncementInfo 我的公告信息聚合
|
// MyAnnouncementInfo 我的公告信息聚合
|
||||||
type MyAnnouncementInfo struct {
|
type MyAnnouncementInfo struct {
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
PopupQueue []PendingPopupItem `json:"popupQueue"` // 需要弹窗的公告(依次弹出)
|
PopupQueue []PendingPopupItem `json:"popupQueue"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMyAnnouncementInfo 获取当前用户的公告聚合信息(未读数 + 待弹窗队列)
|
// GetMyAnnouncementInfo 获取当前用户的公告聚合信息(未读数 + 待弹窗队列)
|
||||||
@@ -116,10 +118,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
|
|||||||
return utils.FailAuth(c, "未认证")
|
return utils.FailAuth(c, "未认证")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 总公告数
|
// 1. 总公告数(包含指定给当前用户的)
|
||||||
var totalCount int64
|
var totalCount int64
|
||||||
database.DB.Model(&model.Announcement{}).
|
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)
|
Count(&totalCount)
|
||||||
|
|
||||||
// 2. 已读数
|
// 2. 已读数
|
||||||
@@ -135,9 +137,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// 3. 待弹窗队列:forcePopup=true 且 (loopPopup=true 或 未读)
|
// 3. 待弹窗队列:forcePopup=true 且 (loopPopup=true 或 未读)
|
||||||
var forceAnnouncements []model.Announcement
|
var forceAnnouncements []model.Announcement
|
||||||
database.DB.Where("target_type = ? AND force_popup = 1", userType).
|
database.DB.Where(
|
||||||
Order("created_at ASC").
|
"force_popup = 1 AND ((target_type = ? AND target_user_id IS NULL) OR target_user_id = ?)",
|
||||||
Find(&forceAnnouncements)
|
userType, userID,
|
||||||
|
).Order("created_at ASC").Find(&forceAnnouncements)
|
||||||
|
|
||||||
// 查已读记录
|
// 查已读记录
|
||||||
readIDs := make(map[uint]bool)
|
readIDs := make(map[uint]bool)
|
||||||
@@ -176,14 +179,17 @@ func MarkAnnouncementRead(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
announcementID := c.Params("id")
|
announcementID := c.Params("id")
|
||||||
|
|
||||||
// 验证公告存在
|
// 验证公告存在且用户有权访问
|
||||||
var announcement model.Announcement
|
var announcement model.Announcement
|
||||||
if err := database.DB.First(&announcement, announcementID).Error; err != nil {
|
if err := database.DB.First(&announcement, announcementID).Error; err != nil {
|
||||||
return utils.Fail(c, 404, "公告不存在")
|
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, "无权操作此公告")
|
return utils.Fail(c, 403, "无权操作此公告")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ type Announcement struct {
|
|||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
Title string `gorm:"type:varchar(255);not null" json:"title"`
|
Title string `gorm:"type:varchar(255);not null" json:"title"`
|
||||||
Content string `gorm:"type:text;not null" json:"content"`
|
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"`
|
ForcePopup bool `gorm:"default:false" json:"forcePopup"`
|
||||||
LoopPopup bool `gorm:"default:false" json:"loopPopup"`
|
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"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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"`
|
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"`
|
LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"`
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import (
|
|||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"crypto/des"
|
"crypto/des"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ========== AES-128-CBC-PKCS7 ==========
|
// ========== AES-128-CBC-PKCS7 ==========
|
||||||
@@ -104,61 +105,26 @@ func NewRSAKeyPairFromPublicKey(base64Key string) (*RSAKeyPair, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("base64 decode error: %w", err)
|
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 格式 (SEQUENCE { INTEGER n, INTEGER e })
|
||||||
// 简化的 PKCS#1 解析
|
pub, err := x509.ParsePKCS1PublicKey(der)
|
||||||
// 实际上应该用 crypto/x509,但为了轻量级,手动解析
|
if err != nil {
|
||||||
// 格式: SEQUENCE { SEQUENCE { OID, NULL }, BIT STRING { SEQUENCE { INTEGER n, INTEGER e } } }
|
// 尝试 PKIX/SubjectPublicKeyInfo 格式
|
||||||
// 或者直接 PKCS#1: SEQUENCE { INTEGER n, INTEGER e }
|
keyAny, err2 := x509.ParsePKIXPublicKey(der)
|
||||||
|
if err2 != nil {
|
||||||
// 找最后的两个 INTEGER
|
return nil, fmt.Errorf("parse DER failed (pkcs1: %w, pkix: %w)", err, err2)
|
||||||
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:]
|
rsaPub, ok := keyAny.(*rsa.PublicKey)
|
||||||
} else {
|
if !ok {
|
||||||
data = data[1:]
|
return nil, fmt.Errorf("not an RSA public key")
|
||||||
|
}
|
||||||
|
pub = rsaPub
|
||||||
}
|
}
|
||||||
if length > len(data) {
|
|
||||||
length = len(data)
|
return &RSAKeyPair{
|
||||||
}
|
n: pub.N,
|
||||||
return data[:length]
|
e: big.NewInt(int64(pub.E)),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RSAKeyPair) Encrypt(plaintext string) (string, error) {
|
func (r *RSAKeyPair) Encrypt(plaintext string) (string, error) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type sessionClient struct {
|
|||||||
func newSessionClient() *sessionClient {
|
func newSessionClient() *sessionClient {
|
||||||
jar := newCookieJar()
|
jar := newCookieJar()
|
||||||
client := resty.New().
|
client := resty.New().
|
||||||
|
SetDoNotParseResponse(true).
|
||||||
SetTimeout(30*time.Second).
|
SetTimeout(30*time.Second).
|
||||||
SetTLSClientConfig(&tls.Config{
|
SetTLSClientConfig(&tls.Config{
|
||||||
InsecureSkipVerify: true,
|
InsecureSkipVerify: true,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||||
sc := newSessionClient()
|
sc := newSessionClient()
|
||||||
loginUrl := getIapValidLoginUrl(school)
|
loginUrl := getIapValidLoginUrl(school)
|
||||||
|
slog.Info("IAP登录", "school", school.Name, "loginUrl", loginUrl, "ampUrl", school.AmpUrl, "wecloudUrl", school.WecloudUrl, "joinType", school.JoinType)
|
||||||
|
|
||||||
// 1. 获取 LT (Login Ticket)
|
// 1. 获取 LT (Login Ticket)
|
||||||
lt, err := getIapLT(ctx, loginUrl, sc)
|
lt, err := getIapLT(ctx, loginUrl, sc)
|
||||||
@@ -34,6 +35,7 @@ func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("获取真实LT失败: %w", err)
|
return nil, fmt.Errorf("获取真实LT失败: %w", err)
|
||||||
}
|
}
|
||||||
|
slog.Info("IAP登录凭证", "lt", lt, "realLT", realLT)
|
||||||
|
|
||||||
// 3. RSA 加密密码
|
// 3. RSA 加密密码
|
||||||
rsa, err := NewRSAKeyPairFromPublicKey(IapRsaPubKey)
|
rsa, err := NewRSAKeyPairFromPublicKey(IapRsaPubKey)
|
||||||
@@ -62,27 +64,53 @@ func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
|||||||
"password": "{rsa}" + encPwd,
|
"password": "{rsa}" + encPwd,
|
||||||
}
|
}
|
||||||
|
|
||||||
submitUrls := []string{
|
|
||||||
loginUrl + "/iap/doLogin",
|
|
||||||
loginUrl + "/doLogin",
|
|
||||||
}
|
|
||||||
|
|
||||||
var loginResp *resty.Response
|
var loginResp *resty.Response
|
||||||
var lastErr error
|
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().
|
loginResp, lastErr = sc.R().
|
||||||
SetFormData(submitData).
|
SetFormData(submitData).
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
Post(submitUrl)
|
Post(submitUrl)
|
||||||
if lastErr == nil {
|
statusCode = 0
|
||||||
break
|
if loginResp != nil {
|
||||||
|
statusCode = loginResp.StatusCode()
|
||||||
}
|
}
|
||||||
|
slog.Info("IAP登录第二尝试", "url", submitUrl, "lastErr", lastErr, "status", statusCode)
|
||||||
}
|
}
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
return nil, fmt.Errorf("提交IAP登录失败: %w", lastErr)
|
return nil, fmt.Errorf("提交IAP登录失败: %w", lastErr)
|
||||||
}
|
}
|
||||||
defer loginResp.RawBody().Close()
|
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. 处理响应
|
// 6. 处理响应
|
||||||
switch loginResp.StatusCode() {
|
switch loginResp.StatusCode() {
|
||||||
case 302:
|
case 302:
|
||||||
@@ -200,6 +228,7 @@ func handleIapLoginSuccess(ctx context.Context, resp *resty.Response, school *Sc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("获取IAP重定向地址失败: %w", err)
|
return nil, fmt.Errorf("获取IAP重定向地址失败: %w", err)
|
||||||
}
|
}
|
||||||
|
slog.Info("IAP重定向", "jumpUrl", jumpUrl.String())
|
||||||
|
|
||||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||||
verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String())
|
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)
|
return nil, fmt.Errorf("IAP跟随重定向失败: %w", err)
|
||||||
}
|
}
|
||||||
defer verifyResp.RawBody().Close()
|
defer verifyResp.RawBody().Close()
|
||||||
|
slog.Info("IAP重定向验证", "status", verifyResp.StatusCode(), "finalUrl", verifyResp.RawResponse.Request.URL.String())
|
||||||
|
|
||||||
activateSession(ctx, school.WecloudUrl, sc.Client)
|
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||||||
return sc.jar.AllCookies(), nil
|
return sc.jar.AllCookies(), nil
|
||||||
|
|||||||
@@ -143,10 +143,15 @@ func GetSchoolDetail(tenantID string) (*SchoolDetailInfo, error) {
|
|||||||
return &detail, nil
|
return &detail, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 硬编码走 IAP 登录的学校 ID 列表(尽管 joinType 不是 cloud,但实际支持 IAP)
|
||||||
|
var iapSchoolIds = map[string]bool{
|
||||||
|
"01e270d1-00e2-4ce9-b0af-5d5fbd462b8a": true, // 河北石油职业技术大学
|
||||||
|
}
|
||||||
|
|
||||||
// CpdailyLogin 执行 cpdaily 登录
|
// CpdailyLogin 执行 cpdaily 登录
|
||||||
// 返回登录成功后的所有 cookies
|
// 返回登录成功后的所有 cookies
|
||||||
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
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 iapLogin(ctx, school, username, password)
|
||||||
}
|
}
|
||||||
return casLogin(ctx, school, username, password)
|
return casLogin(ctx, school, username, password)
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ export interface Announcement {
|
|||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
|
source: string
|
||||||
targetType: 'agent' | 'user'
|
targetType: 'agent' | 'user'
|
||||||
|
targetUserId: number | null
|
||||||
forcePopup: boolean
|
forcePopup: boolean
|
||||||
loopPopup: boolean
|
loopPopup: boolean
|
||||||
createdBy: number
|
createdBy: number
|
||||||
@@ -201,12 +203,14 @@ export interface AnnouncementListItem extends Announcement {
|
|||||||
readCount: number
|
readCount: number
|
||||||
unreadCount: number
|
unreadCount: number
|
||||||
totalTargets: number
|
totalTargets: number
|
||||||
|
targetUserName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAnnouncementParams {
|
export interface CreateAnnouncementParams {
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
targetType: 'agent' | 'user'
|
targetType: 'agent' | 'user'
|
||||||
|
targetUserId?: number
|
||||||
forcePopup: boolean
|
forcePopup: boolean
|
||||||
loopPopup: boolean
|
loopPopup: boolean
|
||||||
}
|
}
|
||||||
@@ -241,3 +245,67 @@ export function getAnnouncementDetail(id: number) {
|
|||||||
export function deleteAnnouncement(id: number) {
|
export function deleteAnnouncement(id: number) {
|
||||||
return api.delete(`/admin/announcements/${id}`)
|
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 AdminTicketList from '@/views/admin/AdminTicketList.vue'
|
||||||
import AdminTicketDetail from '@/views/admin/AdminTicketDetail.vue'
|
import AdminTicketDetail from '@/views/admin/AdminTicketDetail.vue'
|
||||||
import AdminPendingSchools from '@/views/admin/AdminPendingSchools.vue'
|
import AdminPendingSchools from '@/views/admin/AdminPendingSchools.vue'
|
||||||
|
import AdminUsers from '@/views/admin/AdminUsers.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -61,6 +62,11 @@ const router = createRouter({
|
|||||||
name: 'admin-cdks',
|
name: 'admin-cdks',
|
||||||
component: AdminCdks,
|
component: AdminCdks,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'users',
|
||||||
|
name: 'admin-users',
|
||||||
|
component: AdminUsers,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'admin-settings',
|
name: 'admin-settings',
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { useMessage } from '@/composables/useMessage'
|
import { useMessage } from '@/composables/useMessage'
|
||||||
import * as adminApi from '@/api/admin'
|
import * as adminApi from '@/api/admin'
|
||||||
|
|
||||||
const message = useMessage()
|
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 loading = ref(true)
|
||||||
const showCreateModal = ref(false)
|
const showCreateModal = ref(false)
|
||||||
|
|
||||||
@@ -16,6 +24,8 @@ const showCreateModal = ref(false)
|
|||||||
const formTitle = ref('')
|
const formTitle = ref('')
|
||||||
const formContent = ref('')
|
const formContent = ref('')
|
||||||
const formTargetType = ref<'agent' | 'user'>('user')
|
const formTargetType = ref<'agent' | 'user'>('user')
|
||||||
|
const formTargetMode = ref<'all' | 'specific'>('all') // 推送范围:全部 / 指定用户
|
||||||
|
const formTargetUserId = ref('') // 指定用户ID
|
||||||
const formForcePopup = ref(false)
|
const formForcePopup = ref(false)
|
||||||
const formLoopPopup = ref(false)
|
const formLoopPopup = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
@@ -40,6 +50,8 @@ function openCreate() {
|
|||||||
formTitle.value = ''
|
formTitle.value = ''
|
||||||
formContent.value = ''
|
formContent.value = ''
|
||||||
formTargetType.value = 'user'
|
formTargetType.value = 'user'
|
||||||
|
formTargetMode.value = 'all'
|
||||||
|
formTargetUserId.value = ''
|
||||||
formForcePopup.value = false
|
formForcePopup.value = false
|
||||||
formLoopPopup.value = false
|
formLoopPopup.value = false
|
||||||
showCreateModal.value = true
|
showCreateModal.value = true
|
||||||
@@ -55,12 +67,27 @@ async function handleCreate() {
|
|||||||
return
|
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
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
await adminApi.createAnnouncement({
|
await adminApi.createAnnouncement({
|
||||||
title: formTitle.value.trim(),
|
title: formTitle.value.trim(),
|
||||||
content: formContent.value.trim(),
|
content: formContent.value.trim(),
|
||||||
targetType: formTargetType.value,
|
targetType: formTargetType.value,
|
||||||
|
targetUserId: targetUserId,
|
||||||
forcePopup: formForcePopup.value,
|
forcePopup: formForcePopup.value,
|
||||||
loopPopup: formLoopPopup.value,
|
loopPopup: formLoopPopup.value,
|
||||||
})
|
})
|
||||||
@@ -75,6 +102,11 @@ async function handleCreate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
|
const item = announcements.value.find(a => a.id === id)
|
||||||
|
if (item?.source === 'system') {
|
||||||
|
message.info('系统自动生成的公告不能删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!confirm('确定要删除此公告吗?')) return
|
if (!confirm('确定要删除此公告吗?')) return
|
||||||
try {
|
try {
|
||||||
await adminApi.deleteAnnouncement(id)
|
await adminApi.deleteAnnouncement(id)
|
||||||
@@ -88,6 +120,17 @@ async function handleDelete(id: number) {
|
|||||||
function targetLabel(t: string) {
|
function targetLabel(t: string) {
|
||||||
return t === 'agent' ? '代理' : '普通用户'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -126,14 +169,15 @@ function targetLabel(t: string) {
|
|||||||
<table v-else class="w-full text-sm table-fixed">
|
<table v-else class="w-full text-sm table-fixed">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
<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-[16%]">标题</th>
|
||||||
<th class="px-4 py-3 font-medium w-[28%]">内容</th>
|
<th class="px-4 py-3 font-medium w-[22%]">内容</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-[12%]">推送范围</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-[14%]">阅读人数</th>
|
<th class="px-4 py-3 font-medium w-[6%]">循环弹窗</th>
|
||||||
<th class="px-4 py-3 font-medium w-[18%]">创建时间</th>
|
<th class="px-4 py-3 font-medium w-[12%]">阅读人数</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 text-center w-[8%]">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -150,13 +194,24 @@ function targetLabel(t: string) {
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<p class="text-gray-500 truncate block">{{ item.content }}</p>
|
<p class="text-gray-500 truncate block">{{ item.content }}</p>
|
||||||
</td>
|
</td>
|
||||||
<!-- 推送对象 -->
|
<!-- 来源 -->
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
|
||||||
:class="item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'"
|
: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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<!-- 强制弹窗 -->
|
<!-- 强制弹窗 -->
|
||||||
@@ -195,12 +250,14 @@ function targetLabel(t: string) {
|
|||||||
<!-- 操作 -->
|
<!-- 操作 -->
|
||||||
<td class="px-4 py-3 text-center whitespace-nowrap">
|
<td class="px-4 py-3 text-center whitespace-nowrap">
|
||||||
<button
|
<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"
|
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)"
|
@click="handleDelete(item.id)"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
|
<span v-else class="text-xs text-gray-300">--</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -238,7 +295,7 @@ function targetLabel(t: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||||
@@ -253,6 +310,40 @@ function targetLabel(t: string) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Circle,
|
Circle,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
School,
|
School,
|
||||||
|
UserCheck,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useMessage } from '@/composables/useMessage'
|
import { useMessage } from '@/composables/useMessage'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -35,6 +36,7 @@ const showSidebar = ref(false)
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: 'admin-dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin/dashboard' },
|
{ name: 'admin-dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin/dashboard' },
|
||||||
{ name: 'admin-agents', label: '代理管理', icon: Users, path: '/admin/agents' },
|
{ 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-cdks', label: 'CDK管理', icon: QrCode, path: '/admin/cdks' },
|
||||||
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements' },
|
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements' },
|
||||||
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets' },
|
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -13,6 +13,8 @@ const message = useMessage()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const baseDeduction = ref('')
|
const baseDeduction = ref('')
|
||||||
|
const inviterReward = ref('')
|
||||||
|
const inviteeReward = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSettings()
|
await loadSettings()
|
||||||
@@ -24,6 +26,8 @@ async function loadSettings() {
|
|||||||
const res = await adminApi.getSettings()
|
const res = await adminApi.getSettings()
|
||||||
const settings = res.data?.data || {}
|
const settings = res.data?.data || {}
|
||||||
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
|
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 {
|
} catch {
|
||||||
message.error('加载设置失败')
|
message.error('加载设置失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -32,14 +36,27 @@ async function loadSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const val = parseFloat(baseDeduction.value)
|
const deduction = parseFloat(baseDeduction.value)
|
||||||
if (isNaN(val) || val <= 0) {
|
if (isNaN(deduction) || deduction <= 0) {
|
||||||
message.info('请输入有效的正数')
|
message.info('请输入有效的正数')
|
||||||
return
|
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
|
saving.value = true
|
||||||
try {
|
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('保存成功')
|
message.success('保存成功')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.response?.data?.message || e?.message || '保存失败')
|
message.error(e?.response?.data?.message || e?.message || '保存失败')
|
||||||
@@ -87,5 +104,57 @@ async function handleSave() {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</template>
|
</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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
<div v-else class="mt-1">
|
<div v-else class="mt-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user