Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 764bae23fc | |||
| 1f4d601ffa | |||
| 5de7852786 | |||
| 98631afdc5 | |||
| bc4e739e09 | |||
| 9bffdb7026 | |||
| 42c8b66a01 | |||
| 81b2942cad | |||
| 4b1dea3d3c | |||
| 0d665ad0fe | |||
| 67d925bb91 | |||
| bf0e1656ab | |||
| 66d07781be | |||
| 99c16652a7 | |||
| cc4aa1c00e | |||
| 15eceea38a | |||
| a9dbc2661d | |||
| ebc94ec9a0 | |||
| 98cf8666f6 |
+17
-32
@@ -26,32 +26,18 @@ steps:
|
||||
|
||||
IMAGE_TAG="${CI_COMMIT_TAG:-latest}"
|
||||
|
||||
# 确保网络存在
|
||||
docker network create likedream-net 2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# 第一步:初始化 1Panel 站点配置(首次部署时才需要)
|
||||
# ============================================================
|
||||
# nginx 配置文件在 git clone 后位于 deploy/nginx/ 目录
|
||||
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
|
||||
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
|
||||
|
||||
if ! docker exec 1Panel-openresty-ESmn ls /www/sites/xiaozhushou/proxy/api.conf 2>/dev/null; then
|
||||
echo "=== 首次部署:创建 1Panel 站点配置 ==="
|
||||
docker exec 1Panel-openresty-ESmn mkdir -p /www/sites/xiaozhushou/proxy /www/sites/xiaozhushou/log
|
||||
docker cp deploy/nginx/xiaozhushou.conf 1Panel-openresty-ESmn:/usr/local/openresty/nginx/conf/conf.d/xiaozhushou.conf
|
||||
docker cp deploy/nginx/api.conf 1Panel-openresty-ESmn:/www/sites/xiaozhushou/proxy/api.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " 1Panel 站点配置完成 ✓"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 第二步:读取当前代理端口,确定蓝绿状态
|
||||
# 第一步:读取当前反代端口,确定蓝绿状态
|
||||
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
|
||||
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
|
||||
# ============================================================
|
||||
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
|
||||
grep -o 'proxy_pass http://10.10.10.128:[0-9]\+' \
|
||||
/www/sites/xiaozhushou/proxy/api.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前 1Panel 反代端口: $CURRENT_PORT ==="
|
||||
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前反代端口: $CURRENT_PORT ==="
|
||||
|
||||
if [ "$CURRENT_PORT" = "8884" ]; then
|
||||
PORT_ACTIVE=8884
|
||||
@@ -60,9 +46,8 @@ steps:
|
||||
PORT_ACTIVE=8885
|
||||
PORT_STANDBY=8884
|
||||
else
|
||||
echo "未知当前端口 $CURRENT_PORT,默认使用 8884/8885"
|
||||
PORT_ACTIVE=8884
|
||||
PORT_STANDBY=8885
|
||||
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)"
|
||||
exit 1
|
||||
fi
|
||||
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
|
||||
|
||||
@@ -70,7 +55,7 @@ steps:
|
||||
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# 第三步:在备用端口启动新容器
|
||||
# 第二步:在备用端口启动新容器
|
||||
# ============================================================
|
||||
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
|
||||
|
||||
@@ -83,7 +68,7 @@ steps:
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
|
||||
# ============================================================
|
||||
# 第四步:健康检查
|
||||
# 第三步:健康检查
|
||||
# ============================================================
|
||||
HEALTHY=false
|
||||
for i in $(seq 1 15); do
|
||||
@@ -104,24 +89,24 @@ steps:
|
||||
echo " 新容器已就绪 ✓"
|
||||
|
||||
# ============================================================
|
||||
# 第五步:切换 1Panel 代理到备用端口
|
||||
# 第四步:切换 1Panel 反代到备用端口
|
||||
# ============================================================
|
||||
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
|
||||
/www/sites/xiaozhushou/proxy/api.conf
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
|
||||
# ============================================================
|
||||
# 第六步:停止旧容器
|
||||
# 第五步:停止旧容器
|
||||
# ============================================================
|
||||
echo "=== 清理旧容器 ==="
|
||||
docker stop cpdaily-xiaozhushou 2>/dev/null || true
|
||||
docker rm cpdaily-xiaozhushou 2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# 第七步:在活跃端口启动正式容器
|
||||
# 第六步:在活跃端口启动正式容器
|
||||
# ============================================================
|
||||
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
|
||||
docker run -d --name cpdaily-xiaozhushou \
|
||||
@@ -133,17 +118,17 @@ steps:
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
|
||||
# ============================================================
|
||||
# 第八步:切回 1Panel 代理到活跃端口
|
||||
# 第七步:切回 1Panel 反代到活跃端口
|
||||
# ============================================================
|
||||
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
|
||||
/www/sites/xiaozhushou/proxy/api.conf
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
|
||||
# ============================================================
|
||||
# 第九步:清理
|
||||
# 第八步:清理
|
||||
# ============================================================
|
||||
echo "=== 清理 ==="
|
||||
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
|
||||
|
||||
@@ -45,9 +45,9 @@ func main() {
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Cpdaily New",
|
||||
// 信任 openresty 反代(host 网络,经 docker NAT 后源 IP 为 172.22.0.1),
|
||||
// 从 X-Forwarded-For 取真实客户端 IP(如待适配学校提交记录)
|
||||
TrustedProxies: []string{"172.22.0.0/16"},
|
||||
// 信任 openresty 反代(frpc 经 PROXY protocol 透传后,openresty 直接来源为 127.0.0.1;
|
||||
// 宿主机 docker NAT 场景来源为 172.22.0.1),从 X-Forwarded-For 取真实客户端 IP
|
||||
TrustedProxies: []string{"172.22.0.0/16", "127.0.0.1"},
|
||||
EnableTrustedProxyCheck: true,
|
||||
ProxyHeader: fiber.HeaderXForwardedFor,
|
||||
})
|
||||
@@ -96,6 +96,7 @@ func main() {
|
||||
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")
|
||||
@@ -124,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)
|
||||
@@ -179,6 +182,7 @@ func main() {
|
||||
// 管理后台 - 待适配学校
|
||||
adminAuth.Get("/pending-schools", handler.AdminListPendingSchools)
|
||||
adminAuth.Post("/pending-schools/:id/mark-done", handler.AdminMarkSchoolDone)
|
||||
adminAuth.Post("/pending-schools/:id/reject", handler.AdminRejectSchool)
|
||||
|
||||
// 健康检查(用于蓝绿部署)
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
|
||||
@@ -29,6 +29,8 @@ type SysUserListItem struct {
|
||||
InviterName string `json:"inviterName"` // 邀请人展示名(姓名优先,其次用户名)
|
||||
TaskCount int64 `json:"taskCount"`
|
||||
ExecutedCount int64 `json:"executedCount"` // 累计成功执行次数
|
||||
VipStatus string `json:"vipStatus"` // vip: active / expired / none
|
||||
VipExpireAt string `json:"vipExpireAt"` // VIP 到期时间,未开通为空
|
||||
TodayStatus string `json:"todayStatus"` // today: success / fail / none
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
@@ -174,6 +176,17 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
if u.InviterID != nil {
|
||||
inviterName = inviterNameMap[*u.InviterID]
|
||||
}
|
||||
// VIP 状态:active=有效期内 / expired=已过期 / none=未开通
|
||||
vipStatus := "none"
|
||||
vipExpireAt := ""
|
||||
if u.VipExpireAt != nil {
|
||||
vipExpireAt = u.VipExpireAt.Format("2006-01-02 15:04")
|
||||
if u.VipExpireAt.After(time.Now()) {
|
||||
vipStatus = "active"
|
||||
} else {
|
||||
vipStatus = "expired"
|
||||
}
|
||||
}
|
||||
items = append(items, SysUserListItem{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
@@ -190,6 +203,8 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
InviterName: inviterName,
|
||||
TaskCount: taskCountMap[u.ID],
|
||||
ExecutedCount: executedMap[u.ID],
|
||||
VipStatus: vipStatus,
|
||||
VipExpireAt: vipExpireAt,
|
||||
TodayStatus: todayStatus,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
@@ -322,3 +337,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -133,15 +133,16 @@ type ReportFailedLoginRequest struct {
|
||||
}
|
||||
|
||||
type PendingSchoolRes struct {
|
||||
ID uint `json:"id"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID uint `json:"id"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
RejectReason string `json:"rejectReason"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ---------- 用户端 API ----------
|
||||
@@ -268,6 +269,8 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
|
||||
query = query.Where("status = ?", "pending")
|
||||
} else if status == "completed" {
|
||||
query = query.Where("status = ?", "completed")
|
||||
} else if status == "rejected" {
|
||||
query = query.Where("status = ?", "rejected")
|
||||
}
|
||||
if err := query.Find(&records).Error; err != nil {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
@@ -280,15 +283,16 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
|
||||
ip = "-"
|
||||
}
|
||||
items = append(items, PendingSchoolRes{
|
||||
ID: r.ID,
|
||||
SchoolID: r.SchoolID,
|
||||
SchoolName: r.SchoolName,
|
||||
Username: r.Username,
|
||||
Password: r.Password,
|
||||
Email: r.Email,
|
||||
IP: ip,
|
||||
Status: r.Status,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
ID: r.ID,
|
||||
SchoolID: r.SchoolID,
|
||||
SchoolName: r.SchoolName,
|
||||
Username: r.Username,
|
||||
Password: r.Password,
|
||||
Email: r.Email,
|
||||
IP: ip,
|
||||
Status: r.Status,
|
||||
RejectReason: r.RejectReason,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -333,3 +337,49 @@ func AdminMarkSchoolDone(c *fiber.Ctx) error {
|
||||
|
||||
return utils.Success(c, fiber.Map{"completed": true})
|
||||
}
|
||||
|
||||
// AdminRejectSchool 拒绝某条待适配学校记录(拒绝必须说明理由,理由将邮件通知联系邮箱)
|
||||
func AdminRejectSchool(c *fiber.Ctx) error {
|
||||
recordID, err := c.ParamsInt("id")
|
||||
if err != nil {
|
||||
return utils.FailParam(c, "无效的记录ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
if req.Reason == "" {
|
||||
return utils.FailParam(c, "拒绝时必须说明理由")
|
||||
}
|
||||
if len([]rune(req.Reason)) > 500 {
|
||||
return utils.FailParam(c, "拒绝理由不能超过500字")
|
||||
}
|
||||
|
||||
var record model.PendingSchool
|
||||
if err := database.DB.First(&record, recordID).Error; err != nil {
|
||||
return utils.Fail(c, 404, "记录不存在")
|
||||
}
|
||||
if record.Status != "pending" {
|
||||
return utils.Fail(c, 400, "该记录已处理,无法拒绝")
|
||||
}
|
||||
|
||||
// 仅拒绝当前这条记录(单条拒绝,不影响同校其他记录)
|
||||
database.DB.Model(&record).Updates(map[string]interface{}{
|
||||
"status": "rejected",
|
||||
"reject_reason": req.Reason,
|
||||
})
|
||||
|
||||
// 邮件只通知该条记录留下的联系邮箱(附拒绝理由)
|
||||
contactEmails := service.SplitEmailsList(record.Email)
|
||||
|
||||
slog.Info("待适配学校记录已被拒绝", "recordId", record.ID, "schoolId", record.SchoolID, "schoolName", record.SchoolName, "reason", req.Reason)
|
||||
|
||||
// 异步邮件通知该条记录的联系邮箱(附拒绝理由)
|
||||
service.NotifySchoolRejected(contactEmails, record.SchoolName, req.Reason)
|
||||
|
||||
return utils.Success(c, fiber.Map{"rejected": true})
|
||||
}
|
||||
|
||||
@@ -157,14 +157,14 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 从 cpdaily 拉取任务详情
|
||||
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err := cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
if err != nil {
|
||||
// cookie 过期时自动尝试重新登录
|
||||
if isCookieExpired(err) {
|
||||
slog.Info("获取任务表单cookie过期,尝试刷新", "taskId", id)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,30 @@ type SaveTaskFormRequest struct {
|
||||
FormData []model.CpTaskFormItem `json:"formData"`
|
||||
}
|
||||
|
||||
// missingRequiredFormFields 返回表单中「必填但内容为空」的字段标题列表。
|
||||
// 学校表单模板中必填项为空时提交必然失败(如签到地点未配置 → 服务端报"签到地点纬度不能为空"),
|
||||
// 因此在执行前拦截并给出明确提示,而不是等提交失败后只报笼统错误。
|
||||
func missingRequiredFormFields(formData string) []string {
|
||||
var items []model.CpTaskFormItem
|
||||
if err := json.Unmarshal([]byte(formData), &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
var missing []string
|
||||
for _, it := range items {
|
||||
if !it.Required || !it.Show {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(it.Content) == "" {
|
||||
name := it.Title
|
||||
if name == "" {
|
||||
name = it.ColName
|
||||
}
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func SaveTaskForm(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
@@ -359,6 +383,9 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
if task.FormData == "" {
|
||||
return utils.Fail(c, 400, "请先配置表单数据")
|
||||
}
|
||||
if missing := missingRequiredFormFields(task.FormData); len(missing) > 0 {
|
||||
return utils.Fail(c, 400, "签到信息未配置完整,缺少:"+strings.Join(missing, "、")+",请编辑任务补全后再执行")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
@@ -428,14 +455,14 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 2. 获取表单详情
|
||||
// 2. 获取表单详情(按任务名从当天列表实时匹配实例,wid 每次执行时自动获取)
|
||||
sendEvent("fetching_form", map[string]interface{}{"status": "fetching_form", "message": "正在获取签到表单..."})
|
||||
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err := cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取签到表单cookie过期,尝试自动刷新", "taskId", id)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
@@ -456,15 +483,10 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 尝试获取实际标题(用于错误消息和日志中的真实签到/查寝名称)
|
||||
if task.RealTitle == "" {
|
||||
todayTasks, _ := cpd.FetchTodayTasks(cookieMap, host, cpd.TaskType(task.TaskType))
|
||||
for _, t := range todayTasks {
|
||||
if t.SignInstanceWid == task.SignInstanceWid && t.TaskName != "" {
|
||||
task.RealTitle = t.TaskName
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
break
|
||||
}
|
||||
}
|
||||
// 详情来自当天列表匹配,RealTitle 即列表中的真实任务名
|
||||
if task.RealTitle == "" && detail.RealTitle != "" && detail.RealTitle != task.Title {
|
||||
task.RealTitle = detail.RealTitle
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
}
|
||||
|
||||
// 3. 反序列化用户配置的表单
|
||||
@@ -486,6 +508,23 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
|
||||
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
|
||||
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
|
||||
previewUrl, upErr := cpd.UploadDataURIImage(cookieMap, host, cpd.TaskType(task.TaskType), photo)
|
||||
if upErr != nil {
|
||||
errMsg := "照片上传失败: " + upErr.Error()
|
||||
slog.Error("照片上传失败", "taskId", id, "error", upErr)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
submitForm["signPhotoUrl"] = previewUrl
|
||||
}
|
||||
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// touchDailyLogin 补齐“当天已登录”标记:JWT 有效期较长且可静默续期,
|
||||
// 用户当天在用平台也不会真正重新登录,后台“当天登录人数”会因此偏少。
|
||||
// 这里在认证通过后做一次条件更新:仅当 last_login_at 为空或还停留在昨天及以前时才写入今天,
|
||||
// 即每个用户每天最多产生一次真实写库;并发请求重复触发也只会写同一天,其余请求仅做主键定位、零写入。
|
||||
// 当天起点用 Go 本地时区(与后台统计的 “YYYY-MM-DD 00:00:00” 口径一致)。
|
||||
func touchDailyLogin(userID uint) {
|
||||
now := time.Now()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
database.DB.Model(&model.SysUser{}).
|
||||
Where("id = ? AND (last_login_at IS NULL OR last_login_at < ?)", userID, todayStart).
|
||||
Update("last_login_at", now)
|
||||
}
|
||||
|
||||
func AuthRequired() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
tokenStr := ""
|
||||
@@ -38,6 +54,8 @@ func AuthRequired() fiber.Handler {
|
||||
c.Locals("userId", claims.UserID)
|
||||
c.Locals("username", claims.Username)
|
||||
c.Locals("schoolId", claims.SchoolID)
|
||||
// 当天首次使用平台时把 last_login_at 顶到今天(详见 touchDailyLogin 注释)
|
||||
touchDailyLogin(claims.UserID)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@ import (
|
||||
|
||||
// PendingSchool 待适配学校(用户提交的测试账号)
|
||||
type PendingSchool struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
|
||||
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
|
||||
Username string `gorm:"size:100;not null" json:"username"`
|
||||
Password string `gorm:"size:200;not null" json:"password"`
|
||||
Email string `gorm:"size:100" json:"email"`
|
||||
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
|
||||
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
|
||||
Username string `gorm:"size:100;not null" json:"username"`
|
||||
Password string `gorm:"size:200;not null" json:"password"`
|
||||
Email string `gorm:"size:100" json:"email"`
|
||||
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
|
||||
RejectReason string `gorm:"size:500" json:"rejectReason"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (PendingSchool) TableName() string {
|
||||
|
||||
@@ -3,10 +3,13 @@ package cpdaily
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
@@ -14,11 +17,12 @@ import (
|
||||
|
||||
// CpImageUploadPolicy 今日校园 OBS 上传策略
|
||||
type CpImageUploadPolicy struct {
|
||||
AccessId string `json:"accessid"`
|
||||
FileName string `json:"fileName"`
|
||||
Policy string `json:"policy"`
|
||||
Host string `json:"host"`
|
||||
Signature string `json:"signature"`
|
||||
AccessId string `json:"accessid"`
|
||||
FileName string `json:"fileName"`
|
||||
Policy string `json:"policy"`
|
||||
Host string `json:"host"`
|
||||
Signature string `json:"signature"`
|
||||
FormData map[string]string `json:"formData"`
|
||||
}
|
||||
|
||||
// getImageUrlPrefix 根据任务类型返回图片上传的 URL 前缀
|
||||
@@ -75,7 +79,11 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
|
||||
return result.Datas, nil
|
||||
}
|
||||
|
||||
// uploadToOBS 将文件上传到今日校园的 OBS
|
||||
// uploadToOBS 将文件上传到今日校园的 OBS。
|
||||
// 优先使用接口返回的 formData 字段集原样提交:不同任务类型的表单字段不同
|
||||
// (查寝:AccessKeyId/signature/x-obs-acl;签到:q-ak/q-key-time/q-signature 等),
|
||||
// 按旧方式自行拼装字段会导致签到类型上传被 OBS 拒绝(AccessDenied)。
|
||||
// 仅当接口未返回 formData 时回退到通用字段拼装。
|
||||
func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType string) error {
|
||||
client := resty.New().SetTimeout(30 * time.Second).SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
|
||||
@@ -87,15 +95,26 @@ func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType strin
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
writer.WriteField("x-obs-acl", "public-read")
|
||||
writer.WriteField("AccessKeyId", policy.AccessId)
|
||||
writer.WriteField("key", policy.FileName)
|
||||
writer.WriteField("signature", policy.Signature)
|
||||
writer.WriteField("policy", policy.Policy)
|
||||
writer.WriteField("content-type", contentType)
|
||||
if len(policy.FormData) > 0 {
|
||||
for k, v := range policy.FormData {
|
||||
writer.WriteField(k, v)
|
||||
}
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", `form-data; name="file"; filename="file.jpg"`)
|
||||
header.Set("Content-Type", contentType)
|
||||
part, _ := writer.CreatePart(header)
|
||||
part.Write(fileData)
|
||||
} else {
|
||||
writer.WriteField("x-obs-acl", "public-read")
|
||||
writer.WriteField("AccessKeyId", policy.AccessId)
|
||||
writer.WriteField("key", policy.FileName)
|
||||
writer.WriteField("signature", policy.Signature)
|
||||
writer.WriteField("policy", policy.Policy)
|
||||
writer.WriteField("content-type", contentType)
|
||||
|
||||
part, _ := writer.CreateFormFile("file", "file")
|
||||
part.Write(fileData)
|
||||
part, _ := writer.CreateFormFile("file", "file")
|
||||
part.Write(fileData)
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
resp, err := client.R().
|
||||
@@ -145,15 +164,33 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
var result struct {
|
||||
ErrCode int `json:"errCode"`
|
||||
Code json.RawMessage `json:"code"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Message string `json:"message"`
|
||||
Datas json.RawMessage `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w", err)
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return "", fmt.Errorf("获取预览 URL 错误(%d): %s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
// 部分学校(如 cyprxy 代理域)返回 {"code":"0",...} 方言
|
||||
if len(result.Code) > 0 {
|
||||
var codeStr string
|
||||
if json.Unmarshal(result.Code, &codeStr) == nil {
|
||||
if codeStr != "" && codeStr != "0" {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = result.ErrMsg
|
||||
}
|
||||
return "", fmt.Errorf("获取预览 URL 错误(%s): %s", codeStr, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(result.Datas) == 0 || string(result.Datas) == "null" {
|
||||
return "", fmt.Errorf("获取预览 URL 为空(body: %.200s)", string(body))
|
||||
}
|
||||
|
||||
var previewUrl string
|
||||
if err := json.Unmarshal(result.Datas, &previewUrl); err != nil {
|
||||
@@ -164,6 +201,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
}
|
||||
|
||||
// UploadImage 完整的图片上传流程:获取策略 → 上传 OBS → 获取预览 URL
|
||||
// 预览接口偶发返回空响应,做少量重试避免误判失败
|
||||
func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fileData []byte, contentType string) (string, error) {
|
||||
policy, err := GetUploadPolicy(cookieMap, host, taskType)
|
||||
if err != nil {
|
||||
@@ -174,17 +212,47 @@ func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fi
|
||||
return "", err
|
||||
}
|
||||
|
||||
previewUrl, err := GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var previewUrl string
|
||||
var lastErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(time.Duration(i) * time.Second)
|
||||
}
|
||||
previewUrl, lastErr = GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
|
||||
if lastErr == nil {
|
||||
return previewUrl, nil
|
||||
}
|
||||
// 业务性错误(错误码/数据为空)无需重试,仅空响应类瞬时错误重试
|
||||
if !strings.Contains(lastErr.Error(), "解析预览 URL 响应失败") && !strings.Contains(lastErr.Error(), "获取预览 URL 为空") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return previewUrl, nil
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
// IsDataURIImage 判断内容是否为 base64 图片(前端本地预览,尚未成功上传到 OBS)
|
||||
func IsDataURIImage(content string) bool {
|
||||
return strings.HasPrefix(content, "data:image")
|
||||
}
|
||||
|
||||
// UploadDataURIImage 将 base64 图片解码后走正常 OBS 上传流程,返回正式预览 URL。
|
||||
// 用于执行时自动修复历史表单中残留的 base64 照片(用户上传失败时保存的内容)。
|
||||
func UploadDataURIImage(cookieMap map[string]string, host string, taskType TaskType, dataURI string) (string, error) {
|
||||
idx := strings.Index(dataURI, ",")
|
||||
if idx < 0 {
|
||||
return "", fmt.Errorf("图片数据格式不正确")
|
||||
}
|
||||
imgBytes, err := base64.StdEncoding.DecodeString(dataURI[idx+1:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码图片失败: %w", err)
|
||||
}
|
||||
return UploadImage(cookieMap, host, taskType, imgBytes, "image/jpeg")
|
||||
}
|
||||
|
||||
func newImgClient() *resty.Client {
|
||||
return resty.New().
|
||||
SetTimeout(30*time.Second).
|
||||
SetTimeout(30 * time.Second).
|
||||
SetDoNotParseResponse(true).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 合肥师范学院专用登录适配 ==========
|
||||
//
|
||||
// 该校(hfnu.campusphere.net)登录走 portal.hfnu.edu.cn 老式 jasig CAS
|
||||
// (/sso/login,webflow lt/e1sX 风格),与通用 CAS 流程存在两处不兼容,
|
||||
// 故单独适配:
|
||||
// 1. 会话用 URL 重写传递(form action 形如 /sso/login;jsessionid=xxx?service=...),
|
||||
// 服务器不通过 Set-Cookie 建立 session,POST 必须发到 form 的 action
|
||||
// 地址(带 ;jsessionid),否则 webflow 会话丢失、静默回到登录页;
|
||||
// 2. 每次登录都要求图形验证码(页面 JS: codeupdate==0 → 强制显示
|
||||
// #code_validate),验证码图片 /sso/code/code.jsp,4 位纯数字 JPEG,
|
||||
// 提交字段为 validateCode。通用流程探测的 /authserver/needCaptcha.html
|
||||
// 在该校返回空 body,会被误判为"不需要验证码"。
|
||||
//
|
||||
// 密码为明文提交(页面无加密 salt),登录成功后 302 回跳
|
||||
// ehall.hfnu.edu.cn/amp-auth-adapter/loginSuccess?sessionToken=...&ticket=...
|
||||
|
||||
// HfnuSchoolId 合肥师范学院 tenant id
|
||||
const HfnuSchoolId = "hfnu"
|
||||
|
||||
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
|
||||
|
||||
// hfnuLogin 合肥师范学院专用登录
|
||||
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
loginUrl := getCasValidLoginUrl(school)
|
||||
if loginUrl == "" {
|
||||
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
|
||||
}
|
||||
slog.Info("合肥师范专用登录", "loginUrl", loginUrl)
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. GET 登录页(跟随重定向到 portal.hfnu.edu.cn/sso/login)
|
||||
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求登录页面失败: %w", err)
|
||||
}
|
||||
pageBody := readBody(loginPageResp)
|
||||
pageURL := loginPageResp.RawResponse.Request.URL
|
||||
slog.Info("合肥师范登录页", "finalUrl", pageURL.String(), "bodyLen", len(pageBody))
|
||||
|
||||
// 2. 解析 form action(含 ;jsessionid)与 lt
|
||||
postUrl, lt, err := parseHfnuLoginForm(pageBody, pageURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("合肥师范CAS表单", "postUrl", postUrl, "lt", lt)
|
||||
|
||||
// 3. 获取验证码并 OCR(该校每次登录都需要)
|
||||
captchaCode, err := resolveHfnuCaptcha(ctx, pageURL, postUrl, sc.Client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("合肥师范验证码OCR", "code", captchaCode)
|
||||
|
||||
// 4. 提交登录(禁用重定向,登录成功返回 302)
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
loginResp, err := sc.R().
|
||||
SetContext(loginCtx).
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"validateCode": captchaCode,
|
||||
"lt": lt,
|
||||
"_eventId": "submit",
|
||||
}).
|
||||
Post(postUrl)
|
||||
if err != nil {
|
||||
if loginResp != nil && loginResp.StatusCode() == http.StatusFound {
|
||||
slog.Info("合肥师范登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
|
||||
} else {
|
||||
return nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("合肥师范登录响应", "statusCode", loginResp.StatusCode())
|
||||
switch loginResp.StatusCode() {
|
||||
case http.StatusFound:
|
||||
return handleCasLoginSuccess(ctx, loginResp, school, sc)
|
||||
case http.StatusOK, http.StatusUnauthorized:
|
||||
// 2026-09 排查:该校认证流程本身可用(账密/验证码校验均可通过),
|
||||
// 但 CAS 认证通过后校方 amp-auth-adapter 单点跳转服务拒绝授权
|
||||
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
|
||||
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
|
||||
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
|
||||
msg := parseHfnuLoginError(readBody(loginResp))
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
default:
|
||||
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||
}
|
||||
}
|
||||
|
||||
// parseHfnuLoginForm 从登录页解析 form action(绝对化,保留 ;jsessionid)与 lt
|
||||
func parseHfnuLoginForm(page []byte, pageURL *url.URL) (string, string, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(page)))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("解析登录页失败: %w", err)
|
||||
}
|
||||
form := doc.Find("#credentials")
|
||||
if form.Length() == 0 {
|
||||
form = doc.Find("form").First()
|
||||
}
|
||||
if form.Length() == 0 {
|
||||
return "", "", fmt.Errorf("登录页未找到登录表单")
|
||||
}
|
||||
|
||||
action, _ := form.Attr("action")
|
||||
postUrl := ""
|
||||
if action != "" {
|
||||
if u, err := pageURL.Parse(action); err == nil {
|
||||
postUrl = u.String()
|
||||
}
|
||||
}
|
||||
if postUrl == "" {
|
||||
return "", "", fmt.Errorf("登录页未找到表单提交地址")
|
||||
}
|
||||
|
||||
lt := ""
|
||||
form.Find("input").Each(func(_ int, s *goquery.Selection) {
|
||||
name, _ := s.Attr("name")
|
||||
if name == "lt" {
|
||||
lt, _ = s.Attr("value")
|
||||
}
|
||||
})
|
||||
if lt == "" {
|
||||
return "", "", fmt.Errorf("登录页未找到 lt 参数")
|
||||
}
|
||||
return postUrl, lt, nil
|
||||
}
|
||||
|
||||
// resolveHfnuCaptcha 拉取 /sso/code/code.jsp 验证码并 OCR。
|
||||
// 该校 session 走 URL 重写,验证码请求需带上与 form action 相同的 ;jsessionid。
|
||||
func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, client *resty.Client) (string, error) {
|
||||
// 登录页路径为 /sso/login;相对路径 code/code.jsp 基于其解析 → /sso/code/code.jsp
|
||||
captchaUrl, err := pageURL.Parse("code/code.jsp")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析验证码地址失败: %w", err)
|
||||
}
|
||||
// 该校不依赖 Cookie 而用 URL 重写:把 form action 中的 jsessionid 拼到验证码路径
|
||||
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
|
||||
captchaUrl.Path = captchaUrl.Path + ";jsessionid=" + m[1]
|
||||
}
|
||||
captchaUrl.RawQuery = fmt.Sprintf("t=%.3d", time.Now().UnixNano()%1000)
|
||||
slog.Info("合肥师范验证码", "url", captchaUrl.String())
|
||||
|
||||
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl.String())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取验证码失败: %w", err)
|
||||
}
|
||||
imgData := readBody(imgResp)
|
||||
if len(imgData) == 0 {
|
||||
return "", fmt.Errorf("验证码图片为空")
|
||||
}
|
||||
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
|
||||
|
||||
code := captchaOCR(imgData)
|
||||
if code == "" {
|
||||
return "", fmt.Errorf("验证码识别失败,请重试")
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// parseHfnuLoginError 解析登录失败页的错误提示
|
||||
func parseHfnuLoginError(body []byte) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))
|
||||
if err == nil {
|
||||
if msg := strings.TrimSpace(doc.Find("li.err").Text()); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return "登录失败,请检查账号密码或稍后重试"
|
||||
}
|
||||
@@ -163,6 +163,11 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
if school.ID == NutSchoolId {
|
||||
return nutCasIapLogin(ctx, school, username, password)
|
||||
}
|
||||
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
|
||||
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
|
||||
if school.ID == HfnuSchoolId {
|
||||
return iapLogin(ctx, school, username, password)
|
||||
}
|
||||
if school.IsIap() || iapSchoolIds[school.ID] {
|
||||
if !hasUsableLoginUrl(school) {
|
||||
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -141,25 +140,22 @@ func (r *submitCaptchaResolver) GetCaptchaTicket(info *ValidationInfo) (string,
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// createCaptcha 创建验证码(JSON body)
|
||||
// createCaptcha 创建验证码(必须用 multipart,JSON body 会返回空壳响应导致验证码识别为空)
|
||||
func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCaptcha, error) {
|
||||
baseURL := strings.TrimSuffix(r.host, "/")
|
||||
url := baseURL + "/captcha-open-api/v1/captcha/create/scenesImage"
|
||||
|
||||
client := newTaskClient()
|
||||
|
||||
reqData := map[string]string{
|
||||
"accountKey": info.AccountKey,
|
||||
"sceneCode": info.SceneCode,
|
||||
"tenantId": info.TenantId,
|
||||
"userId": info.UserId,
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetHeader("Cookie", r.cookieStr).
|
||||
SetHeader("deviceId", r.deviceId).
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetBody(reqData).
|
||||
SetMultipartFormData(map[string]string{
|
||||
"accountKey": info.AccountKey,
|
||||
"sceneCode": info.SceneCode,
|
||||
"tenantId": info.TenantId,
|
||||
"userId": info.UserId,
|
||||
}).
|
||||
Post(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建验证码请求失败: %w", err)
|
||||
@@ -229,12 +225,11 @@ func (r *submitCaptchaResolver) getResultFromItStar(rawResponse json.RawMessage)
|
||||
return itResp.Data, nil
|
||||
}
|
||||
|
||||
// submitCaptcha 提交验证码结果(application/x-www-form-urlencoded)
|
||||
// submitCaptcha 提交验证码结果(multipart,与旧版一致)
|
||||
func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo *CpSubmitCaptcha, codes []string) (string, error) {
|
||||
baseURL := strings.TrimSuffix(r.host, "/")
|
||||
targetURL := baseURL + "/captcha-open-api/v1/captcha/validate/scenesImage"
|
||||
|
||||
// 构建 URL-encoded 表单数据
|
||||
if captchaInfo.Code == "" {
|
||||
return "", fmt.Errorf("创建验证码返回的场景码为空")
|
||||
}
|
||||
@@ -242,23 +237,22 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
|
||||
return "", fmt.Errorf("验证码识别结果为空,不提交空验证码")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("accountKey", info.AccountKey)
|
||||
form.Set("sceneCode", info.SceneCode)
|
||||
form.Set("tenantId", info.TenantId)
|
||||
form.Set("userId", info.UserId)
|
||||
form.Set("scenesImageCode", captchaInfo.Code)
|
||||
for _, code := range codes {
|
||||
form.Add("scenesImageCodes", code)
|
||||
}
|
||||
|
||||
client := newTaskClient()
|
||||
resp, err := client.R().
|
||||
req := client.R().
|
||||
SetHeader("Cookie", r.cookieStr).
|
||||
SetHeader("deviceId", r.deviceId).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||
SetBody(form.Encode()).
|
||||
Post(targetURL)
|
||||
SetMultipartFormData(map[string]string{
|
||||
"accountKey": info.AccountKey,
|
||||
"sceneCode": info.SceneCode,
|
||||
"tenantId": info.TenantId,
|
||||
"userId": info.UserId,
|
||||
"scenesImageCode": captchaInfo.Code,
|
||||
})
|
||||
for _, code := range codes {
|
||||
req = req.SetMultipartField("scenesImageCodes", "", "", strings.NewReader(code))
|
||||
}
|
||||
|
||||
resp, err := req.Post(targetURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("提交验证码请求失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ func FetchHistoryFormData(cookieMap map[string]string, host string, taskType Tas
|
||||
todayTasks, _ := FetchTodayTasks(cookieMap, host, taskType)
|
||||
for _, t := range todayTasks {
|
||||
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
|
||||
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||
if err == nil && detail != nil {
|
||||
return detail.Rows
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func FetchHistoryFormData(cookieMap map[string]string, host string, taskType Tas
|
||||
monthTasks, _ := fetchTasksByYearMonth(cookieMap, host, taskType, year, month)
|
||||
for _, t := range monthTasks {
|
||||
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
|
||||
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||
if err == nil && detail != nil {
|
||||
return detail.Rows
|
||||
}
|
||||
@@ -365,60 +365,53 @@ func mapValueStr(m map[string]interface{}, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// FetchTaskDetail 获取签到任务详情(表单字段)
|
||||
// 任务实例过期(详情返回空)时自动匹配同名任务的最新可用实例重试
|
||||
func FetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
|
||||
return fetchTaskDetail(cookieMap, host, taskType, signWid, signInstanceWid, title, true)
|
||||
// GetTaskDetailByWid 按 wid 直接拉取任务详情(表单预览、历史表单预填充等场景,无任何回退)
|
||||
func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
|
||||
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
|
||||
}
|
||||
|
||||
// findLatestTaskInstance 查找同名任务的最新可用实例
|
||||
// 优先当天/近期列表中未签到的实例(循环任务当天实例,可正常提交);
|
||||
// 否则回退到已签到实例(用于还原表单结构与历史默认值)
|
||||
func findLatestTaskInstance(cookieMap map[string]string, host string, taskType TaskType, title, excludeInstanceWid string) (TodayTaskInfo, bool) {
|
||||
collect := func(yearMonth string) ([]TodayTaskInfo, error) {
|
||||
if yearMonth == "" {
|
||||
return fetchTasksByEndpoint(cookieMap, host, taskType, "")
|
||||
}
|
||||
return fetchTasksByYearMonth(cookieMap, host, taskType, yearMonth[:4], yearMonth[5:])
|
||||
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
|
||||
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
|
||||
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
|
||||
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当日任务列表失败: %w", err)
|
||||
}
|
||||
|
||||
// 依次:当天列表、本月列表、上月列表
|
||||
now := time.Now()
|
||||
months := []string{"", now.Format("2006-01"), now.AddDate(0, -1, 0).Format("2006-01")}
|
||||
seen := map[string]bool{}
|
||||
for _, ym := range months {
|
||||
tasks, err := collect(ym)
|
||||
if err != nil || len(tasks) == 0 {
|
||||
var signed, target *TodayTaskInfo
|
||||
for i := range tasks {
|
||||
t := tasks[i]
|
||||
if !globMatch(t.TaskName, title) || t.SignInstanceWid == "" {
|
||||
continue
|
||||
}
|
||||
var signedFallback *TodayTaskInfo
|
||||
for i := range tasks {
|
||||
t := tasks[i]
|
||||
if !globMatch(t.TaskName, title) || t.SignInstanceWid == "" || t.SignInstanceWid == excludeInstanceWid {
|
||||
continue
|
||||
}
|
||||
if seen[t.SignInstanceWid] {
|
||||
continue
|
||||
}
|
||||
seen[t.SignInstanceWid] = true
|
||||
if !t.IsSigned {
|
||||
// 未签到实例优先(当天循环任务可正常提交)
|
||||
return t, true
|
||||
}
|
||||
if signedFallback == nil {
|
||||
s := t
|
||||
signedFallback = &s
|
||||
}
|
||||
if !t.IsSigned {
|
||||
target = &t
|
||||
break
|
||||
}
|
||||
if signedFallback != nil {
|
||||
return *signedFallback, true
|
||||
if signed == nil {
|
||||
s := t
|
||||
signed = &s
|
||||
}
|
||||
}
|
||||
return TodayTaskInfo{}, false
|
||||
if target == nil {
|
||||
if signed != nil {
|
||||
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
|
||||
}
|
||||
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
|
||||
}
|
||||
slog.Info("按任务名实时匹配到当日任务实例",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if detail.Rows == nil {
|
||||
return nil, fmt.Errorf("获取「%s」任务详情为空", title)
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// fetchTaskDetail 拉取任务详情;allowFallback 为 true 且实例已过期(datas 为空)时自动匹配最新实例重试一次
|
||||
func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string, allowFallback bool) (*TaskFormDetail, error) {
|
||||
// fetchTaskDetailByWid 按 wid 拉取任务详情(纯查询,无任何回退逻辑)
|
||||
func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
|
||||
client := newTaskClient()
|
||||
cookieStr := cookiesToStr(cookieMap)
|
||||
baseURL := taskBasePath(host, taskType)
|
||||
@@ -453,13 +446,6 @@ func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType
|
||||
}
|
||||
|
||||
rows := result.Datas
|
||||
if rows == nil && allowFallback {
|
||||
// 实例已过期/当天实例未发布导致详情为空,自动匹配同名任务的最新可用实例重试
|
||||
slog.Info("任务详情数据为空,自动匹配最新实例", "title", title, "signInstanceWid", signInstanceWid, "taskType", taskType)
|
||||
if alt, ok := findLatestTaskInstance(cookieMap, host, taskType, title, signInstanceWid); ok {
|
||||
return fetchTaskDetail(cookieMap, host, taskType, alt.SignWid, alt.SignInstanceWid, alt.TaskName, false)
|
||||
}
|
||||
}
|
||||
|
||||
detail := &TaskFormDetail{
|
||||
Rows: rows,
|
||||
|
||||
@@ -296,6 +296,22 @@ func NotifySchoolAdapted(contactEmails []string, schoolName string) {
|
||||
SendMailAsync(contactEmails, subject, EmailBody(inner))
|
||||
}
|
||||
|
||||
// NotifySchoolRejected 学校适配申请拒绝通知(发给该学校提交者留下的全部联系邮箱,附拒绝理由)
|
||||
func NotifySchoolRejected(contactEmails []string, schoolName, reason string) {
|
||||
if len(contactEmails) == 0 {
|
||||
slog.Info("学校无联系邮箱,跳过拒绝通知", "school", schoolName)
|
||||
return
|
||||
}
|
||||
escapedReason := html.EscapeString(reason)
|
||||
escapedReason = strings.ReplaceAll(escapedReason, "\n", "<br/>")
|
||||
subject := "【小助手】" + schoolName + " 暂无法适配"
|
||||
inner := `<h2 style="margin:0 0 16px;font-size:17px">很抱歉,` + schoolName + ` 暂无法适配</h2>
|
||||
<p style="font-size:14px;color:#555;line-height:1.8">感谢您的反馈。经管理员核实,该学校暂时无法适配,具体原因如下:</p>
|
||||
<div style="font-size:14px;color:#333;line-height:1.8;background:#f7f8fa;border-radius:8px;padding:12px 16px;margin:12px 0">` + escapedReason + `</div>
|
||||
<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()
|
||||
|
||||
@@ -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
|
||||
@@ -368,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()}`)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ export function getDeviceStatus(token: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 随机生成设备码并绑定到当前账号(个人中心「随机」按钮,需连续两次风险确认) */
|
||||
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<{
|
||||
|
||||
@@ -59,10 +59,10 @@ const steps: GuideStep[] = [
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: 'VIP 会员:签到不限次',
|
||||
targets: ['[data-guide="profile-vip-btn"]'],
|
||||
title: '先绑定设备码',
|
||||
targets: ['[data-guide="profile-device-code"]'],
|
||||
descByKind: () =>
|
||||
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
@@ -97,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])
|
||||
|
||||
@@ -212,10 +212,7 @@ onMounted(async () => {
|
||||
const data = res.data.data
|
||||
userInfo.value = data
|
||||
|
||||
if (!data.mobileCode) {
|
||||
router.replace('/bind-device')
|
||||
return
|
||||
}
|
||||
// 未绑定设备码不再强制跳转绑定页(新建任务时会在入口处弹窗引导)
|
||||
// 新手引导:数据库判定该用户未看过当前版本引导则展示(跨设备一致);
|
||||
// 本次若来自 ?guide=1 强播则优先展示(guideForced 已消费,下次加载不再生效)
|
||||
if (guideForced.value || needGuide(data)) {
|
||||
|
||||
@@ -66,31 +66,37 @@ const router = createRouter({
|
||||
{
|
||||
path: 'users',
|
||||
name: 'admin-users',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminUsers,
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'admin-settings',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminSettings,
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'admin-announcements',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminAnnouncements,
|
||||
},
|
||||
{
|
||||
path: 'tickets',
|
||||
name: 'admin-tickets',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminTicketList,
|
||||
},
|
||||
{
|
||||
path: 'tickets/:id',
|
||||
name: 'admin-ticket-detail',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminTicketDetail,
|
||||
},
|
||||
{
|
||||
path: 'pending-schools',
|
||||
name: 'admin-pending-schools',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminPendingSchools,
|
||||
},
|
||||
],
|
||||
@@ -235,6 +241,12 @@ router.beforeEach(async (to, _from) => {
|
||||
localStorage.removeItem('admin_info')
|
||||
return '/admin/login'
|
||||
}
|
||||
// 代理(agent)只能访问仪表盘 / 代理管理 / CDK管理,其余页面重定向回仪表盘
|
||||
const meData = await meRes.json()
|
||||
const role = meData?.data?.role
|
||||
if (role && role !== 'admin' && to.meta.adminOnly) {
|
||||
return '/admin/dashboard'
|
||||
}
|
||||
} catch {
|
||||
// 网络错误,放行让组件自己处理
|
||||
}
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
/**
|
||||
* 复制文本到剪贴板(兼容 HTTP 环境与移动端)
|
||||
* 优先 Clipboard API(需 HTTPS/localhost),失败回退 execCommand
|
||||
* 复制文本到剪贴板(兼容 HTTP / HTTPS / 移动端 WebView)
|
||||
*
|
||||
* 执行顺序刻意为先同步 execCommand:
|
||||
* document.execCommand('copy') 必须在「用户手势调用栈」内同步执行才可靠,
|
||||
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
|
||||
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
|
||||
*
|
||||
* 返回是否复制成功
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 1) Clipboard API(仅安全上下文可用)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// 继续走 fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 隐藏 textarea + execCommand(HTTP / 老浏览器)
|
||||
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
// 必须可编辑且不在屏幕外,否则 iOS Safari 会复制失败
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.top = '0'
|
||||
ta.style.left = '0'
|
||||
ta.style.opacity = '0'
|
||||
// 必须可见且在屏幕内(不可 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()
|
||||
@@ -34,5 +27,16 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
ok = false
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
return ok
|
||||
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
|
||||
}
|
||||
|
||||
@@ -174,8 +174,9 @@ async function copyCode(code: string, index: number) {
|
||||
if (await copyToClipboard(code)) {
|
||||
copiedIndex.value = index
|
||||
setTimeout(() => { copiedIndex.value = null }, 2000)
|
||||
message.success('已复制')
|
||||
} else {
|
||||
message.error('复制失败,请长按手动复制')
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,8 +184,9 @@ async function copyResultCode(code: string, index: number) {
|
||||
if (await copyToClipboard(code)) {
|
||||
resultCopiedIdx.value = index
|
||||
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
|
||||
message.success('已复制')
|
||||
} else {
|
||||
message.error('复制失败,请长按手动复制')
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +198,7 @@ async function copyAllCodes() {
|
||||
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
|
||||
setTimeout(() => { copiedAll.value = false }, 2000)
|
||||
} else {
|
||||
message.error('复制失败,请长按手动复制')
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,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>
|
||||
@@ -340,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"
|
||||
|
||||
@@ -33,17 +33,22 @@ const adminInfo = ref<AgentInfo | null>(null)
|
||||
const showUserMenu = ref(false)
|
||||
const showSidebar = ref(false)
|
||||
|
||||
const navItems = [
|
||||
const navItems: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }[] = [
|
||||
{ name: 'admin-dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin/dashboard' },
|
||||
{ name: 'admin-agents', label: '代理管理', icon: Users, path: '/admin/agents' },
|
||||
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users' },
|
||||
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users', adminOnly: true },
|
||||
{ name: 'admin-cdks', label: 'CDK管理', icon: QrCode, path: '/admin/cdks' },
|
||||
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements' },
|
||||
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets' },
|
||||
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools' },
|
||||
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings' },
|
||||
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements', adminOnly: true },
|
||||
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets', adminOnly: true },
|
||||
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools', adminOnly: true },
|
||||
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings', adminOnly: true },
|
||||
]
|
||||
|
||||
// 代理(agent)只能访问:仪表盘、代理管理、CDK管理
|
||||
const visibleNavItems = computed(() =>
|
||||
navItems.filter((item) => !item.adminOnly || adminInfo.value?.role === 'admin')
|
||||
)
|
||||
|
||||
const activeNav = computed(() => route.name as string)
|
||||
|
||||
// 公告相关(代理端)
|
||||
@@ -130,7 +135,7 @@ function dismissForcePopup() {
|
||||
forcePopupQueue.value = forcePopupQueue.value.slice(1)
|
||||
}
|
||||
|
||||
function navigateTo(item: typeof navItems[0]) {
|
||||
function navigateTo(item: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }) {
|
||||
router.push({ name: item.name })
|
||||
showSidebar.value = false
|
||||
}
|
||||
@@ -217,7 +222,7 @@ function handleLogout() {
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
v-for="item in visibleNavItems"
|
||||
:key="item.name"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
|
||||
:class="activeNav === item.name
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
|
||||
import { Shield, RefreshCw, CheckCircle, XCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
@@ -16,6 +16,7 @@ interface PendingSchoolItem {
|
||||
email: string
|
||||
ip: string
|
||||
status: string
|
||||
rejectReason: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -35,6 +36,10 @@ const searchKeyword = ref('')
|
||||
const showPasswordMap = ref<Record<number, boolean>>({})
|
||||
// 已适配学校:schoolId -> 学校名(用于在待处理列表中标识已适配过的学校)
|
||||
const adaptedSchools = ref<Map<string, string>>(new Map())
|
||||
// 拒绝弹窗
|
||||
const rejectTarget = ref<PendingSchoolItem | null>(null)
|
||||
const rejectReason = ref('')
|
||||
const rejectSubmitting = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
@@ -103,6 +108,38 @@ async function handleMarkDone(record: PendingSchoolItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function openRejectModal(record: PendingSchoolItem) {
|
||||
rejectTarget.value = record
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
function closeRejectModal() {
|
||||
if (rejectSubmitting.value) return
|
||||
rejectTarget.value = null
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
const target = rejectTarget.value
|
||||
if (!target) return
|
||||
const reason = rejectReason.value.trim()
|
||||
if (!reason) {
|
||||
message.error('拒绝时必须说明理由')
|
||||
return
|
||||
}
|
||||
rejectSubmitting.value = true
|
||||
try {
|
||||
await api.post(`/admin/pending-schools/${target.id}/reject`, { reason })
|
||||
message.success(`已拒绝该条记录,拒绝理由将邮件通知联系邮箱`)
|
||||
closeRejectModal()
|
||||
await loadData()
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
rejectSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) {
|
||||
@@ -139,6 +176,11 @@ async function copyText(text: string) {
|
||||
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="statusFilter = 'completed'; loadData()"
|
||||
>已完成</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === 'rejected' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="statusFilter = 'rejected'; loadData()"
|
||||
>已拒绝</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@@ -216,15 +258,27 @@ async function copyText(text: string) {
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="statusFilter !== 'completed'"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="handleMarkDone(group.records[0])"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 mr-1" />
|
||||
标记完成
|
||||
</Button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
v-if="statusFilter !== 'completed' && statusFilter !== 'rejected'"
|
||||
size="sm"
|
||||
class="h-7 text-xs whitespace-nowrap"
|
||||
@click="handleMarkDone(group.records[0])"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 mr-1 shrink-0" />
|
||||
标记完成
|
||||
</Button>
|
||||
<Button
|
||||
v-if="statusFilter === 'pending'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 whitespace-nowrap"
|
||||
@click="openRejectModal(group.records[0])"
|
||||
>
|
||||
<XCircle class="w-3 h-3 mr-1 shrink-0" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号列表(小屏横向滚动) -->
|
||||
@@ -289,23 +343,36 @@ async function copyText(text: string) {
|
||||
<td class="px-5 py-3">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
|
||||
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : 'bg-green-50 text-green-600'"
|
||||
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : r.status === 'rejected' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'"
|
||||
>
|
||||
{{ r.status === 'pending' ? '待处理' : '已完成' }}
|
||||
{{ r.status === 'pending' ? '待处理' : r.status === 'rejected' ? '已拒绝' : '已完成' }}
|
||||
</span>
|
||||
<p
|
||||
v-if="r.status === 'rejected' && r.rejectReason"
|
||||
class="text-[10px] text-red-400 mt-1 max-w-[180px] leading-snug"
|
||||
:title="r.rejectReason"
|
||||
>理由:{{ r.rejectReason }}</p>
|
||||
</td>
|
||||
<!-- 提交时间 -->
|
||||
<td class="px-5 py-3 text-gray-500 text-xs">{{ r.createdAt }}</td>
|
||||
<!-- 操作 -->
|
||||
<td class="px-5 py-3">
|
||||
<button
|
||||
v-if="r.status === 'pending'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
@click="handleMarkDone(r)"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3" />
|
||||
完成
|
||||
</button>
|
||||
<td class="px-5 py-3 whitespace-nowrap">
|
||||
<div v-if="r.status === 'pending'" class="flex items-center gap-1 whitespace-nowrap">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
@click="handleMarkDone(r)"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 shrink-0" />
|
||||
完成
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-red-500 hover:bg-red-50 transition-colors"
|
||||
@click="openRejectModal(r)"
|
||||
>
|
||||
<XCircle class="w-3 h-3 shrink-0" />
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -314,5 +381,44 @@ async function copyText(text: string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拒绝理由弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="rejectTarget"
|
||||
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center px-4"
|
||||
@click.self="closeRejectModal"
|
||||
>
|
||||
<div class="bg-white rounded-2xl w-full max-w-md p-6 shadow-xl">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-9 h-9 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<XCircle class="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-gray-900">拒绝适配申请</h3>
|
||||
<p class="text-xs text-gray-400">{{ rejectTarget.schoolName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="block text-sm text-gray-600 mb-1.5">拒绝理由 <span class="text-red-500">*</span></label>
|
||||
<textarea
|
||||
v-model="rejectReason"
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
|
||||
<div class="flex justify-end gap-2 mt-5">
|
||||
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" class="h-9 text-xs bg-red-500 hover:bg-red-600 text-white" :disabled="rejectSubmitting" @click="confirmReject">
|
||||
<XCircle class="w-3.5 h-3.5 mr-1" />
|
||||
{{ rejectSubmitting ? '提交中...' : '确认拒绝' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -51,6 +51,8 @@ interface UserItem {
|
||||
inviterName: string
|
||||
taskCount: number
|
||||
executedCount: number
|
||||
vipStatus: string
|
||||
vipExpireAt: string
|
||||
todayStatus: string
|
||||
status: string
|
||||
createdAt: string
|
||||
@@ -107,6 +109,126 @@ 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 `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
// 当天已登录判定:lastLoginAt 为服务器时间 "YYYY-MM-DD HH:mm:ss",取日期部分与本地今天比对
|
||||
function isTodayLogin(lastLoginAt: string) {
|
||||
if (!lastLoginAt) return false
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const today = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
return lastLoginAt.slice(0, 10) === today
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -253,14 +375,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">
|
||||
@@ -326,23 +453,28 @@ function todayStatusLabel(s: string) {
|
||||
<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-[9%]">学工号</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">学工号</th>
|
||||
<th class="px-4 py-3 font-medium w-[6%]">真实姓名</th>
|
||||
<th class="px-4 py-3 font-medium w-[13%]">学校</th>
|
||||
<th class="px-4 py-3 font-medium w-[11%]">设备码</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-[8%]">可执行/已执行</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">VIP</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>
|
||||
<th class="px-4 py-3 font-medium w-[9%]">创建时间</th>
|
||||
<th class="px-4 py-3 font-medium text-center w-[18%]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
class="border-t border-gray-50 transition-colors"
|
||||
:class="isTodayLogin(user.lastLoginAt)
|
||||
? 'bg-green-50 hover:bg-green-100/70'
|
||||
: 'hover:bg-gray-50/50'"
|
||||
:title="isTodayLogin(user.lastLoginAt) ? '该用户当天已登录' : undefined"
|
||||
>
|
||||
<td class="px-4 py-3 truncate font-mono text-xs">{{ user.studentId }}</td>
|
||||
<td class="px-4 py-3 truncate">{{ user.realName || '--' }}</td>
|
||||
@@ -366,6 +498,23 @@ function todayStatusLabel(s: string) {
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
<span class="text-gray-500">{{ user.executedCount }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
v-if="user.vipStatus === 'active'"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
|
||||
:title="`VIP 到期时间:${user.vipExpireAt}`"
|
||||
>
|
||||
VIP · {{ user.vipExpireAt.slice(5, 10) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="user.vipStatus === 'expired'"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
|
||||
:title="`VIP 已于 ${user.vipExpireAt} 过期`"
|
||||
>
|
||||
VIP · 已过期
|
||||
</span>
|
||||
<span v-else class="text-xs text-gray-300">非VIP</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900">{{ user.inviteReward }}</span>
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
@@ -617,6 +766,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-[80px]">时间</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-[60px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium">说明</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 tabular-nums">{{ 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 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
|
||||
: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 break-words">{{ 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>
|
||||
|
||||
|
||||
@@ -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 = '选择签到任务'
|
||||
@@ -284,13 +299,20 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
const policy = policyJson.data
|
||||
|
||||
// Step 2: 浏览器直传到今日校园 OBS(文件不经过我们的服务器)
|
||||
// 使用接口返回的 formData 字段集原样提交:签到为 q-ak/q-key-time/q-signature 风格,
|
||||
// 查寝为 AccessKeyId/signature 风格,字段不同必须按接口返回拼装
|
||||
const obsForm = new FormData()
|
||||
obsForm.append('x-obs-acl', 'public-read')
|
||||
obsForm.append('AccessKeyId', policy.accessid || policy.AccessId)
|
||||
obsForm.append('key', policy.fileName || policy.FileName)
|
||||
obsForm.append('signature', policy.signature || policy.Signature)
|
||||
obsForm.append('policy', policy.policy || policy.Policy)
|
||||
obsForm.append('content-type', file.type || 'image/jpeg')
|
||||
const fd = policy.formData || policy.FormData
|
||||
if (fd && typeof fd === 'object' && Object.keys(fd).length) {
|
||||
for (const [k, v] of Object.entries(fd)) obsForm.append(k, String(v))
|
||||
} else {
|
||||
obsForm.append('x-obs-acl', 'public-read')
|
||||
obsForm.append('AccessKeyId', policy.accessid || policy.AccessId)
|
||||
obsForm.append('key', policy.fileName || policy.FileName)
|
||||
obsForm.append('signature', policy.signature || policy.Signature)
|
||||
obsForm.append('policy', policy.policy || policy.Policy)
|
||||
obsForm.append('content-type', file.type || 'image/jpeg')
|
||||
}
|
||||
obsForm.append('file', file)
|
||||
|
||||
const obsRes = await fetch(policy.host || policy.Host, {
|
||||
@@ -330,6 +352,19 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
}
|
||||
|
||||
// ======== 保存表单(创建流程) ========
|
||||
// ======== 表单完整性:必填项为空时提交必然失败(如签到地点未配置 → 服务端报"纬度不能为空") ========
|
||||
function missingRequiredFields(items: authApi.FormItem[] | null | undefined): string[] {
|
||||
if (!items?.length) return []
|
||||
return items
|
||||
.filter(i => i.required && i.show !== false && !String(i.content ?? '').trim())
|
||||
.map(i => i.title || i.colName)
|
||||
}
|
||||
|
||||
function parseFormItems(formData?: string | null): authApi.FormItem[] {
|
||||
if (!formData) return []
|
||||
try { return JSON.parse(formData) as authApi.FormItem[] } catch { return [] }
|
||||
}
|
||||
|
||||
// ======== 范围外签到原因必填校验 ========
|
||||
function validateMalpositionReason(items: authApi.FormItem[]): boolean {
|
||||
const mal = items.find(i => i.colName === 'isMalposition')
|
||||
@@ -350,6 +385,10 @@ async function saveForm() {
|
||||
await authApi.saveTaskForm(formTaskId.value, formItems.value)
|
||||
showStep.value = false
|
||||
await loadTasks()
|
||||
const missing = missingRequiredFields(formItems.value)
|
||||
if (missing.length) {
|
||||
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请在「编辑」中补全`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message || e.message || '保存失败')
|
||||
} finally {
|
||||
@@ -397,6 +436,10 @@ async function saveEdit() {
|
||||
await authApi.saveTaskForm(editTask.value.id, editFormItems.value)
|
||||
showEditModal.value = false
|
||||
await loadTasks()
|
||||
const missing = missingRequiredFields(editFormItems.value)
|
||||
if (missing.length) {
|
||||
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请补全`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message || e.message || '保存失败')
|
||||
} finally {
|
||||
@@ -413,6 +456,14 @@ function executeTask(task: authApi.TaskInfo) {
|
||||
if (executingId.value) return
|
||||
if (task.taskStatus === 'success') return
|
||||
|
||||
// 表单完整性预检:必填项为空时直接引导去编辑补全(后端同样会拦截,此处提示更快更直观)
|
||||
const missing = missingRequiredFields(parseFormItems(task.formData))
|
||||
if (missing.length) {
|
||||
message.warning(`签到信息未配置完整,缺少:${missing.join('、')},请先点「编辑」补全`)
|
||||
openEditModal(task)
|
||||
return
|
||||
}
|
||||
|
||||
// 清除旧的错误信息
|
||||
execStatus.value = ''
|
||||
execMessage.value = ''
|
||||
@@ -1295,6 +1346,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
|
||||
|
||||
@@ -471,7 +471,7 @@ async function handleReportSubmit() {
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">忘记密码?</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">进入学校登录地址自行找回</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">点击将会打开学校官方登录地址,您可以在官方地址测试登录或者找回密码,然后回到本网站继续登录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -135,6 +135,40 @@ 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(kind: 'count' | 'vip' = 'count') {
|
||||
redeemKind.value = kind
|
||||
@@ -419,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">
|
||||
@@ -433,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>
|
||||
|
||||
@@ -531,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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user