Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf0e1656ab | |||
| 66d07781be | |||
| 99c16652a7 | |||
| cc4aa1c00e | |||
| 15eceea38a | |||
| a9dbc2661d | |||
| ebc94ec9a0 | |||
| 98cf8666f6 | |||
| ff3593f057 | |||
| 74422b1848 |
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ func getBaseDeduction() float64 {
|
||||
}
|
||||
|
||||
// getVipCardPrice 获取 VIP 卡价格(元/张,系统设置)
|
||||
// key: vip_price_week(周卡7天,默认5元)/ vip_price_month(月卡30天,默认15元)
|
||||
// key: vip_price_week(周卡7天,默认30元)/ vip_price_month(月卡30天,默认100元)
|
||||
// (单次运行基础扣费 6 元,周卡/月卡按次折算后再给折扣)
|
||||
func getVipCardPrice(key string, def float64) float64 {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
|
||||
@@ -50,8 +51,8 @@ func getVipCardPrice(key string, def float64) float64 {
|
||||
return def
|
||||
}
|
||||
|
||||
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 5.00) }
|
||||
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 15.00) }
|
||||
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
|
||||
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
|
||||
|
||||
// GenerateCDK 生成CDK
|
||||
func GenerateCDK(c *fiber.Ctx) error {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -35,10 +35,10 @@ func GetSettings(c *fiber.Ctx) error {
|
||||
settings["register_reward"] = "1.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_week"]; !ok {
|
||||
settings["vip_price_week"] = "5.00"
|
||||
settings["vip_price_week"] = "30.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_month"]; !ok {
|
||||
settings["vip_price_month"] = "15.00"
|
||||
settings["vip_price_month"] = "100.00"
|
||||
}
|
||||
|
||||
return utils.Success(c, settings)
|
||||
|
||||
@@ -417,6 +417,19 @@ func findLatestTaskInstance(cookieMap map[string]string, host string, taskType T
|
||||
return TodayTaskInfo{}, false
|
||||
}
|
||||
|
||||
// widToString 宽松转换详情返回的实例 wid(兼容字符串与数字两种 JSON 类型)
|
||||
func widToString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchTaskDetail 拉取任务详情;allowFallback 为 true 且实例已过期(datas 为空)时自动匹配最新实例重试一次
|
||||
func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string, allowFallback bool) (*TaskFormDetail, error) {
|
||||
client := newTaskClient()
|
||||
@@ -461,6 +474,21 @@ func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType
|
||||
}
|
||||
}
|
||||
|
||||
// 循环任务(如每日查寝)每天生成新实例:存储的 signInstanceWid 对应的历史实例
|
||||
// 详情接口仍可查询(datas 非空),但提交会被判定"已结束"。
|
||||
// 因此对比当日列表中同名任务的实例,若不一致且当日实例未签到,则切换到当日实例。
|
||||
if rows != nil && allowFallback {
|
||||
curWid := widToString(rows["signInstanceWid"])
|
||||
if curWid == "" {
|
||||
curWid = signInstanceWid
|
||||
}
|
||||
if alt, ok := findLatestTaskInstance(cookieMap, host, taskType, title, ""); ok && !alt.IsSigned && alt.SignInstanceWid != curWid {
|
||||
slog.Info("任务详情实例非当日可用实例,切换到当日实例",
|
||||
"title", title, "old", curWid, "new", alt.SignInstanceWid, "taskType", taskType)
|
||||
return fetchTaskDetail(cookieMap, host, taskType, alt.SignWid, alt.SignInstanceWid, alt.TaskName, false)
|
||||
}
|
||||
}
|
||||
|
||||
detail := &TaskFormDetail{
|
||||
Rows: rows,
|
||||
RealTitle: title,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -57,6 +57,13 @@ const steps: GuideStep[] = [
|
||||
descByKind: () =>
|
||||
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '先绑定设备码',
|
||||
targets: ['[data-guide="profile-device-code"]'],
|
||||
descByKind: () =>
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
title: '新建每日签到任务',
|
||||
@@ -90,6 +97,13 @@ const steps: GuideStep[] = [
|
||||
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
|
||||
},
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: 'VIP 会员:签到不限次',
|
||||
targets: ['[data-guide="profile-vip-btn"]'],
|
||||
descByKind: () =>
|
||||
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
},
|
||||
]
|
||||
|
||||
const current = computed(() => steps[step.value])
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 复制文本到剪贴板(兼容 HTTP / HTTPS / 移动端 WebView)
|
||||
*
|
||||
* 执行顺序刻意为先同步 execCommand:
|
||||
* document.execCommand('copy') 必须在「用户手势调用栈」内同步执行才可靠,
|
||||
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
|
||||
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
|
||||
*
|
||||
* 返回是否复制成功
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
// 必须可见且在屏幕内(不可 display:none / 移出视口),否则 iOS Safari 复制失败;
|
||||
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
|
||||
ta.setAttribute('readonly', '')
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
if (ok) return true
|
||||
|
||||
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// 忽略,走失败返回
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import * as adminApi from '@/api/admin'
|
||||
|
||||
const message = useMessage()
|
||||
@@ -170,57 +171,34 @@ function closeGenerateModal() {
|
||||
}
|
||||
|
||||
async function copyCode(code: string, index: number) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
copiedIndex.value = index
|
||||
setTimeout(() => { copiedIndex.value = null }, 2000)
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = code
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
if (await copyToClipboard(code)) {
|
||||
copiedIndex.value = index
|
||||
setTimeout(() => { copiedIndex.value = null }, 2000)
|
||||
message.success('已复制')
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResultCode(code: string, index: number) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
resultCopiedIdx.value = index
|
||||
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = code
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
if (await copyToClipboard(code)) {
|
||||
resultCopiedIdx.value = index
|
||||
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
|
||||
message.success('已复制')
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAllCodes() {
|
||||
if (resultCdks.value.length === 0) return
|
||||
const all = resultCdks.value.map((c) => c.code).join('\n')
|
||||
try {
|
||||
await navigator.clipboard.writeText(all)
|
||||
if (await copyToClipboard(all)) {
|
||||
copiedAll.value = true
|
||||
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
|
||||
setTimeout(() => { copiedAll.value = false }, 2000)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = all
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
copiedAll.value = true
|
||||
setTimeout(() => { copiedAll.value = false }, 2000)
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,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>
|
||||
@@ -364,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"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucid
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import api from '@/api/index'
|
||||
|
||||
interface PendingSchoolItem {
|
||||
@@ -102,10 +103,13 @@ async function handleMarkDone(record: PendingSchoolItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
async function copyText(text: string) {
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) {
|
||||
message.success('已复制')
|
||||
})
|
||||
} else {
|
||||
message.error('复制失败,请长按手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ async function loadSettings() {
|
||||
const res = await adminApi.getSettings()
|
||||
const settings = res.data?.data || {}
|
||||
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
|
||||
vipWeekPrice.value = settings['vip_price_week'] || '5.00'
|
||||
vipMonthPrice.value = settings['vip_price_month'] || '15.00'
|
||||
vipWeekPrice.value = settings['vip_price_week'] || '30.00'
|
||||
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
|
||||
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
|
||||
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
|
||||
registerReward.value = settings['register_reward'] || '1.00'
|
||||
|
||||
@@ -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,117 @@ const disableTarget = ref<UserItem | null>(null)
|
||||
const disableReason = ref('')
|
||||
const disabling = ref(false)
|
||||
|
||||
// 当天执行日志弹窗
|
||||
interface AdminTaskLogItem {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
realName: string
|
||||
schoolName: string
|
||||
taskType: string
|
||||
title: string
|
||||
realTitle: string
|
||||
isSuccess: boolean
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const showLogsModal = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const logItems = ref<AdminTaskLogItem[]>([])
|
||||
const logsTotal = ref(0)
|
||||
const logsPage = ref(1)
|
||||
const logsPageSize = ref(20)
|
||||
const logsDate = ref(todayStr())
|
||||
const logsStatus = ref<'all' | 'success' | 'fail'>('all')
|
||||
const logsKeyword = ref('')
|
||||
const logsSearchKeyword = ref('')
|
||||
|
||||
const logsStatusOptions = [
|
||||
{ key: 'all' as const, label: '全部' },
|
||||
{ key: 'success' as const, label: '成功' },
|
||||
{ key: 'fail' as const, label: '失败' },
|
||||
]
|
||||
|
||||
const logsTotalPages = computed(() => Math.ceil(logsTotal.value / logsPageSize.value) || 1)
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
function openTodayLogs() {
|
||||
logsDate.value = todayStr()
|
||||
logsStatus.value = 'all'
|
||||
logsKeyword.value = ''
|
||||
logsSearchKeyword.value = ''
|
||||
logsPage.value = 1
|
||||
showLogsModal.value = true
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
async function loadTodayLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const res = await adminApi.getAdminTaskLogs({
|
||||
date: logsDate.value,
|
||||
status: logsStatus.value,
|
||||
keyword: logsSearchKeyword.value,
|
||||
page: logsPage.value,
|
||||
pageSize: logsPageSize.value,
|
||||
})
|
||||
const data = res.data?.data
|
||||
logItems.value = data?.items || []
|
||||
logsTotal.value = data?.total || 0
|
||||
} catch {
|
||||
message.error('加载执行日志失败')
|
||||
logItems.value = []
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogsStatusChange(val: 'all' | 'success' | 'fail') {
|
||||
if (logsStatus.value === val) return
|
||||
logsStatus.value = val
|
||||
logsPage.value = 1
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function handleLogsDateChange(e: Event) {
|
||||
logsDate.value = (e.target as HTMLInputElement).value || todayStr()
|
||||
logsPage.value = 1
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function handleLogsSearch() {
|
||||
logsPage.value = 1
|
||||
logsSearchKeyword.value = logsKeyword.value.trim()
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function handleLogsPage(p: number) {
|
||||
if (p < 1 || p > logsTotalPages.value) return
|
||||
logsPage.value = p
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function formatLogTime(iso: string) {
|
||||
if (!iso) return '--'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function taskTypeLabel(t: string) {
|
||||
if (t === 'sign') return '签到'
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
return t
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
|
||||
const pageSizeOptions = [20, 50, 100]
|
||||
|
||||
@@ -253,14 +366,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,16 +444,17 @@ 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>
|
||||
@@ -366,6 +485,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 +753,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 = '选择签到任务'
|
||||
@@ -1295,6 +1310,38 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showBindDeviceDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40" @click="showBindDeviceDialog = false" />
|
||||
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Smartphone class="w-7 h-7 text-indigo-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
请先在个人中心绑定设备码,绑定后才能创建和执行签到任务
|
||||
</p>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="showBindDeviceDialog = false; router.push('/profile')"
|
||||
>
|
||||
去绑定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 操作频繁弹窗(限流) ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
|
||||
@@ -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'
|
||||
@@ -61,8 +61,6 @@ const redeemKind = ref<'count' | 'vip'>('count')
|
||||
// VIP 信息
|
||||
const isVip = ref(false)
|
||||
const vipExpireAt = ref('')
|
||||
const vipWeekPrice = ref('')
|
||||
const vipMonthPrice = ref('')
|
||||
// 验证码
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
@@ -75,14 +73,6 @@ const vipExpireShort = computed(() => {
|
||||
return vipExpireAt.value.slice(0, 10)
|
||||
})
|
||||
|
||||
// 开通按钮价格文案:周卡 ¥5 · 月卡 ¥15(仅展示已配置的档位)
|
||||
const vipPriceLabel = computed(() => {
|
||||
const parts: string[] = []
|
||||
if (vipWeekPrice.value) parts.push(`周¥${vipWeekPrice.value}`)
|
||||
if (vipMonthPrice.value) parts.push(`月¥${vipMonthPrice.value}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
|
||||
function captchaFailMsg(e: unknown): string {
|
||||
const err = e as { response?: { data?: { message?: string } } }
|
||||
@@ -130,8 +120,6 @@ onMounted(async () => {
|
||||
const vipRes = await authApi.getVipInfo()
|
||||
isVip.value = vipRes.data.data.isVip
|
||||
vipExpireAt.value = vipRes.data.data.vipExpireAt
|
||||
vipWeekPrice.value = vipRes.data.data.vipWeekPrice || ''
|
||||
vipMonthPrice.value = vipRes.data.data.vipMonthPrice || vipRes.data.data.vipPrice || ''
|
||||
} catch {
|
||||
// VIP 信息加载失败不阻塞页面
|
||||
}
|
||||
@@ -147,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
|
||||
@@ -167,8 +189,6 @@ async function reloadVipInfo() {
|
||||
const vipRes = await authApi.getVipInfo()
|
||||
isVip.value = vipRes.data.data.isVip
|
||||
vipExpireAt.value = vipRes.data.data.vipExpireAt
|
||||
vipWeekPrice.value = vipRes.data.data.vipWeekPrice || ''
|
||||
vipMonthPrice.value = vipRes.data.data.vipMonthPrice || vipRes.data.data.vipPrice || ''
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -279,7 +299,7 @@ async function handleRedeemInvite() {
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
</div>
|
||||
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5">VIP</div>
|
||||
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
|
||||
</div>
|
||||
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
|
||||
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
|
||||
@@ -294,10 +314,11 @@ async function handleRedeemInvite() {
|
||||
</span>
|
||||
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
|
||||
<button
|
||||
data-guide="profile-vip-btn"
|
||||
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
|
||||
@click="openRedeemModal('vip')"
|
||||
>
|
||||
{{ isVip ? '续费 VIP' : `开通 VIP${vipPriceLabel ? ` ${vipPriceLabel}` : ''}` }}
|
||||
续费 VIP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,8 +333,8 @@ async function handleRedeemInvite() {
|
||||
<ClipboardCheck class="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">可签到次数</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ (userInfo?.signCount ?? 0) }} 次</p>
|
||||
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -432,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">
|
||||
@@ -446,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>
|
||||
|
||||
@@ -478,7 +508,7 @@ async function handleRedeemInvite() {
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900">
|
||||
{{ redeemKind === 'vip' ? '开通 / 续费 VIP' : '兑换签到次数' }}
|
||||
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
|
||||
</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
|
||||
<X class="w-4 h-4" />
|
||||
@@ -487,9 +517,6 @@ async function handleRedeemInvite() {
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<p v-if="redeemKind === 'vip'" class="text-xs text-gray-400 leading-relaxed -mt-1">
|
||||
输入 VIP 兑换码即可开通/续费会员(默认一个月),VIP 有效期内执行任务不限次数、不再扣除签到次数
|
||||
</p>
|
||||
<!-- 兑换码输入 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
|
||||
@@ -547,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>
|
||||
|
||||
@@ -560,9 +647,10 @@ async function handleRedeemInvite() {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* VIP 徽标:金色渐变 + 反光条扫过动画 */
|
||||
/* VIP 徽标:金色渐变 + 反光条扫过动画
|
||||
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
|
||||
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
|
||||
.vip-badge {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
|
||||
Reference in New Issue
Block a user