Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc4aa1c00e | |||
| 15eceea38a | |||
| a9dbc2661d | |||
| ebc94ec9a0 | |||
| 98cf8666f6 | |||
| ff3593f057 | |||
| 74422b1848 | |||
| 84f87cd168 | |||
| 386e9269bf | |||
| 17a71b1e2a | |||
| 1b4f029602 | |||
| 2246752724 | |||
| a303782ab4 | |||
| 2cac0cdfee | |||
| 75d334680b | |||
| 35fa5a6560 | |||
| dbd3c88d32 | |||
| 33e29573ca | |||
| 4a80382b60 | |||
| 8eaaf5781a | |||
| 111cc0439c | |||
| a97537660e | |||
| 664d8a2594 | |||
| 6ede88b01b | |||
| 6b42cff492 |
@@ -77,6 +77,7 @@ steps:
|
||||
docker run -d --name cpdaily-xiaozhushou-new \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-p $PORT_STANDBY:8093 \
|
||||
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
@@ -126,6 +127,7 @@ steps:
|
||||
docker run -d --name cpdaily-xiaozhushou \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-p $PORT_ACTIVE:8093 \
|
||||
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
|
||||
@@ -24,6 +24,8 @@ RUN go build -ldflags="-s -w" -o server ./cmd/server/
|
||||
# ============================================================
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
# 时区固定为北京时间(tzdata 已安装;进程层 main.go 另有 time.Local 兜底)
|
||||
ENV TZ=Asia/Shanghai
|
||||
COPY --from=backend-builder /build/server /app/server
|
||||
COPY --from=frontend-builder /build/dist /app/dist
|
||||
WORKDIR /app
|
||||
|
||||
@@ -23,6 +23,16 @@ func main() {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 全局时区固定为中国标准时间(必须在 database.Init 之前设置:
|
||||
// DSN 的 loc=Local 跟随 time.Local,影响数据库 datetime 读写序列化)。
|
||||
// 运行镜像若不设 TZ 默认 UTC,会让所有记录/查询/统计/输出偏移 8 小时
|
||||
// (前端展示为"美国时间"),每月 1 日与每日 0-8 点的边界计算也会错位。
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
log.Fatalf("加载时区 Asia/Shanghai 失败: %v", err)
|
||||
}
|
||||
time.Local = loc
|
||||
|
||||
// 连接数据库
|
||||
database.Init()
|
||||
|
||||
@@ -58,6 +68,7 @@ func main() {
|
||||
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
|
||||
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
|
||||
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
// 签到任务相关
|
||||
task := api.Group("/task", middleware.AuthRequired())
|
||||
@@ -78,12 +89,14 @@ func main() {
|
||||
// CDK 兑换
|
||||
cdk := api.Group("/cdk", middleware.AuthRequired())
|
||||
cdk.Post("/redeem", handler.UserRedeemCDK)
|
||||
cdk.Get("/vip-info", handler.GetUserVipInfo)
|
||||
|
||||
// 设备绑定
|
||||
device := api.Group("/device")
|
||||
device.Post("/generate", middleware.AuthRequired(), handler.GenerateDeviceQr)
|
||||
device.Post("/submit", handler.SubmitDeviceCode)
|
||||
device.Get("/status", middleware.AuthRequired(), handler.GetDeviceStatus)
|
||||
device.Post("/random-bind", middleware.AuthRequired(), handler.RandomBindDevice)
|
||||
|
||||
// 管理后台 API
|
||||
admin := api.Group("/admin")
|
||||
@@ -112,6 +125,8 @@ func main() {
|
||||
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.Get("/task-logs", middleware.AdminOnly(), handler.AdminListTaskLogs)
|
||||
|
||||
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /my-announcements 收取公告)
|
||||
adminAuth.Post("/announcements", middleware.AdminOnly(), handler.CreateAnnouncement)
|
||||
|
||||
@@ -25,6 +25,8 @@ type SysUserListItem struct {
|
||||
SignCount int `json:"signCount"`
|
||||
InviteReward float64 `json:"inviteReward"`
|
||||
InvitedCount int `json:"invitedCount"`
|
||||
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
|
||||
InviterName string `json:"inviterName"` // 邀请人展示名(姓名优先,其次用户名)
|
||||
TaskCount int64 `json:"taskCount"`
|
||||
ExecutedCount int64 `json:"executedCount"` // 累计成功执行次数
|
||||
TodayStatus string `json:"todayStatus"` // today: success / fail / none
|
||||
@@ -33,6 +35,14 @@ type SysUserListItem struct {
|
||||
LastLoginAt string `json:"lastLoginAt"`
|
||||
}
|
||||
|
||||
// inviterDisplay 邀请人展示名:姓名优先,其次用户名
|
||||
func inviterDisplay(username, realName string) string {
|
||||
if realName != "" {
|
||||
return realName
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
// ListUsers 获取用户列表(仅admin)
|
||||
func ListUsers(c *fiber.Ctx) error {
|
||||
// 分页
|
||||
@@ -130,6 +140,26 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请人信息(本页用户的 inviter_id 去重后一次查询)
|
||||
inviterNameMap := make(map[uint]string)
|
||||
if len(users) > 0 {
|
||||
inviterIDs := make([]uint, 0, len(users))
|
||||
seen := make(map[uint]bool)
|
||||
for _, u := range users {
|
||||
if u.InviterID != nil && !seen[*u.InviterID] {
|
||||
seen[*u.InviterID] = true
|
||||
inviterIDs = append(inviterIDs, *u.InviterID)
|
||||
}
|
||||
}
|
||||
if len(inviterIDs) > 0 {
|
||||
var inviters []model.SysUser
|
||||
database.DB.Select("id, username, real_name").Where("id IN ?", inviterIDs).Find(&inviters)
|
||||
for _, iv := range inviters {
|
||||
inviterNameMap[iv.ID] = inviterDisplay(iv.Username, iv.RealName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]SysUserListItem, 0, len(users))
|
||||
for _, u := range users {
|
||||
lastLogin := ""
|
||||
@@ -140,6 +170,10 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
if todayStatus == "" {
|
||||
todayStatus = "none"
|
||||
}
|
||||
inviterName := ""
|
||||
if u.InviterID != nil {
|
||||
inviterName = inviterNameMap[*u.InviterID]
|
||||
}
|
||||
items = append(items, SysUserListItem{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
@@ -152,6 +186,8 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
SignCount: u.SignCount,
|
||||
InviteReward: u.InviteReward,
|
||||
InvitedCount: u.InvitedCount,
|
||||
InviterID: u.InviterID,
|
||||
InviterName: inviterName,
|
||||
TaskCount: taskCountMap[u.ID],
|
||||
ExecutedCount: executedMap[u.ID],
|
||||
TodayStatus: todayStatus,
|
||||
@@ -197,11 +233,14 @@ func GetUserDetail(c *fiber.Ctx) error {
|
||||
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 来知道邀请人数
|
||||
// 邀请人信息(被谁邀请)
|
||||
var inviterName string
|
||||
if user.InviterID != nil {
|
||||
var inviter model.SysUser
|
||||
if err := database.DB.Select("id, username, real_name").First(&inviter, *user.InviterID).Error; err == nil {
|
||||
inviterName = inviterDisplay(inviter.Username, inviter.RealName)
|
||||
}
|
||||
}
|
||||
|
||||
lastLogin := ""
|
||||
if user.LastLoginAt != nil {
|
||||
@@ -228,6 +267,8 @@ func GetUserDetail(c *fiber.Ctx) error {
|
||||
"signCount": user.SignCount,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"inviterId": user.InviterID,
|
||||
"inviterName": inviterName,
|
||||
"status": user.Status,
|
||||
"disabledReason": user.DisabledReason,
|
||||
"taskCount": taskCount,
|
||||
@@ -281,3 +322,81 @@ func UpdateUserStatus(c *fiber.Ctx) error {
|
||||
"status": req.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminTaskLogItem 任务执行日志列表项(后台查看用)
|
||||
type AdminTaskLogItem struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
TaskType string `json:"taskType"`
|
||||
Title string `json:"title"`
|
||||
RealTitle string `json:"realTitle"`
|
||||
IsSuccess bool `json:"isSuccess"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// AdminListTaskLogs 任务执行日志列表(仅admin,供"当天执行总次数"卡片点击查看)
|
||||
// 支持按日期(默认当天)、执行状态、关键字(用户名/姓名/任务标题)筛选,分页返回
|
||||
func AdminListTaskLogs(c *fiber.Ctx) error {
|
||||
// 日期(yyyy-MM-dd,默认当天)
|
||||
date := strings.TrimSpace(c.Query("date", ""))
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||
if err != nil {
|
||||
return utils.Fail(c, 400, "日期格式错误,应为 yyyy-MM-dd")
|
||||
}
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
// 执行状态筛选:all / success / fail
|
||||
status := c.Query("status", "all")
|
||||
if status != "all" && status != "success" && status != "fail" {
|
||||
status = "all"
|
||||
}
|
||||
keyword := strings.TrimSpace(c.Query("keyword", ""))
|
||||
|
||||
// 分页
|
||||
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
|
||||
}
|
||||
|
||||
query := database.DB.Table("cp_task_logs").
|
||||
Joins("LEFT JOIN sys_user ON sys_user.id = cp_task_logs.user_id").
|
||||
Where("cp_task_logs.created_at >= ? AND cp_task_logs.created_at < ?", dayStart, dayEnd)
|
||||
if status == "success" {
|
||||
query = query.Where("cp_task_logs.is_success = 1")
|
||||
} else if status == "fail" {
|
||||
query = query.Where("cp_task_logs.is_success = 0")
|
||||
}
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("sys_user.username LIKE ? OR sys_user.real_name LIKE ? OR cp_task_logs.title LIKE ? OR cp_task_logs.real_title LIKE ?",
|
||||
like, like, like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []AdminTaskLogItem
|
||||
query.Select("cp_task_logs.id, cp_task_logs.user_id, sys_user.username, sys_user.real_name, sys_user.school_name, cp_task_logs.task_type, cp_task_logs.title, cp_task_logs.real_title, cp_task_logs.is_success, cp_task_logs.error_message, cp_task_logs.created_at").
|
||||
Order("cp_task_logs.created_at DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Scan(&items)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -17,15 +18,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetCaptcha 获取图片验证码(同 IP 10 秒内最多生成一次,防刷屏打爆资源)
|
||||
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
|
||||
func GetCaptcha(c *fiber.Ctx) error {
|
||||
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
|
||||
ip := c.IP()
|
||||
if ip == "" {
|
||||
ip = c.Context().RemoteIP().String()
|
||||
}
|
||||
if !service.AllowCaptchaGen(ip) {
|
||||
return utils.Fail(c, 429, "验证码获取过于频繁,请 10 秒后重试")
|
||||
ok, wait := service.AllowCaptchaToken(ip)
|
||||
if !ok {
|
||||
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
|
||||
}
|
||||
result, err := service.GenerateCaptcha()
|
||||
if err != nil {
|
||||
@@ -221,6 +223,19 @@ func GetUserInfo(c *fiber.Ctx) error {
|
||||
return utils.Success(c, user)
|
||||
}
|
||||
|
||||
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
|
||||
const GuideCompleteVersion = "v2"
|
||||
|
||||
// CompleteGuide 记录用户已完成当前版本新手引导
|
||||
func CompleteGuide(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("guide_version", GuideCompleteVersion).Error; err != nil {
|
||||
return utils.FailServer(c, "记录失败,请稍后重试")
|
||||
}
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库
|
||||
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
|
||||
cookieMap := cpd.ParseCookieJSON(cookieStr)
|
||||
@@ -394,6 +409,9 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
return nil
|
||||
}
|
||||
|
||||
// 记录邀请关系(后台用户管理可展示"被谁邀请")
|
||||
tx.Model(&user).Update("inviter_id", inviter.ID)
|
||||
|
||||
// 给邀请人发放奖励积分 + 增加邀请计数
|
||||
if inviterReward > 0 {
|
||||
tx.Model(&inviter).Updates(map[string]interface{}{
|
||||
|
||||
+151
-26
@@ -19,8 +19,10 @@ import (
|
||||
|
||||
// GenerateCDKRequest 生成CDK请求
|
||||
type GenerateCDKRequest struct {
|
||||
Count int `json:"count"` // 生成数量,默认1
|
||||
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数,默认1
|
||||
Count int `json:"count"` // 生成数量,默认1
|
||||
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数(type=count),默认1
|
||||
Type string `json:"type"` // CDK类型:count=执行次数(默认)/ vip=VIP时长
|
||||
VipDays int `json:"vipDays"` // VIP天数(type=vip),默认30(一个月)
|
||||
}
|
||||
|
||||
// 获取基础扣费(从系统设置或配置)
|
||||
@@ -36,6 +38,22 @@ func getBaseDeduction() float64 {
|
||||
return config.Global.Agent.SystemCdkPrice
|
||||
}
|
||||
|
||||
// getVipCardPrice 获取 VIP 卡价格(元/张,系统设置)
|
||||
// key: vip_price_week(周卡7天,默认30元)/ vip_price_month(月卡30天,默认100元)
|
||||
// (单次运行基础扣费 6 元,周卡/月卡按次折算后再给折扣)
|
||||
func getVipCardPrice(key string, def float64) float64 {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
|
||||
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
|
||||
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
|
||||
|
||||
// GenerateCDK 生成CDK
|
||||
func GenerateCDK(c *fiber.Ctx) error {
|
||||
agentID := c.Locals("adminId").(uint)
|
||||
@@ -44,32 +62,70 @@ func GenerateCDK(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
// CDK 类型校验(默认次数)
|
||||
if req.Type == "" {
|
||||
req.Type = "count"
|
||||
}
|
||||
if req.Type != "count" && req.Type != "vip" {
|
||||
return utils.Fail(c, 400, "CDK类型无效,必须为 count 或 vip")
|
||||
}
|
||||
if req.Count <= 0 {
|
||||
req.Count = 1
|
||||
}
|
||||
if req.Count > 100 {
|
||||
return utils.Fail(c, 400, "单次最多生成100个CDK")
|
||||
}
|
||||
if req.MaxRuns <= 0 {
|
||||
req.MaxRuns = 1
|
||||
}
|
||||
if req.MaxRuns > 999 {
|
||||
return utils.Fail(c, 400, "每张CDK最多999次")
|
||||
}
|
||||
|
||||
// 获取代理信息
|
||||
var agent model.AgentUser
|
||||
if err := database.DB.First(&agent, agentID).Error; err != nil {
|
||||
return utils.Fail(c, 404, "账号不存在")
|
||||
}
|
||||
|
||||
if agent.Status == "disabled" {
|
||||
return utils.Fail(c, 403, "账号已被禁用")
|
||||
}
|
||||
|
||||
baseDeduction := getBaseDeduction()
|
||||
cdkPrice := baseDeduction * float64(req.MaxRuns) * agent.Rate
|
||||
costTotal := baseDeduction * float64(req.MaxRuns) * agent.Rate * float64(req.Count)
|
||||
|
||||
var maxRuns int
|
||||
var vipDays int
|
||||
var unitPrice float64 // 单张CDK代理扣费
|
||||
var costPerCDK float64
|
||||
|
||||
if req.Type == "vip" {
|
||||
// VIP CDK:仅支持周卡(7天)/ 月卡(30天)两档
|
||||
if req.VipDays <= 0 {
|
||||
req.VipDays = 30
|
||||
}
|
||||
if req.VipDays != 7 && req.VipDays != 30 {
|
||||
return utils.Fail(c, 400, "VIP CDK 仅支持 7 天(周卡)或 30 天(月卡)")
|
||||
}
|
||||
maxRuns = 0
|
||||
vipDays = req.VipDays
|
||||
var vipPrice float64
|
||||
if req.VipDays == 7 {
|
||||
vipPrice = getVipWeekPrice()
|
||||
} else {
|
||||
vipPrice = getVipMonthPrice()
|
||||
}
|
||||
unitPrice = vipPrice * agent.Rate
|
||||
costPerCDK = vipPrice
|
||||
} else {
|
||||
// 次数 CDK
|
||||
if req.MaxRuns <= 0 {
|
||||
req.MaxRuns = 1
|
||||
}
|
||||
if req.MaxRuns > 999 {
|
||||
return utils.Fail(c, 400, "每张CDK最多999次")
|
||||
}
|
||||
maxRuns = req.MaxRuns
|
||||
vipDays = 0
|
||||
unitPrice = baseDeduction * float64(req.MaxRuns) * agent.Rate
|
||||
costPerCDK = baseDeduction * float64(req.MaxRuns)
|
||||
}
|
||||
|
||||
cdkPrice := unitPrice
|
||||
costTotal := unitPrice * float64(req.Count)
|
||||
|
||||
// 事务:扣余额 + 生成CDK
|
||||
cdkCodes := make([]model.CDKCode, 0, req.Count)
|
||||
@@ -90,9 +146,11 @@ func GenerateCDK(c *fiber.Ctx) error {
|
||||
cdk := model.CDKCode{
|
||||
Code: code,
|
||||
Price: cdkPrice,
|
||||
CostPrice: baseDeduction * float64(req.MaxRuns),
|
||||
CostPrice: costPerCDK,
|
||||
AgentID: agentID,
|
||||
MaxRuns: req.MaxRuns,
|
||||
MaxRuns: maxRuns,
|
||||
Type: req.Type,
|
||||
VipDays: vipDays,
|
||||
Status: "unused",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -311,6 +369,9 @@ func UserRedeemCDK(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 使用行锁(悲观锁)确保并发安全
|
||||
var vipExpireAt time.Time
|
||||
redeemType := cdk.Type
|
||||
redeemRuns := cdk.MaxRuns
|
||||
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 重新查询并锁定该行
|
||||
var lockedCDK model.CDKCode
|
||||
@@ -320,28 +381,54 @@ func UserRedeemCDK(c *fiber.Ctx) error {
|
||||
if lockedCDK.Status != "unused" {
|
||||
return errors.New("CDK码已被使用")
|
||||
}
|
||||
if lockedCDK.MaxRuns <= 0 {
|
||||
return errors.New("CDK码已无可运行次数")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// 一次性消耗全部次数
|
||||
totalRuns := lockedCDK.MaxRuns
|
||||
updates := map[string]interface{}{
|
||||
"status": "used",
|
||||
"user_id": userID,
|
||||
"used_at": now,
|
||||
}
|
||||
|
||||
if lockedCDK.Type == "vip" {
|
||||
// ===== VIP CDK:延长 VIP 到期时间 =====
|
||||
if lockedCDK.VipDays <= 0 {
|
||||
return errors.New("VIP CDK配置无效")
|
||||
}
|
||||
// 锁定用户行,防止并发兑换导致到期时间计算错误
|
||||
var lockedUser model.SysUser
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&lockedUser, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 已开通且未过期:在原到期时间上顺延;否则从当前时间起算
|
||||
base := now
|
||||
if lockedUser.VipExpireAt != nil && lockedUser.VipExpireAt.After(base) {
|
||||
base = *lockedUser.VipExpireAt
|
||||
}
|
||||
vipExpireAt = base.AddDate(0, 0, lockedCDK.VipDays)
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("vip_expire_at", vipExpireAt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// ===== 次数 CDK:一次性消耗全部次数 =====
|
||||
if lockedCDK.MaxRuns <= 0 {
|
||||
return errors.New("CDK码已无可运行次数")
|
||||
}
|
||||
redeemRuns = lockedCDK.MaxRuns
|
||||
if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 增加用户签到次数(一次性加上所有剩余次数)
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count + ?", redeemRuns)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 增加用户签到次数(一次性加上所有剩余次数)
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count + ?", totalRuns)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -352,15 +439,30 @@ func UserRedeemCDK(c *fiber.Ctx) error {
|
||||
if err.Error() == "CDK码已无可运行次数" {
|
||||
return utils.Fail(c, 400, "CDK码已无可运行次数")
|
||||
}
|
||||
if err.Error() == "VIP CDK配置无效" {
|
||||
return utils.Fail(c, 400, "VIP CDK配置无效,请联系管理员")
|
||||
}
|
||||
slog.Error("兑换CDK失败", "error", err)
|
||||
return utils.FailServer(c, "兑换失败")
|
||||
}
|
||||
|
||||
// 按 CDK 类型返回不同提示
|
||||
if redeemType == "vip" {
|
||||
slog.Info("VIP CDK兑换成功", "userId", userID, "code", req.Code, "vipDays", cdk.VipDays, "newExpireAt", vipExpireAt.Format("2006-01-02 15:04:05"))
|
||||
return utils.Success(c, fiber.Map{
|
||||
"message": "VIP兑换成功",
|
||||
"type": "vip",
|
||||
"vipDays": cdk.VipDays,
|
||||
"vipExpireAt": vipExpireAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("CDK兑换成功", "userId", userID, "code", req.Code)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"message": "兑换成功",
|
||||
"totalRuns": cdk.MaxRuns,
|
||||
"type": "count",
|
||||
"totalRuns": redeemRuns,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -379,3 +481,26 @@ func generateCDKCode() string {
|
||||
func formatFloat(f float64) string {
|
||||
return utils.FormatFloat(f)
|
||||
}
|
||||
|
||||
// GetUserVipInfo 获取当前用户 VIP 信息(到期时间 + 售价,供个人中心展示)
|
||||
func GetUserVipInfo(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
var user model.SysUser
|
||||
if err := database.DB.Select("id, vip_expire_at").First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
|
||||
vipExpireAt := ""
|
||||
if user.VipExpireAt != nil {
|
||||
vipExpireAt = user.VipExpireAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"isVip": isVip,
|
||||
"vipExpireAt": vipExpireAt,
|
||||
"vipWeekPrice": formatFloat(getVipWeekPrice()),
|
||||
"vipMonthPrice": formatFloat(getVipMonthPrice()),
|
||||
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,6 +137,32 @@ func SubmitDeviceCode(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"status": "bound"})
|
||||
}
|
||||
|
||||
// RandomBindDevice 随机生成设备码并绑定到当前用户(个人中心「随机」按钮)
|
||||
// 风险:与真实手机绑定的设备码不一致,执行签到时现有手机会提示「频繁切换设备」导致无法签到,
|
||||
// 前端已做连续两次确认;此处不再重复校验,直接生成 UUID(大写,与真实设备码格式一致)绑定。
|
||||
func RandomBindDevice(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return utils.FailServer(c, "生成设备码失败")
|
||||
}
|
||||
// 标准 UUID v4 格式(大写),与真实设备码样式一致
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
deviceCode := fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
|
||||
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"mobile_code": deviceCode,
|
||||
"mobile_type": "manual",
|
||||
}).Error; err != nil {
|
||||
return utils.FailServer(c, "绑定失败")
|
||||
}
|
||||
|
||||
slog.Info("随机绑定设备码", "userId", userID, "deviceCode", deviceCode)
|
||||
return utils.Success(c, fiber.Map{"deviceCode": deviceCode})
|
||||
}
|
||||
|
||||
// ClosePage 授权完成页(当 WebView 无法关闭时跳转至此)
|
||||
func ClosePage(c *fiber.Ctx) error {
|
||||
c.Type("html")
|
||||
|
||||
@@ -196,7 +196,9 @@ func ReportFailedLogin(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 1. 检查该校是否有今天登录过的账号(说明学校可正常使用,忽略本次报告)
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
// 注意:Truncate(24h) 按 UTC 午夜截断会偏移 8 小时,必须按北京时间当日 0 点
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todayLoginCount int64
|
||||
database.DB.Model(&model.SysUser{}).
|
||||
Where("school_id = ? AND last_login_at >= ?", req.SchoolID, today).
|
||||
|
||||
@@ -34,6 +34,12 @@ func GetSettings(c *fiber.Ctx) error {
|
||||
if _, ok := settings["register_reward"]; !ok {
|
||||
settings["register_reward"] = "1.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_week"]; !ok {
|
||||
settings["vip_price_week"] = "30.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_month"]; !ok {
|
||||
settings["vip_price_month"] = "100.00"
|
||||
}
|
||||
|
||||
return utils.Success(c, settings)
|
||||
}
|
||||
@@ -60,15 +66,17 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
"inviter_reward_per_invite": true,
|
||||
"invitee_reward_per_invite": true,
|
||||
"register_reward": true,
|
||||
"vip_price_week": true,
|
||||
"vip_price_month": true,
|
||||
// 邮件通知(SMTP)
|
||||
"smtp_host": true,
|
||||
"smtp_port": true,
|
||||
"smtp_user": true,
|
||||
"smtp_password": true,
|
||||
"smtp_from": true,
|
||||
"smtp_host": true,
|
||||
"smtp_port": true,
|
||||
"smtp_user": true,
|
||||
"smtp_password": true,
|
||||
"smtp_from": true,
|
||||
"smtp_from_name": true,
|
||||
"smtp_secure": true,
|
||||
"admin_emails": true,
|
||||
"smtp_secure": true,
|
||||
"admin_emails": true,
|
||||
}
|
||||
if !allowedKeys[req.Key] {
|
||||
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
|
||||
|
||||
@@ -393,8 +393,11 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
database.DB.Create(&logEntry)
|
||||
}
|
||||
|
||||
// 检查可用次数
|
||||
if user.SignCount <= 0 {
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 检查可用次数(VIP 免检查)
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
@@ -481,6 +484,8 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交签到..."})
|
||||
|
||||
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
|
||||
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
|
||||
@@ -512,15 +517,22 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
})
|
||||
saveLog(true, "", taskTitle(task))
|
||||
|
||||
// 扣减可用次数(原子操作,防止并发超卖)
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!",
|
||||
})
|
||||
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!",
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!(VIP 免扣次数)",
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title)
|
||||
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title, "isVip", isVip)
|
||||
})
|
||||
|
||||
return nil
|
||||
@@ -569,7 +581,7 @@ var _ = time.Now
|
||||
|
||||
// UpdateTaskRequest 更新任务请求
|
||||
type UpdateTaskRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Title *string `json:"title"`
|
||||
FormData *[]model.CpTaskFormItem `json:"formData"`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"fmt"
|
||||
|
||||
@@ -117,6 +118,12 @@ func CreateTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "创建工单失败")
|
||||
}
|
||||
|
||||
// 异步邮件提醒管理员处理新工单
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("id, username, real_name, school_name").First(&u, userID).Error; err == nil {
|
||||
service.NotifyNewTicket(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, ticket.Content, ticket.CreatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"id": ticket.ID,
|
||||
})
|
||||
|
||||
@@ -6,11 +6,15 @@ import "time"
|
||||
type CDKCode struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Code string `gorm:"type:varchar(32);uniqueIndex;not null" json:"code"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格)
|
||||
CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价)
|
||||
AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理
|
||||
Agent *AgentUser `gorm:"foreignKey:AgentID" json:"agent,omitempty"` // 生成代理(admin全量列表预加载用)
|
||||
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格)
|
||||
CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价)
|
||||
AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理
|
||||
Agent *AgentUser `gorm:"foreignKey:AgentID" json:"agent,omitempty"` // 生成代理(admin全量列表预加载用)
|
||||
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数(type=count 有效)
|
||||
// CDK 类型:count=执行次数 / vip=VIP 时长
|
||||
Type string `gorm:"type:varchar(16);not null;default:count;index" json:"type"`
|
||||
// VIP 时长天数(type=vip 有效,默认 30 天/一个月)
|
||||
VipDays int `gorm:"default:0" json:"vipDays"`
|
||||
Status string `gorm:"type:varchar(16);not null;default:unused" json:"status"` // unused / used / disabled
|
||||
UserID *uint `gorm:"index" json:"userId"` // 最近兑换用户
|
||||
UsedAt *time.Time `json:"usedAt"`
|
||||
|
||||
@@ -3,15 +3,15 @@ package model
|
||||
import "time"
|
||||
|
||||
type SysUser struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"`
|
||||
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"`
|
||||
StudentID string `gorm:"type:varchar(255);not null" json:"studentId"`
|
||||
Cookie string `gorm:"type:longtext" json:"-"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"`
|
||||
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"`
|
||||
StudentID string `gorm:"type:varchar(255);not null" json:"studentId"`
|
||||
Cookie string `gorm:"type:longtext" json:"-"`
|
||||
// RawPassword AES加密后的cpdaily明文密码(base64编码),用于cookie过期时自动重新登录
|
||||
RawPassword string `gorm:"type:longtext" json:"-"`
|
||||
RawPassword string `gorm:"type:longtext" json:"-"`
|
||||
|
||||
// 今日校园真实姓名
|
||||
RealName string `gorm:"type:varchar(255)" json:"realName"`
|
||||
@@ -37,8 +37,12 @@ type SysUser struct {
|
||||
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
|
||||
// 可签到次数
|
||||
SignCount int `gorm:"type:int;default:0" json:"signCount"`
|
||||
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
|
||||
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
|
||||
// 邀请码
|
||||
InviteCode string `gorm:"type:varchar(32);uniqueIndex" json:"inviteCode"`
|
||||
// 邀请人用户ID(null=非邀请注册)
|
||||
InviterID *uint `gorm:"type:int;index" json:"inviterId"`
|
||||
// 邀请奖励积分(成功邀请一人得 0.5,满 1 可兑换 1 次签到)
|
||||
InviteReward float64 `gorm:"type:decimal(10,1);default:0" json:"inviteReward"`
|
||||
// 累计邀请人数
|
||||
@@ -52,6 +56,9 @@ type SysUser struct {
|
||||
// 最后登录时间
|
||||
LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"`
|
||||
|
||||
// 已看过的新手引导版本(如 v2;空=未看过,登录后需展示引导,跨设备生效)
|
||||
GuideVersion string `gorm:"type:varchar(32);default:''" json:"guideVersion"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
@@ -24,12 +24,54 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
captchaTTL = 5 * time.Minute
|
||||
captchaKeyFmt = "captcha:%s" // 验证码内容
|
||||
captchaRLKeyFmt = "captcha:rl:%s" // 生成频率限制
|
||||
captchaRLWindow = 10 * time.Second // 每 IP 生成间隔
|
||||
captchaTTL = 5 * time.Minute
|
||||
captchaKeyFmt = "captcha:%s" // 验证码内容
|
||||
captchaRLKey = "captcha:rl:%s"
|
||||
|
||||
// 令牌桶:同 IP 5 分钟最多获取 10 次(桶容量 10,速率 10/5min = 每 30s 回补 1 个)
|
||||
// 容忍共享出口 IP(宿舍/校园网多人)短时突发,又能约束爆破频率
|
||||
captchaBucketCapacity = 10
|
||||
captchaBucketWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
// 令牌桶 Lua 脚本(原子):状态存 "剩余tokens:上次补桶毫秒时间戳"
|
||||
// 返回 {1,0} 放行(已扣 1 个 token);{0, waitMs} 被限流(需等待 waitMs 毫秒后才有新 token)
|
||||
var captchaTokenScript = redis.NewScript(`
|
||||
local cap = tonumber(ARGV[1])
|
||||
local rateMs = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local windowMs = tonumber(ARGV[4])
|
||||
|
||||
local tokens = cap
|
||||
local last = now
|
||||
local data = redis.call('GET', KEYS[1])
|
||||
if data then
|
||||
local colon = string.find(data, ':')
|
||||
if colon then
|
||||
tokens = tonumber(string.sub(data, 1, colon - 1))
|
||||
last = tonumber(string.sub(data, colon + 1))
|
||||
if not tokens or not last then tokens = cap end
|
||||
end
|
||||
end
|
||||
|
||||
local elapsedMs = now - last
|
||||
if elapsedMs > 0 then
|
||||
tokens = tokens + elapsedMs * rateMs
|
||||
end
|
||||
if tokens > cap then tokens = cap end
|
||||
|
||||
if tokens < 1 then
|
||||
local waitMs = math.ceil((1 - tokens) / rateMs)
|
||||
if waitMs < 1000 then waitMs = 1000 end
|
||||
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
|
||||
return { 0, waitMs }
|
||||
end
|
||||
|
||||
tokens = tokens - 1
|
||||
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
|
||||
return { 1, 0 }
|
||||
`)
|
||||
|
||||
// redisCaptchaOp 短超时上下文(不阻塞请求)
|
||||
func redisCaptchaCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 2*time.Second)
|
||||
@@ -248,23 +290,54 @@ func VerifyCaptcha(captchaID, code string) bool {
|
||||
return strings.EqualFold(cv.Code, code)
|
||||
}
|
||||
|
||||
// AllowCaptchaGen 验证码生成频率限制:同 IP 在窗口期内最多生成一次
|
||||
// 返回 true 表示允许生成。Redis 不可用时放行(生成接口的存储兜底同样受限)。
|
||||
func AllowCaptchaGen(ip string) bool {
|
||||
if ip == "" {
|
||||
return true
|
||||
}
|
||||
if Redis == nil {
|
||||
return true
|
||||
// AllowCaptchaToken 验证码生成令牌桶限流:同 IP 5 分钟内最多获取 10 次。
|
||||
// 返回 (true, 0) 表示允许生成并已扣减 token;
|
||||
// 返回 (false, wait) 表示被限流,wait 为最快可再次获取的等待时长(已向上取整到秒)。
|
||||
// 空 IP 或 Redis 不可用/出错时放行(存储兜底同样受限,避免误伤正常用户)。
|
||||
func AllowCaptchaToken(ip string) (bool, time.Duration) {
|
||||
if ip == "" || Redis == nil {
|
||||
return true, 0
|
||||
}
|
||||
ctx, cancel := redisCaptchaCtx()
|
||||
defer cancel()
|
||||
ok, err := Redis.SetNX(ctx, fmt.Sprintf(captchaRLKeyFmt, ip), "1", captchaRLWindow).Result()
|
||||
|
||||
rateMs := float64(captchaBucketCapacity) / float64(captchaBucketWindow.Milliseconds())
|
||||
res, err := captchaTokenScript.Run(ctx, Redis,
|
||||
[]string{fmt.Sprintf(captchaRLKey, ip)},
|
||||
captchaBucketCapacity,
|
||||
fmt.Sprintf("%.12f", rateMs),
|
||||
time.Now().UnixMilli(),
|
||||
captchaBucketWindow.Milliseconds(),
|
||||
).Int64Slice()
|
||||
if err != nil {
|
||||
slog.Warn("验证码限流 Redis 操作失败", "err", err)
|
||||
return true
|
||||
slog.Warn("验证码限流 Redis 操作失败,放行", "err", err)
|
||||
return true, 0
|
||||
}
|
||||
if len(res) != 2 {
|
||||
return true, 0
|
||||
}
|
||||
if res[0] == 1 {
|
||||
return true, 0
|
||||
}
|
||||
// waitMs 向上取整到秒(至少 1 秒)
|
||||
wait := time.Duration(((res[1]+999)/1000)*1000) * time.Millisecond
|
||||
if wait < time.Second {
|
||||
wait = time.Second
|
||||
}
|
||||
return false, wait
|
||||
}
|
||||
|
||||
// FormatWait 将等待时长格式化为提示文案片段:如 "5 秒"、"3 分钟"
|
||||
func FormatWait(d time.Duration) string {
|
||||
secs := int((d + 999*time.Millisecond) / time.Second)
|
||||
switch {
|
||||
case secs < 60:
|
||||
return fmt.Sprintf("%d 秒", secs)
|
||||
case secs < 3600:
|
||||
return fmt.Sprintf("%d 分钟", (secs+59)/60)
|
||||
default:
|
||||
return fmt.Sprintf("%d 小时", (secs+3599)/3600)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// 画粗线(绘制多条平行线模拟粗线)
|
||||
|
||||
@@ -140,6 +140,14 @@ func getCasValidLoginUrl(school *SchoolDetailInfo) string {
|
||||
return school.IdsUrl
|
||||
}
|
||||
}
|
||||
// 优先选择 campusphere / wec 网关入口:登录后 cookie 落在网关域,
|
||||
// 后续签到/查寝等任务请求(wec-counselor-* 均在网关域)才能携带有效会话。
|
||||
// 部分学校 AmpUrl 为学校自研门户(如 ehall),若登录到该域,cookie 与任务接口域不匹配。
|
||||
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
|
||||
if u != "" && (strings.Contains(u, "campusphere") || strings.Contains(u, "wec-portal-mobile")) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
|
||||
if u != "" {
|
||||
return u
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -49,20 +51,20 @@ type TodayTaskInfo struct {
|
||||
|
||||
// TaskFormDetail 签到任务表单详情
|
||||
type TaskFormDetail struct {
|
||||
Rows map[string]interface{} `json:"rows"`
|
||||
RealTitle string `json:"realTitle"`
|
||||
SignWid string `json:"signWid"`
|
||||
SignInstanceWid string `json:"signInstanceWid"`
|
||||
IsPhoto bool `json:"isPhoto"`
|
||||
IsNeedExtra bool `json:"isNeedExtra"`
|
||||
ExtraField []interface{} `json:"extraField,omitempty"`
|
||||
HistoryRows map[string]interface{} `json:"historyRows,omitempty"`
|
||||
Rows map[string]interface{} `json:"rows"`
|
||||
RealTitle string `json:"realTitle"`
|
||||
SignWid string `json:"signWid"`
|
||||
SignInstanceWid string `json:"signInstanceWid"`
|
||||
IsPhoto bool `json:"isPhoto"`
|
||||
IsNeedExtra bool `json:"isNeedExtra"`
|
||||
ExtraField []interface{} `json:"extraField,omitempty"`
|
||||
HistoryRows map[string]interface{} `json:"historyRows,omitempty"`
|
||||
}
|
||||
|
||||
// newRestyClient 创建 resty 客户端
|
||||
func newTaskClient() *resty.Client {
|
||||
return resty.New().
|
||||
SetTimeout(30*time.Second).
|
||||
SetTimeout(30 * time.Second).
|
||||
SetDoNotParseResponse(true).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
}
|
||||
@@ -851,6 +853,132 @@ func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskForm
|
||||
return result
|
||||
}
|
||||
|
||||
// SignPlace 查寝组/签到任务配置的签到范围点
|
||||
type SignPlace struct {
|
||||
Address string
|
||||
Longitude string // 原始字符串,保持精度
|
||||
Latitude string // 原始字符串,保持精度
|
||||
LongitudeF float64 // 用于距离计算
|
||||
LatitudeF float64
|
||||
RadiusMeters float64
|
||||
}
|
||||
|
||||
// parseSignPlaces 解析任务详情中的 signPlaceSelected(兼容 JSON 字符串与数组两种格式)
|
||||
func parseSignPlaces(rows map[string]interface{}) []SignPlace {
|
||||
if rows == nil {
|
||||
return nil
|
||||
}
|
||||
var places []SignPlace
|
||||
appendPlace := func(m map[string]interface{}) {
|
||||
lon := mapValueStr(m, "longitude")
|
||||
lat := mapValueStr(m, "latitude")
|
||||
if lon == "" || lat == "" {
|
||||
return
|
||||
}
|
||||
lonF, err1 := strconv.ParseFloat(lon, 64)
|
||||
latF, err2 := strconv.ParseFloat(lat, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return
|
||||
}
|
||||
radius := 0.0
|
||||
if r, ok := m["radius"]; ok && r != nil {
|
||||
if f, ok := r.(float64); ok {
|
||||
radius = f
|
||||
} else {
|
||||
radius, _ = strconv.ParseFloat(fmt.Sprintf("%v", r), 64)
|
||||
}
|
||||
}
|
||||
if radius <= 0 {
|
||||
return
|
||||
}
|
||||
places = append(places, SignPlace{
|
||||
Address: mapValueStr(m, "address"),
|
||||
Longitude: lon,
|
||||
Latitude: lat,
|
||||
LongitudeF: lonF,
|
||||
LatitudeF: latF,
|
||||
RadiusMeters: radius,
|
||||
})
|
||||
}
|
||||
if sps, ok := rows["signPlaceSelected"]; ok {
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
var arr []map[string]interface{}
|
||||
if json.Unmarshal([]byte(v), &arr) == nil {
|
||||
for _, m := range arr {
|
||||
appendPlace(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
appendPlace(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return places
|
||||
}
|
||||
|
||||
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
|
||||
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadius = 6371000.0
|
||||
rad := math.Pi / 180.0
|
||||
dLat := (lat2 - lat1) * rad
|
||||
dLon := (lon2 - lon1) * rad
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return 2 * earthRadius * math.Asin(math.Sqrt(a))
|
||||
}
|
||||
|
||||
// AdjustSubmitFormToSignRange 提交前校验历史位置是否落在查寝组签到范围内,
|
||||
// 若超出所有签到点范围则自动改用距离最近的签到点坐标(避免服务端拒绝"位置不在签到范围内")。
|
||||
// 返回 true 表示发生了坐标调整。
|
||||
func AdjustSubmitFormToSignRange(submitForm map[string]interface{}, detailRows map[string]interface{}) bool {
|
||||
places := parseSignPlaces(detailRows)
|
||||
if len(places) == 0 {
|
||||
return false
|
||||
}
|
||||
latStr, _ := submitForm["latitude"].(string)
|
||||
lonStr, _ := submitForm["longitude"].(string)
|
||||
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
|
||||
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
best := -1
|
||||
bestDist := math.MaxFloat64
|
||||
for i, p := range places {
|
||||
d := haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF)
|
||||
if d <= p.RadiusMeters {
|
||||
// 历史位置在某个签到点范围内,正常沿用历史记录
|
||||
return false
|
||||
}
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
best = i
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
p := places[best]
|
||||
submitForm["latitude"] = p.Latitude
|
||||
submitForm["longitude"] = p.Longitude
|
||||
if p.Address != "" {
|
||||
submitForm["position"] = p.Address
|
||||
}
|
||||
slog.Info("历史位置超出查寝组所有签到范围,自动改用最近签到点",
|
||||
"origLat", latStr, "origLon", lonStr,
|
||||
"newLat", p.Latitude, "newLon", p.Longitude,
|
||||
"place", p.Address, "distM", int(bestDist), "radiusM", int(p.RadiusMeters))
|
||||
return true
|
||||
}
|
||||
|
||||
// GetBodyString 加密提交表单为 bodyString
|
||||
func GetBodyString(form map[string]interface{}) string {
|
||||
data, _ := json.Marshal(form)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 模拟阜阳职业技术学院查寝组真实返回的签到点配置
|
||||
func mockSignPlacesRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"signPlaceSelected": `[{"address":"七渔河东区","longitude":"115.78725271446127","latitude":"32.90506452710031","radius":500},
|
||||
{"address":"新建的签到地点","longitude":"115.78725271446127","latitude":"32.90506452710031","radius":790},
|
||||
{"address":"新校区1000米","longitude":"115.77058016419615","latitude":"32.904624876025075","radius":1000},
|
||||
{"address":"新校区边角修正2","longitude":"115.77471236954204","latitude":"32.9053826914217","radius":500},
|
||||
{"address":"新校区边角修正1","longitude":"115.76709473881743","latitude":"32.90250295808209","radius":500}]`,
|
||||
}
|
||||
}
|
||||
|
||||
// 历史位置在"新校区1000米"点范围内(约932m)→ 不调整,沿用历史记录
|
||||
func TestAdjustInKeepHistory(t *testing.T) {
|
||||
form := map[string]interface{}{
|
||||
"latitude": "32.898615",
|
||||
"longitude": "115.763603",
|
||||
"position": "七渔河东区",
|
||||
}
|
||||
adjusted := AdjustSubmitFormToSignRange(form, mockSignPlacesRows())
|
||||
if adjusted {
|
||||
t.Fatalf("历史位置在范围内不应调整, form=%v", form)
|
||||
}
|
||||
if form["latitude"] != "32.898615" || form["longitude"] != "115.763603" {
|
||||
t.Fatalf("范围内历史坐标不应被修改: %v", form)
|
||||
}
|
||||
}
|
||||
|
||||
// 历史位置远离所有签到点(2km+)→ 调整为最近签到点坐标
|
||||
func TestAdjustOutOfRange(t *testing.T) {
|
||||
form := map[string]interface{}{
|
||||
"latitude": "32.880000",
|
||||
"longitude": "115.740000",
|
||||
"position": "旧位置",
|
||||
}
|
||||
adjusted := AdjustSubmitFormToSignRange(form, mockSignPlacesRows())
|
||||
if !adjusted {
|
||||
t.Fatal("完全出界的历史位置应被调整")
|
||||
}
|
||||
if form["longitude"] != "115.76709473881743" || form["latitude"] != "32.90250295808209" {
|
||||
t.Fatalf("应调整为最近的'边角修正1'坐标, got %v", form)
|
||||
}
|
||||
if form["position"] != "新校区边角修正1" {
|
||||
t.Fatalf("地址应同步为最近签到点: %v", form)
|
||||
}
|
||||
}
|
||||
|
||||
// 无 signPlaceSelected / 无 radius 时保持不动
|
||||
func TestAdjustNoPlaces(t *testing.T) {
|
||||
form := map[string]interface{}{"latitude": "32.88", "longitude": "115.74"}
|
||||
if AdjustSubmitFormToSignRange(form, map[string]interface{}{}) {
|
||||
t.Fatal("无签到点配置不应调整")
|
||||
}
|
||||
if AdjustSubmitFormToSignRange(form, map[string]interface{}{"signPlaceSelected": "[]"}) {
|
||||
t.Fatal("空签到点不应调整")
|
||||
}
|
||||
}
|
||||
|
||||
// 坐标解析异常时保持不动
|
||||
func TestAdjustBadCoord(t *testing.T) {
|
||||
form := map[string]interface{}{"latitude": "", "longitude": "abc"}
|
||||
if AdjustSubmitFormToSignRange(form, mockSignPlacesRows()) {
|
||||
t.Fatal("坐标异常不应调整")
|
||||
}
|
||||
}
|
||||
|
||||
// haversine 距离合理性:边角修正1距历史位置约540m,七渔河东区约2.26km
|
||||
func TestHaversineMeters(t *testing.T) {
|
||||
d1 := haversineMeters(32.898615, 115.763603, 32.90250295808209, 115.76709473881743)
|
||||
if d1 < 480 || d1 > 600 {
|
||||
t.Fatalf("距边角修正1应约540m, got %.0f", d1)
|
||||
}
|
||||
d2 := haversineMeters(32.898615, 115.763603, 32.90506452710031, 115.78725271446127)
|
||||
if d2 < 2000 || d2 > 2500 {
|
||||
t.Fatalf("距七渔河东区应约2.26km, got %.0f", d2)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/smtp"
|
||||
@@ -294,3 +295,33 @@ func NotifySchoolAdapted(contactEmails []string, schoolName string) {
|
||||
<p style="font-size:14px;color:#555">若仍有问题,欢迎在应用内提交工单反馈。</p>`
|
||||
SendMailAsync(contactEmails, subject, EmailBody(inner))
|
||||
}
|
||||
|
||||
// NotifyNewTicket 新工单提醒(发给管理员)
|
||||
func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, content, createdAt string) {
|
||||
adminEmails := GetAdminEmails()
|
||||
if len(adminEmails) == 0 {
|
||||
slog.Info("未配置管理员邮箱,跳过新工单通知", "ticketId", ticketID)
|
||||
return
|
||||
}
|
||||
// 内容摘要:HTML 转义防注入,换行转 <br>,超长截断
|
||||
escaped := html.EscapeString(content)
|
||||
if len([]rune(escaped)) > 500 {
|
||||
escaped = string([]rune(escaped)[:500]) + "……"
|
||||
}
|
||||
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
|
||||
displayName := realName
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
subject := "【小助手】新的工单:" + title
|
||||
inner := `<h2 style="margin:0 0 16px;font-size:17px">收到新的工单反馈</h2>
|
||||
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
|
||||
<tr><td style="padding-right:16px;color:#888;vertical-align:top">标题</td><td><b>` + html.EscapeString(title) + `</b></td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888">用户</td><td>` + html.EscapeString(displayName) + `(` + html.EscapeString(username) + `)</td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888">学校</td><td>` + html.EscapeString(schoolName) + `</td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888;vertical-align:top">内容</td><td style="max-width:420px">` + escaped + `</td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888">提交时间</td><td>` + html.EscapeString(createdAt) + `</td></tr>
|
||||
</table>
|
||||
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并回复处理。</p>`
|
||||
SendMailAsync(adminEmails, subject, EmailBody(inner))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8884:8093"
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
volumes:
|
||||
- /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro
|
||||
networks:
|
||||
|
||||
@@ -28,7 +28,7 @@ watch(
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
|
||||
@@ -138,6 +138,10 @@ export interface CDKCode {
|
||||
costPrice: number
|
||||
agentId: number
|
||||
maxRuns: number
|
||||
/** CDK类型:count=次数卡 / vip=VIP时长卡 */
|
||||
type: 'count' | 'vip'
|
||||
/** VIP 时长天数(type=vip 有效:7=周卡 30=月卡) */
|
||||
vipDays: number
|
||||
status: string
|
||||
userId: number | null
|
||||
usedAt: string | null
|
||||
@@ -175,9 +179,14 @@ export interface GenerateCDKResult {
|
||||
cdkCodes: CDKCode[]
|
||||
}
|
||||
|
||||
/** 生成CDK */
|
||||
export function generateCDK(count: number = 1, maxRuns: number = 1) {
|
||||
return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns })
|
||||
/** 生成CDK(type: count=次数 / vip=VIP时长,vipDays 默认 30 天) */
|
||||
export function generateCDK(
|
||||
count: number = 1,
|
||||
maxRuns: number = 1,
|
||||
type: 'count' | 'vip' = 'count',
|
||||
vipDays: number = 30
|
||||
) {
|
||||
return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns, type, vipDays })
|
||||
}
|
||||
|
||||
/** 获取自己生成的CDK列表 */
|
||||
@@ -308,6 +317,8 @@ export interface UserListItem {
|
||||
signCount: number
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
inviterId: number | null
|
||||
inviterName: string
|
||||
taskCount: number
|
||||
status: string
|
||||
createdAt: string
|
||||
@@ -332,6 +343,8 @@ export interface UserDetail {
|
||||
signCount: number
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
inviterId: number | null
|
||||
inviterName: string
|
||||
status: string
|
||||
disabledReason: string
|
||||
taskCount: number
|
||||
@@ -359,3 +372,43 @@ export function getUserDetail(id: number) {
|
||||
export function updateUserStatus(id: number, status: 'active' | 'disabled', disabledReason = '') {
|
||||
return api.put(`/admin/users/${id}/status`, { status, disabledReason })
|
||||
}
|
||||
|
||||
// ======== 任务执行日志(admin only) ========
|
||||
|
||||
export interface AdminTaskLogItem {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
realName: string
|
||||
schoolName: string
|
||||
taskType: string // sign / sleep_check
|
||||
title: string
|
||||
realTitle: string
|
||||
isSuccess: boolean
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminTaskLogResult {
|
||||
total: number
|
||||
items: AdminTaskLogItem[]
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 获取任务执行日志列表(默认当天) */
|
||||
export function getAdminTaskLogs(params: {
|
||||
date?: string
|
||||
status?: 'all' | 'success' | 'fail'
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.date) qs.set('date', params.date)
|
||||
qs.set('status', params.status || 'all')
|
||||
if (params.keyword) qs.set('keyword', params.keyword)
|
||||
qs.set('page', String(params.page || 1))
|
||||
qs.set('pageSize', String(params.pageSize || 20))
|
||||
return api.get<{ data: AdminTaskLogResult }>(`/admin/task-logs?${qs.toString()}`)
|
||||
}
|
||||
|
||||
@@ -56,9 +56,13 @@ export interface UserInfo {
|
||||
mobileCode: string
|
||||
mobileType: string
|
||||
signCount: number
|
||||
/** VIP 到期时间(null=从未开通) */
|
||||
vipExpireAt: string | null
|
||||
inviteCode: string
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
/** 已看过的新手引导版本(如 v2;空=未看过,登录后需展示引导) */
|
||||
guideVersion: string
|
||||
}
|
||||
|
||||
/** 我的邀请信息(含奖励规则,随系统配置变化) */
|
||||
@@ -117,9 +121,28 @@ export function getDeviceStatus(token: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK,一次性消耗全部次数) */
|
||||
/** 随机生成设备码并绑定到当前账号(个人中心「随机」按钮,需连续两次风险确认) */
|
||||
export function randomBindDevice() {
|
||||
return api.post<{ data: { deviceCode: string } }>('/device/random-bind')
|
||||
}
|
||||
|
||||
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK;次数 CDK 加次数 / VIP CDK 延长 VIP) */
|
||||
export function redeemCDK(code: string, captchaId: string, captchaCode: string) {
|
||||
return api.post<{ data: { totalRuns: number } }>('/cdk/redeem', { code, captchaId, captchaCode })
|
||||
return api.post<{
|
||||
data: {
|
||||
type: 'count' | 'vip'
|
||||
totalRuns?: number
|
||||
vipDays?: number
|
||||
vipExpireAt?: string
|
||||
}
|
||||
}>('/cdk/redeem', { code, captchaId, captchaCode })
|
||||
}
|
||||
|
||||
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价) */
|
||||
export function getVipInfo() {
|
||||
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipWeekPrice: string; vipMonthPrice: string; vipPrice: string } }>(
|
||||
'/cdk/vip-info'
|
||||
)
|
||||
}
|
||||
|
||||
// ======== 签到任务相关 ========
|
||||
@@ -283,3 +306,8 @@ export function getMyInviteInfo() {
|
||||
export function redeemInviteReward() {
|
||||
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||
}
|
||||
|
||||
/** 上报已完成当前版本新手引导(数据库记录,跨设备不再重复展示) */
|
||||
export function completeGuide() {
|
||||
return api.post('/auth/guide/complete')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { X, ChevronLeft, ChevronRight, Sparkles } from 'lucide-vue-next'
|
||||
import { X, ChevronLeft, ChevronRight, Sparkles, Play } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
@@ -57,11 +57,18 @@ const steps: GuideStep[] = [
|
||||
descByKind: () =>
|
||||
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '先绑定设备码',
|
||||
targets: ['[data-guide="profile-device-code"]'],
|
||||
descByKind: () =>
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
title: '新建每日签到任务',
|
||||
targets: ['[data-guide="home-create-task"]'],
|
||||
descByKind: () => '点击蓝色的「+ 新的签到任务」按钮,选择任务类型(如晚归签到),填好学号等信息并保存,任务就创建好了。',
|
||||
descByKind: () => '点击蓝色的「+ 新的签到任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
@@ -71,7 +78,7 @@ const steps: GuideStep[] = [
|
||||
kind === 'home-task-exec'
|
||||
? '任务创建后显示在下方列表。点任务右侧的「执行」按钮,即可自动完成今天的签到,之后每天无需再手动操作。'
|
||||
: kind === 'home-task-empty'
|
||||
? '保存任务后,这里会出现你的任务卡片。点卡片右侧的「执行」按钮即可自动完成每日签到,无需手动操作。'
|
||||
? '你还没有真实任务,下面先展示一张示例任务卡。保存真实任务后它会出现在这里,点卡片右侧「执行」按钮即可自动完成每日签到。'
|
||||
: '任务创建后显示在这个区域。点任务右侧的「执行」按钮即可自动完成每日签到,无需手动操作。',
|
||||
},
|
||||
{
|
||||
@@ -90,6 +97,13 @@ const steps: GuideStep[] = [
|
||||
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
|
||||
},
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: 'VIP 会员:签到不限次',
|
||||
targets: ['[data-guide="profile-vip-btn"]'],
|
||||
descByKind: () =>
|
||||
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
},
|
||||
]
|
||||
|
||||
const current = computed(() => steps[step.value])
|
||||
@@ -107,8 +121,37 @@ const resolvedKind = computed(() => {
|
||||
|
||||
const guideDesc = computed(() => current.value.descByKind(resolvedKind.value))
|
||||
|
||||
// 第三步且用户没有任何真实任务(命中空态卡)时:展示一张示例任务卡引导
|
||||
const mockMode = computed(() => props.show && step.value === 2 && resolvedKind.value === 'home-task-empty')
|
||||
const mockCardStyle = computed(() => {
|
||||
const r = holeRect.value
|
||||
if (!r) return null
|
||||
return {
|
||||
top: `${Math.max(0, Math.round(r.top + (r.height - 76) / 2))}px`,
|
||||
left: `${r.left}px`,
|
||||
width: `${r.width}px`,
|
||||
}
|
||||
})
|
||||
|
||||
let lastLocateAt = 0
|
||||
|
||||
/* ================= 定位与高亮 ================= */
|
||||
|
||||
// 方位决策(measure 与 ensure 共用同一函数,避免两侧交替乒乓):
|
||||
// 优先下方(移动端阅读流自然向下展开,也符合"上方放不下就放下方"的预期);
|
||||
// 下方放不下才放上方;两侧都不能完整容纳时选"溢出更少"的一侧
|
||||
function choosePos(r: { top: number; bottom: number }, bH: number, vh: number): 'top' | 'bottom' {
|
||||
const gapTop = r.top - 12 // 目标上方可用高度(气泡底距目标顶 12px 间隙)
|
||||
const gapBottom = vh - r.bottom - 12 // 目标下方可用高度
|
||||
const need = Math.max(240, bH > 0 ? bH + 24 : 260)
|
||||
if (gapBottom >= need) return 'bottom'
|
||||
if (gapTop >= need) return 'top'
|
||||
// 两侧都不足:比较溢出量(气泡被视口裁剪的部分),选溢出少的一侧
|
||||
const topLost = Math.max(0, bH - gapTop)
|
||||
const bottomLost = Math.max(0, bH - gapBottom)
|
||||
return bottomLost <= topLost ? 'bottom' : 'top'
|
||||
}
|
||||
|
||||
function measure(el: Element) {
|
||||
const r = el.getBoundingClientRect()
|
||||
const cs = getComputedStyle(el)
|
||||
@@ -142,50 +185,83 @@ function measure(el: Element) {
|
||||
if (moved) holeRect.value = next
|
||||
|
||||
// 用气泡真实高度(内容/文案变化后实时跟随)决定放目标上方还是下方,始终与高亮框保留间隙
|
||||
const gapTop = r.top - 12
|
||||
const gapBottom = vh - r.bottom - 12
|
||||
// need 采用保守估算:保证整颗气泡完整落在目标一侧,杜绝"放上方但上方放不下溢出到视口外"
|
||||
const bH = bubbleEl.value?.offsetHeight || 0
|
||||
const estH = (bH > 0 ? bH : 230) + 16
|
||||
const pos =
|
||||
gapTop >= estH ? 'top' : gapBottom >= estH ? 'bottom' : gapTop >= gapBottom ? 'top' : 'bottom'
|
||||
const pos = choosePos({ top: r.top, bottom: r.bottom }, bH, vh)
|
||||
if (pos !== bubblePos.value) bubblePos.value = pos
|
||||
}
|
||||
|
||||
// 气泡渲染后自检:若气泡与真实目标元素相交(布局错位/文案高度突变等未及修正的场景),
|
||||
// 强制翻到另一侧排布绕开目标(top/bottom 定位均与目标保留间隙)
|
||||
// 气泡渲染后自检:若气泡与真实目标元素相交、或被视口裁掉过多(布局错位/文案高度突变/
|
||||
// 测量竞态等未及修正的场景),按真实尺寸重新走同一方位决策绕开目标
|
||||
function ensureBubbleNotOverlapping(el: Element) {
|
||||
requestAnimationFrame(() => {
|
||||
const b = bubbleEl.value
|
||||
if (!props.show || !b || bubblePos.value === 'center') return
|
||||
if (!props.show || !b) return
|
||||
const br = b.getBoundingClientRect()
|
||||
const tr = el.getBoundingClientRect()
|
||||
const vh = window.innerHeight
|
||||
const overlapped =
|
||||
br.right > tr.left + 2 &&
|
||||
br.left < tr.right - 2 &&
|
||||
br.bottom > tr.top + 2 &&
|
||||
br.top < tr.bottom - 2
|
||||
if (overlapped) {
|
||||
// 400ms 防抖:翻转后让 measure 轮询先修正高亮位置,避免两侧交替乒乓
|
||||
const now = Date.now()
|
||||
if (now - lastFlipAt > 400) {
|
||||
lastFlipAt = now
|
||||
bubblePos.value = bubblePos.value === 'top' ? 'bottom' : 'top'
|
||||
}
|
||||
// top 模式顶部被视口裁切 / bottom 模式底部被裁切 / center 兜底样式压住了目标
|
||||
const topClipped = bubblePos.value === 'top' && br.top < 4
|
||||
const bottomClipped = bubblePos.value === 'bottom' && br.bottom > vh - 4
|
||||
if (!overlapped && !topClipped && !bottomClipped) return
|
||||
// 400ms 防抖:决策后让 measure/轮询先消化,避免高频翻转
|
||||
const now = Date.now()
|
||||
if (now - lastFlipAt < 400) return
|
||||
lastFlipAt = now
|
||||
if (bubblePos.value === 'center') {
|
||||
// 等待/兜底样式与目标相交(目标已渲染):立即重新定位
|
||||
void locate()
|
||||
return
|
||||
}
|
||||
// 与 measure 相同的决策函数:相交/越界时按真实气泡高度重选方位,不会乒乓
|
||||
const bH = b.offsetHeight || 240
|
||||
const desired = choosePos({ top: tr.top, bottom: tr.bottom }, bH, vh)
|
||||
if (desired !== bubblePos.value) bubblePos.value = desired
|
||||
})
|
||||
}
|
||||
|
||||
// 等待平滑滚动结束再测量(滚动中途测到的位置是错的,会导致气泡方位误判)
|
||||
function waitScrollSettled(timeout: number): Promise<void> {
|
||||
if (timeout <= 0) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
let done = false
|
||||
let t = 0
|
||||
const onScroll = () => {
|
||||
window.clearTimeout(t)
|
||||
t = window.setTimeout(cleanup, 120)
|
||||
}
|
||||
const cleanup = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
window.clearTimeout(t)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
resolve()
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
t = window.setTimeout(cleanup, 120) // 120ms 无滚动即视为稳定
|
||||
window.setTimeout(cleanup, timeout) // 最长等待兜底
|
||||
})
|
||||
}
|
||||
|
||||
async function locate() {
|
||||
const seq = ++locateSeq
|
||||
lastLocateAt = Date.now()
|
||||
const s = current.value
|
||||
holeRect.value = null
|
||||
const crossTab = route.name !== s.tab
|
||||
const prevEl = curTarget.value
|
||||
waitingTarget.value = crossTab
|
||||
curTarget.value = null
|
||||
|
||||
// 目标在另一 tab:请求切换后轮询等待元素出现(组件懒加载/数据异步)
|
||||
if (route.name !== s.tab) {
|
||||
waitingTarget.value = true
|
||||
if (crossTab) {
|
||||
// 跨 tab:旧目标所在页面即将切换,清空高亮避免残影
|
||||
holeRect.value = null
|
||||
emit('goto', s.tab)
|
||||
}
|
||||
// 同 tab 切换步骤时保留旧高亮作为过渡锚点(找到新目标后平滑滑移过去),避免闪黑
|
||||
let el: Element | null = null
|
||||
for (let k = 0; k < 40; k++) {
|
||||
if (seq !== locateSeq) return // 期间步骤已切换
|
||||
@@ -208,13 +284,16 @@ async function locate() {
|
||||
return
|
||||
}
|
||||
curTarget.value = el
|
||||
const moved = !prevEl || prevEl !== el
|
||||
// 找到目标后先立即测量一次:即使后续需要滚动/等待,气泡也已按正确方位
|
||||
// 排在目标上/下方,不会停留在顶部兜底样式遮住目标
|
||||
measure(el)
|
||||
try {
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' })
|
||||
if (moved) el.scrollIntoView({ block: 'center', inline: 'center' })
|
||||
} catch {
|
||||
// ignore(个别浏览器不支持 options)
|
||||
}
|
||||
await nextTick()
|
||||
await sleep(80) // 等待平滑滚动/布局稳定后再测量
|
||||
await waitScrollSettled(moved ? 800 : 0)
|
||||
if (seq !== locateSeq) return
|
||||
measure(el)
|
||||
ensureBubbleNotOverlapping(el)
|
||||
@@ -259,6 +338,14 @@ function startLayoutPolling() {
|
||||
const el = curTarget.value
|
||||
if (el && el.isConnected && document.contains(el)) {
|
||||
measure(el)
|
||||
// 目标升级:异步数据渲染出更高优先级的 v-if 目标(生成→复制 按钮、行→兑换 按钮)时重定位
|
||||
const better = current.value.targets.find((sel) => document.querySelector(sel))
|
||||
if (better && !el.matches(better) && Date.now() - lastLocateAt > 1000) {
|
||||
void locate()
|
||||
}
|
||||
} else if (el) {
|
||||
// 目标已从 DOM 消失(v-if 切换),重新定位到当前存在的最优目标
|
||||
void locate()
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
@@ -386,7 +473,8 @@ const bubbleStyle = computed(() => {
|
||||
if (bubblePos.value === 'top') {
|
||||
style.bottom = `${Math.max(12, vh - r.top + 12)}px`
|
||||
} else {
|
||||
style.top = `${Math.min(vh - 12, r.bottom + 12)}px`
|
||||
// r 为 {top,left,width,height},底部需用 top+height 合成(r.bottom 不存在会算出 NaNpx 被浏览器丢弃)
|
||||
style.top = `${Math.min(vh - 12, r.top + r.height + 12)}px`
|
||||
}
|
||||
return style
|
||||
})
|
||||
@@ -396,12 +484,35 @@ const bubbleStyle = computed(() => {
|
||||
<Teleport to="body">
|
||||
<transition name="guide-fade">
|
||||
<div v-if="show" class="guide-root fixed inset-0 z-[80]" @touchmove.prevent @scroll.prevent>
|
||||
<!-- 暗色遮罩:高亮框阴影法挖洞(高亮框本身透明露出真实页面元素) -->
|
||||
<div v-if="holeRect" :style="holeStyle" class="guide-hole" />
|
||||
<!-- 暗色遮罩:高亮框阴影法挖洞(高亮框本身透明露出真实页面元素);mock 时全屏压暗 -->
|
||||
<div v-if="holeRect && !mockMode" :style="holeStyle" class="guide-hole" />
|
||||
<div v-else class="absolute inset-0 bg-black/60" />
|
||||
|
||||
<!-- tooltip 气泡(key 保证切步时重新播放入场动画) -->
|
||||
<div ref="bubbleEl" :key="step" :style="bubbleStyle" class="guide-bubble absolute z-10">
|
||||
<!-- 无真实任务时的示例任务卡(替换空态区展示,引导指向"执行"按钮) -->
|
||||
<div
|
||||
v-if="mockMode && mockCardStyle"
|
||||
:style="mockCardStyle"
|
||||
class="guide-mock-card absolute pointer-events-none"
|
||||
>
|
||||
<div class="flex items-center justify-between p-3">
|
||||
<div class="flex-1 min-w-0 mr-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-900 whitespace-nowrap">晚签到</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
|
||||
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0" title="执行签到">
|
||||
<Play class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tooltip 气泡(切步时保留同一实例,仅位置平滑过渡,不重放入场动画) -->
|
||||
<div ref="bubbleEl" :style="bubbleStyle" class="guide-bubble absolute z-10">
|
||||
<!-- 顶部:标识 + 步骤 + 跳过 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center gap-1 text-[10px] font-semibold text-blue-600 flex-shrink-0">
|
||||
@@ -441,7 +552,7 @@ const bubbleStyle = computed(() => {
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-9 rounded-lg bg-gradient-to-r from-blue-500 to-violet-500 text-white text-[13px] font-medium shadow-sm hover:brightness-105 active:scale-[0.99] transition-all flex items-center justify-center gap-1"
|
||||
class="flex-1 h-9 rounded-lg bg-blue-500 hover:bg-blue-600 text-white text-[13px] font-medium shadow-sm active:scale-[0.99] transition-all flex items-center justify-center gap-1"
|
||||
@click="next"
|
||||
>
|
||||
{{ isLast ? '完成' : '下一步' }}
|
||||
@@ -471,6 +582,15 @@ const bubbleStyle = computed(() => {
|
||||
height 0.25s ease;
|
||||
}
|
||||
|
||||
.guide-mock-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.95),
|
||||
0 10px 28px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.guide-bubble {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
|
||||
@@ -54,9 +54,15 @@ const currentForcePopup = computed(() => forcePopupQueue.value[0] || null)
|
||||
const showDetail = ref(false)
|
||||
const detailItem = ref<AnnouncementItem | null>(null)
|
||||
|
||||
// 首次登录新手引导(v2 引导:所有登录用户重走一次,展示过即记录本地完成标记)
|
||||
const GUIDE_DONE_KEY = 'xz_guide_v2_done'
|
||||
// 新手引导:是否展示由后端数据库记录判定(用户 guideVersion 未达当前版本即展示,
|
||||
// 完成/关闭后上报写库,跨设备不再重复打扰)。测试强播入口 ?guide=1 保留:
|
||||
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
|
||||
const GUIDE_FORCE_KEY = 'xz_guide_force'
|
||||
/** 与后端 handler.GuideCompleteVersion 一致 */
|
||||
const GUIDE_VERSION = 'v2'
|
||||
const showGuide = ref(false)
|
||||
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
|
||||
const guideForced = ref(false)
|
||||
// 邀请奖励规则(供引导第 4 步动态展示;接口失败保持 null,气泡用泛指文案)
|
||||
const inviterRewardPerInvite = ref<number | null>(null)
|
||||
const inviteeRewardPerInvite = ref<number | null>(null)
|
||||
@@ -80,15 +86,21 @@ function handleGuideAction(target: 'home' | 'profile') {
|
||||
|
||||
function closeGuide() {
|
||||
showGuide.value = false
|
||||
// 标记引导已完成(跳过/关闭也视为完成,避免反复打扰)
|
||||
localStorage.setItem(GUIDE_DONE_KEY, '1')
|
||||
// 引导结束:补上公告未读数与强制弹窗队列
|
||||
loadUnreadCount()
|
||||
maybeLoadAnnouncementPopups()
|
||||
// 上报完成状态到数据库(看过当前版本后任何设备都不再弹);
|
||||
// 测试强播来源不写库,避免污染该账号的"首次引导"状态
|
||||
if (!guideForced.value) {
|
||||
authApi.completeGuide().catch(() => {
|
||||
// 上报失败不打扰用户:下次登录该账号会再次展示引导
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function needGuide(): boolean {
|
||||
return localStorage.getItem(GUIDE_DONE_KEY) !== '1'
|
||||
/** 数据库判定:guideVersion 未达当前引导版本的用户需展示引导 */
|
||||
function needGuide(user: UserInfo): boolean {
|
||||
return (user.guideVersion || '') !== GUIDE_VERSION
|
||||
}
|
||||
|
||||
async function maybeLoadAnnouncementPopups() {
|
||||
@@ -190,17 +202,20 @@ async function handleForcePopupDismiss() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 强播标记一次性消费:仅本次整页加载(URL 带 ?guide=1)内优先展示且关闭不写库;
|
||||
// 读取后立即清除,避免会话残留把后续普通访问(刷新/重开)误判为强播——
|
||||
// 否则会出现"看完引导后刷新又弹、且永不上报完成"的假象
|
||||
guideForced.value = sessionStorage.getItem(GUIDE_FORCE_KEY) === '1'
|
||||
sessionStorage.removeItem(GUIDE_FORCE_KEY)
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
const data = res.data.data
|
||||
userInfo.value = data
|
||||
|
||||
if (!data.mobileCode) {
|
||||
router.replace('/bind-device')
|
||||
return
|
||||
}
|
||||
// v2 新手引导:所有登录用户(含老用户)重新走一次,完成后本地标记不再打扰
|
||||
if (needGuide()) {
|
||||
// 未绑定设备码不再强制跳转绑定页(新建任务时会在入口处弹窗引导)
|
||||
// 新手引导:数据库判定该用户未看过当前版本引导则展示(跨设备一致);
|
||||
// 本次若来自 ?guide=1 强播则优先展示(guideForced 已消费,下次加载不再生效)
|
||||
if (guideForced.value || needGuide(data)) {
|
||||
showGuide.value = true
|
||||
// 引导气泡中展示邀请奖励数值(第 4 步)
|
||||
loadInviteRewardRules()
|
||||
|
||||
@@ -3,6 +3,16 @@ import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/styles/globals.css'
|
||||
|
||||
// 测试入口:URL 带 ?guide=1 强制重播新手引导(不受本地完成标记影响)。
|
||||
// 立即把标记落入 sessionStorage 并移除 URL 参数(登录跳转会丢失 query,故不能等到 MobileLayout)
|
||||
const guideParams = new URLSearchParams(window.location.search)
|
||||
if (guideParams.get('guide') === '1') {
|
||||
sessionStorage.setItem('xz_guide_force', '1')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('guide')
|
||||
window.history.replaceState(null, '', url)
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -4,6 +4,7 @@ import HomeView from '@/views/home/HomeView.vue'
|
||||
import ProfileView from '@/views/profile/ProfileView.vue'
|
||||
import BindDeviceView from '@/views/bind/BindDeviceView.vue'
|
||||
import MobileLayout from '@/layouts/MobileLayout.vue'
|
||||
import BrowserGuideView from '@/views/guide/BrowserGuideView.vue'
|
||||
|
||||
import TicketList from '@/views/ticket/TicketList.vue'
|
||||
import TicketDetail from '@/views/ticket/TicketDetail.vue'
|
||||
@@ -99,6 +100,12 @@ const router = createRouter({
|
||||
path: '/',
|
||||
redirect: '/login',
|
||||
},
|
||||
// 微信/QQ 内置浏览器引导页:提示复制链接用系统浏览器打开
|
||||
{
|
||||
path: '/open-in-browser',
|
||||
name: 'open-in-browser',
|
||||
component: BrowserGuideView,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
@@ -142,7 +149,32 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
// 微信/QQ 系内置浏览器检测:
|
||||
// - MicroMessenger: 微信内置浏览器
|
||||
// - QQ/: 手机 QQ 内置浏览器
|
||||
// 注意:不拦纯 QQ 浏览器 App(MQQBrowser),因为安卓微信右上角
|
||||
// "在浏览器打开"唤起的就是它,拦截会造成引导页死循环
|
||||
function isBlockedBrowser(): boolean {
|
||||
const ua = navigator.userAgent
|
||||
return /MicroMessenger|QQ\//i.test(ua)
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, _from) => {
|
||||
// ======== 微信/QQ 浏览器拦截(优先于一切认证逻辑)========
|
||||
// 引导页自身放行;但若当前已是正常浏览器(如从微信右上角
|
||||
// "在浏览器打开"跳转过来),自动回到 redirect 指向的原页面
|
||||
if (to.name === 'open-in-browser') {
|
||||
if (!isBlockedBrowser()) {
|
||||
const r = to.query.redirect
|
||||
const target = typeof r === 'string' && r.startsWith('/') && r !== to.fullPath ? r : '/'
|
||||
return target
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (isBlockedBrowser()) {
|
||||
return { name: 'open-in-browser', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token')
|
||||
const adminToken = localStorage.getItem('admin_token')
|
||||
const path = to.path
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 复制文本到剪贴板(兼容 HTTP / HTTPS / 移动端 WebView)
|
||||
*
|
||||
* 执行顺序刻意为先同步 execCommand:
|
||||
* document.execCommand('copy') 必须在「用户手势调用栈」内同步执行才可靠,
|
||||
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
|
||||
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
|
||||
*
|
||||
* 返回是否复制成功
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
// 必须可见且在屏幕内(不可 display:none / 移出视口),否则 iOS Safari 复制失败;
|
||||
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
|
||||
ta.setAttribute('readonly', '')
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
if (ok) return true
|
||||
|
||||
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// 忽略,走失败返回
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import * as adminApi from '@/api/admin'
|
||||
|
||||
const message = useMessage()
|
||||
@@ -49,6 +50,8 @@ const showGenerateModal = ref(false)
|
||||
const modalView = ref<'form' | 'result'>('form')
|
||||
const generateCount = ref(1)
|
||||
const generateMaxRuns = ref(1)
|
||||
const generateType = ref<'count' | 'vip'>('count')
|
||||
const generateVipDays = ref(30)
|
||||
const generateLoading = ref(false)
|
||||
const resultCdks = ref<adminApi.CDKCode[]>([])
|
||||
const resultCost = ref(0)
|
||||
@@ -118,13 +121,23 @@ async function handleGenerate() {
|
||||
message.info('数量范围为 1-100')
|
||||
return
|
||||
}
|
||||
if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
|
||||
if (generateType.value === 'vip') {
|
||||
if (generateVipDays.value !== 7 && generateVipDays.value !== 30) {
|
||||
message.info('VIP CDK 仅支持周卡(7 天)或月卡(30 天)')
|
||||
return
|
||||
}
|
||||
} else if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
|
||||
message.info('运行次数范围为 1-999')
|
||||
return
|
||||
}
|
||||
generateLoading.value = true
|
||||
try {
|
||||
const res = await adminApi.generateCDK(generateCount.value, generateMaxRuns.value)
|
||||
const res = await adminApi.generateCDK(
|
||||
generateCount.value,
|
||||
generateMaxRuns.value,
|
||||
generateType.value,
|
||||
generateVipDays.value
|
||||
)
|
||||
const data = res.data?.data
|
||||
resultCdks.value = data?.cdkCodes || []
|
||||
resultCost.value = data?.costTotal || 0
|
||||
@@ -142,6 +155,8 @@ function openGenerateModal() {
|
||||
modalView.value = 'form'
|
||||
generateCount.value = 1
|
||||
generateMaxRuns.value = 1
|
||||
generateType.value = 'count'
|
||||
generateVipDays.value = 30
|
||||
showGenerateModal.value = true
|
||||
}
|
||||
|
||||
@@ -156,57 +171,34 @@ function closeGenerateModal() {
|
||||
}
|
||||
|
||||
async function copyCode(code: string, index: number) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
copiedIndex.value = index
|
||||
setTimeout(() => { copiedIndex.value = null }, 2000)
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = code
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
if (await copyToClipboard(code)) {
|
||||
copiedIndex.value = index
|
||||
setTimeout(() => { copiedIndex.value = null }, 2000)
|
||||
message.success('已复制')
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResultCode(code: string, index: number) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
resultCopiedIdx.value = index
|
||||
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = code
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
if (await copyToClipboard(code)) {
|
||||
resultCopiedIdx.value = index
|
||||
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
|
||||
message.success('已复制')
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAllCodes() {
|
||||
if (resultCdks.value.length === 0) return
|
||||
const all = resultCdks.value.map((c) => c.code).join('\n')
|
||||
try {
|
||||
await navigator.clipboard.writeText(all)
|
||||
if (await copyToClipboard(all)) {
|
||||
copiedAll.value = true
|
||||
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
|
||||
setTimeout(() => { copiedAll.value = false }, 2000)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = all
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
copiedAll.value = true
|
||||
setTimeout(() => { copiedAll.value = false }, 2000)
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +322,8 @@ function statusClass(s: string) {
|
||||
<th class="px-4 py-3 font-medium">CDK码</th>
|
||||
<th v-if="isAdmin" class="px-4 py-3 font-medium">生成代理</th>
|
||||
<th class="px-4 py-3 font-medium">售价</th>
|
||||
<th class="px-4 py-3 font-medium">次数</th>
|
||||
<th class="px-4 py-3 font-medium">类型</th>
|
||||
<th class="px-4 py-3 font-medium">额度</th>
|
||||
<th class="px-4 py-3 font-medium">状态</th>
|
||||
<th class="px-4 py-3 font-medium">创建时间</th>
|
||||
<th class="px-4 py-3 font-medium">操作</th>
|
||||
@@ -350,7 +343,19 @@ function statusClass(s: string) {
|
||||
{{ (cdk as any).agent?.username || cdk.agentId }}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ cdk.price.toFixed(2) }} 元</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">{{ cdk.maxRuns ?? 1 }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
v-if="cdk.type === 'vip'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 whitespace-nowrap"
|
||||
>⭐ VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 whitespace-nowrap"
|
||||
>次数卡</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
|
||||
{{ cdk.type === 'vip' ? `${cdk.vipDays} 天` : `${cdk.maxRuns ?? 1} 次` }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
@@ -445,16 +450,55 @@ function statusClass(s: string) {
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<Label class="mb-2 block text-sm text-gray-600">CDK 类型</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateType === 'count' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-gray-700 border-gray-300 hover:border-emerald-400'"
|
||||
@click="generateType = 'count'"
|
||||
>次数 CDK</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateType === 'vip' ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateType = 'vip'"
|
||||
>⭐ VIP CDK</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">生成数量</Label>
|
||||
<Input v-model.number="generateCount" type="number" min="1" max="100" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">单次最多100个</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
|
||||
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
|
||||
</div>
|
||||
<template v-if="generateType === 'count'">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
|
||||
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">VIP 卡类型</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateVipDays === 7 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateVipDays = 7"
|
||||
>周卡 · 7天</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateVipDays === 30 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateVipDays = 30"
|
||||
>月卡 · 30天</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">兑换后从当前到期时间顺延,到期即享免扣次数执行任务</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
@@ -475,7 +519,10 @@ function statusClass(s: string) {
|
||||
生成成功
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
共 {{ resultCdks.length }} 个CDK · 每张 {{ generateMaxRuns }} 次 · 扣费
|
||||
共 {{ resultCdks.length }} 个CDK ·
|
||||
<template v-if="generateType === 'vip'">{{ generateVipDays === 7 ? 'VIP 周卡 7 天' : 'VIP 月卡 30 天' }} · </template>
|
||||
<template v-else>每张 {{ generateMaxRuns }} 次 · </template>
|
||||
扣费
|
||||
<span class="font-mono text-gray-600">{{ resultCost.toFixed(2) }} 元</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucid
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import api from '@/api/index'
|
||||
|
||||
interface PendingSchoolItem {
|
||||
@@ -102,10 +103,13 @@ async function handleMarkDone(record: PendingSchoolItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
async function copyText(text: string) {
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) {
|
||||
message.success('已复制')
|
||||
})
|
||||
} else {
|
||||
message.error('复制失败,请长按手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -223,8 +227,9 @@ function copyText(text: string) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 账号列表 -->
|
||||
<table class="w-full text-sm">
|
||||
<!-- 账号列表(小屏横向滚动) -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[760px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
|
||||
<th class="px-5 py-2.5 font-medium w-[16%]">账号</th>
|
||||
@@ -304,7 +309,8 @@ function copyText(text: string) {
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const baseDeduction = ref('')
|
||||
const vipWeekPrice = ref('')
|
||||
const vipMonthPrice = ref('')
|
||||
const inviterReward = ref('')
|
||||
const inviteeReward = ref('')
|
||||
const registerReward = ref('')
|
||||
@@ -39,6 +41,8 @@ async function loadSettings() {
|
||||
const res = await adminApi.getSettings()
|
||||
const settings = res.data?.data || {}
|
||||
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
|
||||
vipWeekPrice.value = settings['vip_price_week'] || '30.00'
|
||||
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
|
||||
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
|
||||
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
|
||||
registerReward.value = settings['register_reward'] || '1.00'
|
||||
@@ -123,10 +127,22 @@ async function handleSave() {
|
||||
message.info('直接注册奖励积分请输入有效的非负数')
|
||||
return
|
||||
}
|
||||
const vipWeek = parseFloat(vipWeekPrice.value)
|
||||
if (isNaN(vipWeek) || vipWeek <= 0) {
|
||||
message.info('VIP 周卡售价请输入有效的正数')
|
||||
return
|
||||
}
|
||||
const vipMonth = parseFloat(vipMonthPrice.value)
|
||||
if (isNaN(vipMonth) || vipMonth <= 0) {
|
||||
message.info('VIP 月卡售价请输入有效的正数')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await adminApi.updateSetting('base_deduction_per_run', deduction.toFixed(2))
|
||||
await adminApi.updateSetting('vip_price_week', vipWeek.toFixed(2))
|
||||
await adminApi.updateSetting('vip_price_month', vipMonth.toFixed(2))
|
||||
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
|
||||
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
|
||||
await adminApi.updateSetting('register_reward', regReward.toFixed(2))
|
||||
@@ -168,6 +184,34 @@ async function handleSave() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 周卡售价(元 / 7天)</Label>
|
||||
<Input
|
||||
v-model="vipWeekPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
生成 VIP 周卡 CDK 的单张售价,VIP 有效期内执行任务免扣次数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
|
||||
<Input
|
||||
v-model="vipMonthPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
生成 VIP 月卡 CDK 的单张售价,前台开通/续费 VIP 展示此价格
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button :disabled="saving" @click="handleSave">
|
||||
<Save class="w-4 h-4 mr-1" />
|
||||
|
||||
@@ -47,6 +47,8 @@ interface UserItem {
|
||||
signCount: number
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
inviterId: number | null
|
||||
inviterName: string
|
||||
taskCount: number
|
||||
executedCount: number
|
||||
todayStatus: string
|
||||
@@ -73,6 +75,8 @@ interface UserDetail {
|
||||
signCount: number
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
inviterId: number | null
|
||||
inviterName: string
|
||||
status: string
|
||||
disabledReason: string
|
||||
taskCount: number
|
||||
@@ -103,6 +107,117 @@ const disableTarget = ref<UserItem | null>(null)
|
||||
const disableReason = ref('')
|
||||
const disabling = ref(false)
|
||||
|
||||
// 当天执行日志弹窗
|
||||
interface AdminTaskLogItem {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
realName: string
|
||||
schoolName: string
|
||||
taskType: string
|
||||
title: string
|
||||
realTitle: string
|
||||
isSuccess: boolean
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const showLogsModal = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const logItems = ref<AdminTaskLogItem[]>([])
|
||||
const logsTotal = ref(0)
|
||||
const logsPage = ref(1)
|
||||
const logsPageSize = ref(20)
|
||||
const logsDate = ref(todayStr())
|
||||
const logsStatus = ref<'all' | 'success' | 'fail'>('all')
|
||||
const logsKeyword = ref('')
|
||||
const logsSearchKeyword = ref('')
|
||||
|
||||
const logsStatusOptions = [
|
||||
{ key: 'all' as const, label: '全部' },
|
||||
{ key: 'success' as const, label: '成功' },
|
||||
{ key: 'fail' as const, label: '失败' },
|
||||
]
|
||||
|
||||
const logsTotalPages = computed(() => Math.ceil(logsTotal.value / logsPageSize.value) || 1)
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
function openTodayLogs() {
|
||||
logsDate.value = todayStr()
|
||||
logsStatus.value = 'all'
|
||||
logsKeyword.value = ''
|
||||
logsSearchKeyword.value = ''
|
||||
logsPage.value = 1
|
||||
showLogsModal.value = true
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
async function loadTodayLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const res = await adminApi.getAdminTaskLogs({
|
||||
date: logsDate.value,
|
||||
status: logsStatus.value,
|
||||
keyword: logsSearchKeyword.value,
|
||||
page: logsPage.value,
|
||||
pageSize: logsPageSize.value,
|
||||
})
|
||||
const data = res.data?.data
|
||||
logItems.value = data?.items || []
|
||||
logsTotal.value = data?.total || 0
|
||||
} catch {
|
||||
message.error('加载执行日志失败')
|
||||
logItems.value = []
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogsStatusChange(val: 'all' | 'success' | 'fail') {
|
||||
if (logsStatus.value === val) return
|
||||
logsStatus.value = val
|
||||
logsPage.value = 1
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function handleLogsDateChange(e: Event) {
|
||||
logsDate.value = (e.target as HTMLInputElement).value || todayStr()
|
||||
logsPage.value = 1
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function handleLogsSearch() {
|
||||
logsPage.value = 1
|
||||
logsSearchKeyword.value = logsKeyword.value.trim()
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function handleLogsPage(p: number) {
|
||||
if (p < 1 || p > logsTotalPages.value) return
|
||||
logsPage.value = p
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function formatLogTime(iso: string) {
|
||||
if (!iso) return '--'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function taskTypeLabel(t: string) {
|
||||
if (t === 'sign') return '签到'
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
return t
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
|
||||
const pageSizeOptions = [20, 50, 100]
|
||||
|
||||
@@ -249,14 +364,19 @@ function todayStatusLabel(s: string) {
|
||||
|
||||
<!-- 当天统计卡片 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div
|
||||
class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3 cursor-pointer transition-all hover:border-blue-300 hover:shadow-md group"
|
||||
title="点击查看执行日志明细"
|
||||
@click="openTodayLogs"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<Activity class="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs text-gray-400">当天执行总次数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.executeCount }}</p>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||
@@ -319,17 +439,19 @@ function todayStatusLabel(s: string) {
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm table-fixed min-w-[1080px]">
|
||||
<table class="w-full text-sm table-fixed min-w-[1260px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<th class="px-4 py-3 font-medium w-[11%]">学工号</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">真实姓名</th>
|
||||
<th class="px-4 py-3 font-medium w-[15%]">设备码</th>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">可执行/已执行</th>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">积分/邀请数</th>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">今天执行状态</th>
|
||||
<th class="px-4 py-3 font-medium w-[12%]">创建时间</th>
|
||||
<th class="px-4 py-3 font-medium text-center w-[24%]">操作</th>
|
||||
<th class="px-4 py-3 font-medium w-[9%]">学工号</th>
|
||||
<th class="px-4 py-3 font-medium w-[6%]">真实姓名</th>
|
||||
<th class="px-4 py-3 font-medium w-[13%]">学校</th>
|
||||
<th class="px-4 py-3 font-medium w-[11%]">设备码</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-[7%]">邀请人</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">今天执行状态</th>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">创建时间</th>
|
||||
<th class="px-4 py-3 font-medium text-center w-[20%]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -340,6 +462,7 @@ function todayStatusLabel(s: string) {
|
||||
>
|
||||
<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 truncate text-xs text-gray-600" :title="user.schoolName">{{ user.schoolName || '--' }}</td>
|
||||
<td class="px-4 py-3 truncate font-mono text-xs">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span v-if="user.mobileCode" class="text-gray-700 truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
|
||||
@@ -364,6 +487,10 @@ function todayStatusLabel(s: string) {
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
<span class="text-gray-500">{{ user.invitedCount }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 truncate">
|
||||
<span v-if="user.inviterName" class="text-gray-700" :title="user.inviterName">{{ user.inviterName }}</span>
|
||||
<span v-else class="text-gray-300">--</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
@@ -523,6 +650,12 @@ function todayStatusLabel(s: string) {
|
||||
</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">
|
||||
<UserPlus class="w-3.5 h-3.5" /> 邀请人
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ detailUser.inviterName || '--' }}</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" /> 任务数
|
||||
@@ -600,6 +733,151 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 当天执行日志弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showLogsModal" class="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||
<div class="absolute inset-0 bg-black/40" @click="showLogsModal = false" />
|
||||
<div class="relative w-full sm:max-w-3xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col max-h-[85vh]">
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
|
||||
|
||||
<!-- 头部 -->
|
||||
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">执行日志明细</h3>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ logsDate }} · 共 {{ logsTotal }} 条记录</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
@click="showLogsModal = false"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="px-5 pb-3 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<input
|
||||
type="date"
|
||||
:value="logsDate"
|
||||
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@change="handleLogsDateChange"
|
||||
/>
|
||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in logsStatusOptions"
|
||||
:key="opt.key"
|
||||
class="h-9 px-3 text-xs font-medium transition-colors"
|
||||
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
||||
@click="handleLogsStatusChange(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex-1 min-w-[180px]">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
|
||||
<input
|
||||
v-model="logsKeyword"
|
||||
placeholder="搜索用户名/姓名/任务标题"
|
||||
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@keyup.enter="handleLogsSearch"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" class="h-9 text-sm" @click="handleLogsSearch">
|
||||
<Search class="w-3.5 h-3.5 mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="flex-1 overflow-y-auto border-t border-gray-100">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="logsLoading" class="p-5 space-y-3">
|
||||
<div v-for="i in 6" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="logItems.length === 0" class="text-center py-16 text-gray-400">
|
||||
<ClipboardCheck class="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p class="text-sm">该日期暂无执行记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
|
||||
<th class="px-4 py-2.5 font-medium w-[150px]">时间</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium">任务标题</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[56px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[180px]">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="log in logItems"
|
||||
:key="log.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors align-top"
|
||||
>
|
||||
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap">{{ formatLogTime(log.createdAt) }}</td>
|
||||
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
|
||||
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
|
||||
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
|
||||
{{ taskTypeLabel(log.taskType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
|
||||
{{ log.realTitle || log.title || '--' }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="log.errorMessage" class="text-xs text-red-500 line-clamp-2" :title="log.errorMessage">{{ log.errorMessage }}</span>
|
||||
<span v-else class="text-xs text-gray-300">--</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="logsTotal > 0"
|
||||
class="flex items-center justify-between px-5 py-3 border-t border-gray-100 flex-shrink-0"
|
||||
>
|
||||
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</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="logsPage <= 1"
|
||||
@click="handleLogsPage(logsPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
:disabled="logsPage >= logsTotalPages"
|
||||
@click="handleLogsPage(logsPage + 1)"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Globe, Copy } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
|
||||
// 原始地址(含参数),在系统浏览器中打开后由路由守卫自动回到原页面
|
||||
const targetUrl = computed(() => {
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||
const path = redirect.startsWith('/') ? redirect : `/${redirect}`
|
||||
return `${window.location.origin}${path}`
|
||||
})
|
||||
|
||||
function copyLink() {
|
||||
const text = targetUrl.value
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => message.success('链接已复制,请在浏览器中打开'))
|
||||
.catch(() => fallbackCopy(text))
|
||||
return
|
||||
}
|
||||
fallbackCopy(text)
|
||||
}
|
||||
|
||||
function fallbackCopy(text: string) {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
message.success('链接已复制,请在浏览器中打开')
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制地址栏链接')
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guide-wrap">
|
||||
<div class="guide-card">
|
||||
<!-- 右上角:模拟浏览器菜单 + 手指指向(业界通用引导样式)-->
|
||||
<div class="top-hint" aria-hidden="true">
|
||||
<div class="hand">👆</div>
|
||||
<div class="fake-menu">···</div>
|
||||
</div>
|
||||
|
||||
<div class="icon-circle">
|
||||
<Globe :size="34" :stroke-width="1.8" />
|
||||
</div>
|
||||
<h2 class="title">请在浏览器中打开</h2>
|
||||
<p class="desc">
|
||||
请点击<b>右上角 ···</b>(或 <b>⋮</b>)<br />
|
||||
选择「<b>在浏览器中打开</b>」
|
||||
</p>
|
||||
<Button class="open-btn" size="lg" @click="copyLink">
|
||||
<Copy :size="18" />
|
||||
复制链接去浏览器打开
|
||||
</Button>
|
||||
<p class="tip">如果上方按钮无效,也可以手动复制地址栏链接</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guide-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(160deg, #eef4ff 0%, #f7f9fc 60%, #eefaf3 100%);
|
||||
}
|
||||
|
||||
.guide-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 56px 24px 32px;
|
||||
text-align: center;
|
||||
box-shadow: 0 12px 40px rgba(31, 58, 122, 0.12);
|
||||
}
|
||||
|
||||
/* 右上角菜单图标 + 手指:手指位于菜单左下方、指向右上角 */
|
||||
.top-hint {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fake-menu {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
background: #f3f4f6;
|
||||
border: 1.5px solid #d1d5db;
|
||||
color: #6b7280;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
line-height: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hand {
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
margin-right: 6px;
|
||||
margin-bottom: -2px;
|
||||
transform: rotate(38deg);
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.18));
|
||||
animation: hand-tap 1.1s ease-in-out infinite;
|
||||
transform-origin: 30% 90%;
|
||||
}
|
||||
|
||||
@keyframes hand-tap {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(38deg) translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(38deg) translate(5px, -5px);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #6b7280;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.desc b {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.open-btn {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
font-size: 15px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -88,9 +88,19 @@ async function removeTaskLog(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 设备码绑定状态(新建任务入口拦截用) ========
|
||||
const deviceCode = ref('')
|
||||
const showBindDeviceDialog = ref(false)
|
||||
|
||||
// ======== 初始化 ========
|
||||
onMounted(async () => {
|
||||
// MobileLayout 已加载用户信息并处理绑设备检查
|
||||
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
deviceCode.value = res.data.data?.mobileCode || ''
|
||||
} catch {
|
||||
// 拉取失败不阻塞页面
|
||||
}
|
||||
// 这里直接加载任务数据和日志
|
||||
await loadTasks()
|
||||
await loadTaskLogs()
|
||||
@@ -142,6 +152,11 @@ async function loadTasks() {
|
||||
|
||||
// ======== Step 0: 选择任务标题 ========
|
||||
async function startCreateTask() {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入创建流程
|
||||
if (!deviceCode.value) {
|
||||
showBindDeviceDialog.value = true
|
||||
return
|
||||
}
|
||||
showStep.value = true
|
||||
step.value = 0
|
||||
stepTitle.value = '选择签到任务'
|
||||
@@ -1295,6 +1310,38 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showBindDeviceDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40" @click="showBindDeviceDialog = false" />
|
||||
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Smartphone class="w-7 h-7 text-indigo-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
请先在个人中心绑定设备码,绑定后才能创建和执行签到任务
|
||||
</p>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="showBindDeviceDialog = false; router.push('/profile')"
|
||||
>
|
||||
去绑定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 操作频繁弹窗(限流) ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
|
||||
@@ -27,6 +27,7 @@ const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
const captchaCode = ref('')
|
||||
const loadingCaptcha = ref(false)
|
||||
const captchaError = ref('')
|
||||
|
||||
const logging = ref(false)
|
||||
|
||||
@@ -42,6 +43,7 @@ const reportCaptchaImage = ref('')
|
||||
const reportCaptchaCode = ref('')
|
||||
const reportLoadingCaptcha = ref(false)
|
||||
const reportSubmitting = ref(false)
|
||||
const reportCaptchaError = ref('')
|
||||
|
||||
// 联系邮箱:前缀 + 下拉选择国内邮箱后缀
|
||||
const reportEmailPrefix = ref('')
|
||||
@@ -112,14 +114,25 @@ function handleBlur() {
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
|
||||
function captchaFailMsg(e: unknown): string {
|
||||
const err = e as { response?: { data?: { message?: string } } }
|
||||
return err?.response?.data?.message || '验证码加载失败,请点击重试'
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
loadingCaptcha.value = true
|
||||
captchaError.value = ''
|
||||
try {
|
||||
const res = await authApi.getCaptcha()
|
||||
captchaId.value = res.data?.data?.captchaId || ''
|
||||
captchaImage.value = res.data?.data?.imageBase64 || ''
|
||||
} catch {
|
||||
} catch (e) {
|
||||
captchaId.value = ''
|
||||
captchaImage.value = ''
|
||||
const msg = captchaFailMsg(e)
|
||||
captchaError.value = msg
|
||||
message.error(msg)
|
||||
} finally {
|
||||
loadingCaptcha.value = false
|
||||
}
|
||||
@@ -217,12 +230,17 @@ function handleSubmitTicket() {
|
||||
|
||||
async function loadReportCaptcha() {
|
||||
reportLoadingCaptcha.value = true
|
||||
reportCaptchaError.value = ''
|
||||
try {
|
||||
const res = await authApi.getCaptcha()
|
||||
reportCaptchaId.value = res.data?.data?.captchaId || ''
|
||||
reportCaptchaImage.value = res.data?.data?.imageBase64 || ''
|
||||
} catch {
|
||||
} catch (e) {
|
||||
reportCaptchaId.value = ''
|
||||
reportCaptchaImage.value = ''
|
||||
const msg = captchaFailMsg(e)
|
||||
reportCaptchaError.value = msg
|
||||
message.error(msg)
|
||||
} finally {
|
||||
reportLoadingCaptcha.value = false
|
||||
}
|
||||
@@ -381,6 +399,9 @@ async function handleReportSubmit() {
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="loadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
@@ -564,6 +585,9 @@ async function handleReportSubmit() {
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="reportLoadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare } from 'lucide-vue-next'
|
||||
import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
@@ -56,18 +56,41 @@ const showRedeemModal = ref(false)
|
||||
const redeemCodeText = ref('')
|
||||
const redeeming = ref(false)
|
||||
const redeemError = ref('')
|
||||
// 弹窗打开来源:count=兑换次数 / vip=开通或续费VIP(仅影响标题文案)
|
||||
const redeemKind = ref<'count' | 'vip'>('count')
|
||||
// VIP 信息
|
||||
const isVip = ref(false)
|
||||
const vipExpireAt = ref('')
|
||||
// 验证码
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
const captchaCode = ref('')
|
||||
const captchaError = ref('')
|
||||
|
||||
// VIP 到期时间短格式(2026-10-08)
|
||||
const vipExpireShort = computed(() => {
|
||||
if (!vipExpireAt.value) return ''
|
||||
return vipExpireAt.value.slice(0, 10)
|
||||
})
|
||||
|
||||
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
|
||||
function captchaFailMsg(e: unknown): string {
|
||||
const err = e as { response?: { data?: { message?: string } } }
|
||||
return err?.response?.data?.message || '验证码加载失败,请点击重试'
|
||||
}
|
||||
|
||||
async function refreshCaptcha() {
|
||||
captchaError.value = ''
|
||||
try {
|
||||
const res = await authApi.getCaptcha()
|
||||
captchaId.value = res.data?.data?.captchaId || ''
|
||||
captchaImage.value = res.data?.data?.imageBase64 || ''
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
captchaId.value = ''
|
||||
captchaImage.value = ''
|
||||
const msg = captchaFailMsg(e)
|
||||
captchaError.value = msg
|
||||
message.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +115,14 @@ onMounted(async () => {
|
||||
} catch {
|
||||
message.error('获取用户信息失败')
|
||||
}
|
||||
// 加载 VIP 信息
|
||||
try {
|
||||
const vipRes = await authApi.getVipInfo()
|
||||
isVip.value = vipRes.data.data.isVip
|
||||
vipExpireAt.value = vipRes.data.data.vipExpireAt
|
||||
} catch {
|
||||
// VIP 信息加载失败不阻塞页面
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
@@ -104,8 +135,43 @@ function goRebind() {
|
||||
router.push('/bind-device')
|
||||
}
|
||||
|
||||
// ---- 随机绑定设备码(连续两次风险确认) ----
|
||||
// 0=关闭 1=第一次确认 2=第二次确认
|
||||
const randBindStep = ref(0)
|
||||
const randBinding = ref(false)
|
||||
|
||||
function openRandomBind() {
|
||||
randBindStep.value = 1
|
||||
}
|
||||
|
||||
function closeRandomBind() {
|
||||
randBindStep.value = 0
|
||||
}
|
||||
|
||||
async function confirmRandomBind() {
|
||||
if (randBindStep.value === 1) {
|
||||
// 第一次确认 → 进入第二次确认
|
||||
randBindStep.value = 2
|
||||
return
|
||||
}
|
||||
randBinding.value = true
|
||||
try {
|
||||
const res = await authApi.randomBindDevice()
|
||||
if (userInfo.value) {
|
||||
userInfo.value.mobileCode = res.data.data.deviceCode
|
||||
}
|
||||
randBindStep.value = 0
|
||||
message.success('随机设备码绑定成功')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '绑定失败')
|
||||
} finally {
|
||||
randBinding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 兑换弹窗 ----
|
||||
function openRedeemModal() {
|
||||
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
|
||||
redeemKind.value = kind
|
||||
showRedeemModal.value = true
|
||||
redeemCodeText.value = ''
|
||||
redeemError.value = ''
|
||||
@@ -117,6 +183,17 @@ function closeRedeemModal() {
|
||||
showRedeemModal.value = false
|
||||
}
|
||||
|
||||
// 刷新 VIP 信息(兑换 VIP CDK 后调用)
|
||||
async function reloadVipInfo() {
|
||||
try {
|
||||
const vipRes = await authApi.getVipInfo()
|
||||
isVip.value = vipRes.data.data.isVip
|
||||
vipExpireAt.value = vipRes.data.data.vipExpireAt
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeem() {
|
||||
if (!redeemCodeText.value.trim()) {
|
||||
redeemError.value = '请输入兑换码'
|
||||
@@ -131,12 +208,19 @@ async function handleRedeem() {
|
||||
redeeming.value = true
|
||||
try {
|
||||
const res = await authApi.redeemCDK(redeemCodeText.value.trim(), captchaId.value, captchaCode.value.trim())
|
||||
// 一次性消耗全部剩余次数
|
||||
const totalRuns = res.data.data.totalRuns
|
||||
if (userInfo.value) {
|
||||
userInfo.value.signCount = (userInfo.value.signCount || 0) + totalRuns
|
||||
const data = res.data.data
|
||||
if (data.type === 'vip') {
|
||||
// VIP CDK:延长 VIP 到期时间
|
||||
await reloadVipInfo()
|
||||
message.success(`VIP 兑换成功!已延长至 ${data.vipExpireAt || ''}`)
|
||||
} else {
|
||||
// 次数 CDK:一次性消耗全部剩余次数
|
||||
const totalRuns = data.totalRuns ?? 0
|
||||
if (userInfo.value) {
|
||||
userInfo.value.signCount = (userInfo.value.signCount || 0) + totalRuns
|
||||
}
|
||||
message.success(`兑换成功!获得 ${totalRuns} 次签到次数`)
|
||||
}
|
||||
message.success(`兑换成功!获得 ${totalRuns} 次签到次数`)
|
||||
closeRedeemModal()
|
||||
} catch (err: any) {
|
||||
redeemError.value = err.response?.data?.message || '兑换失败'
|
||||
@@ -209,12 +293,34 @@ async function handleRedeemInvite() {
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
|
||||
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 pt-10 pb-14 text-white text-center flex-shrink-0">
|
||||
<div class="w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-hidden ring-4 ring-white/30">
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
<!-- 头像 + VIP 徽标(头像右上角,反光动画) -->
|
||||
<div class="relative w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-visible ring-4 ring-white/30">
|
||||
<div class="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
</div>
|
||||
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
|
||||
</div>
|
||||
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
|
||||
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
|
||||
|
||||
<!-- VIP 状态行:到期时间 + 开通/续费按钮 -->
|
||||
<div class="mt-3 flex items-center justify-center gap-2">
|
||||
<span
|
||||
v-if="isVip"
|
||||
class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95"
|
||||
>
|
||||
{{ vipExpireShort ? `VIP 至 ${vipExpireShort}` : 'VIP 会员' }}
|
||||
</span>
|
||||
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
|
||||
<button
|
||||
data-guide="profile-vip-btn"
|
||||
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
|
||||
@click="openRedeemModal('vip')"
|
||||
>
|
||||
续费 VIP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情列表 -->
|
||||
@@ -227,13 +333,13 @@ async function handleRedeemInvite() {
|
||||
<ClipboardCheck class="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">可签到次数</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ (userInfo?.signCount ?? 0) }} 次</p>
|
||||
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:brightness-105 transition-all"
|
||||
@click="openRedeemModal"
|
||||
@click="openRedeemModal('count')"
|
||||
>
|
||||
<Gift class="w-3 h-3" />
|
||||
兑换
|
||||
@@ -347,7 +453,7 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</div>
|
||||
<!-- 设备码 -->
|
||||
<div class="flex items-center justify-between px-4 py-3.5">
|
||||
<div data-guide="profile-device-code" class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
@@ -361,13 +467,22 @@ async function handleRedeemInvite() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
重新绑定
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-xs text-amber-600 flex items-center gap-1 hover:text-amber-700"
|
||||
@click="openRandomBind"
|
||||
>
|
||||
<Dices class="w-3 h-3" />
|
||||
随机
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
重新绑定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -392,7 +507,9 @@ async function handleRedeemInvite() {
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900">兑换签到次数</h3>
|
||||
<h3 class="text-base font-semibold text-gray-900">
|
||||
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
|
||||
</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -402,7 +519,7 @@ async function handleRedeemInvite() {
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 兑换码输入 -->
|
||||
<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">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
|
||||
<Input
|
||||
v-model="redeemCodeText"
|
||||
placeholder="请输入 CDK 兑换码"
|
||||
@@ -428,6 +545,14 @@ async function handleRedeemInvite() {
|
||||
@click="refreshCaptcha"
|
||||
title="点击刷新验证码"
|
||||
/>
|
||||
<div
|
||||
v-else-if="captchaError"
|
||||
class="h-10 w-24 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
title="点击重试"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||
加载中
|
||||
</div>
|
||||
@@ -449,6 +574,66 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="randBindStep > 0" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" @click.self="closeRandomBind">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||
<!-- 第一次确认 -->
|
||||
<template v-if="randBindStep === 1">
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-gray-900">随机生成设备码</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5">
|
||||
<div class="flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<TriangleAlert class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-amber-700 leading-relaxed">
|
||||
随机设备码与你真实手机不一致。若你已有绑定的手机在用本系统,随机后该手机执行签到时会出现
|
||||
<strong>「频繁切换设备」提示,无法正常签到</strong>。
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-3">仅建议目前依旧因为其他人帮忙打卡/其他设备打卡导致已经出现「频繁切换设备」提示的用户使用。</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmRandomBind">继续</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 第二次确认 -->
|
||||
<template v-else>
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-red-600">再次确认:确定随机?</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5">
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
随机后,你现有手机登录 App 执行签到时将提示
|
||||
<strong class="text-red-500">「频繁切换设备」而无法正常签到</strong>,
|
||||
需要重新扫码授权绑定才能恢复。
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-2">这是最后一次确认,确定要随机生成设备码吗?</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
|
||||
:disabled="randBinding"
|
||||
@click="confirmRandomBind"
|
||||
>
|
||||
<RefreshCw v-if="randBinding" class="h-4 w-4 animate-spin" />
|
||||
{{ randBinding ? '绑定中...' : '确定随机' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -461,4 +646,72 @@ async function handleRedeemInvite() {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* VIP 徽标:金色渐变 + 反光条扫过动画
|
||||
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
|
||||
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
|
||||
.vip-badge {
|
||||
overflow: hidden;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.5px;
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.65);
|
||||
box-shadow: 0 2px 6px rgba(160, 100, 0, 0.35);
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.vip-badge::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60%;
|
||||
left: -45%;
|
||||
width: 38%;
|
||||
height: 220%;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.85) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: rotate(18deg);
|
||||
animation: vip-shine 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes vip-shine {
|
||||
0% {
|
||||
left: -45%;
|
||||
}
|
||||
55%,
|
||||
100% {
|
||||
left: 135%;
|
||||
}
|
||||
}
|
||||
|
||||
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
|
||||
.vip-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
|
||||
}
|
||||
.vip-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60%;
|
||||
left: -45%;
|
||||
width: 30%;
|
||||
height: 220%;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.7) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: rotate(18deg);
|
||||
animation: vip-shine 3s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user