Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a4507903f | |||
| a95c843bf7 | |||
| 764bae23fc | |||
| 1f4d601ffa |
@@ -289,6 +289,29 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
var tasks []model.CpTask
|
||||
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tasks)
|
||||
|
||||
// 跨天惰性重置:签到/查寝均为每日任务,"已完成(success)"只应代表当天成功。
|
||||
// 昨日及更早的 success 若继续保留,任务卡会一直显示绿色对勾、隐藏执行按钮,
|
||||
// 误导用户"今天已执行"且无法再次打卡。以"当天是否存在成功日志"为准判定并重置回 pending。
|
||||
now := time.Now()
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todaySuccessIDs []uint
|
||||
database.DB.Model(&model.CpTaskLog{}).
|
||||
Where("user_id = ? AND is_success = 1 AND created_at >= ?", userID, startOfToday).
|
||||
Distinct("cp_task_id").
|
||||
Pluck("cp_task_id", &todaySuccessIDs)
|
||||
todaySuccessSet := make(map[uint]bool, len(todaySuccessIDs))
|
||||
for _, tid := range todaySuccessIDs {
|
||||
todaySuccessSet[tid] = true
|
||||
}
|
||||
for i := range tasks {
|
||||
if tasks[i].TaskStatus == "success" && !todaySuccessSet[tasks[i].ID] {
|
||||
tasks[i].TaskStatus = "pending"
|
||||
tasks[i].ErrorMessage = ""
|
||||
database.DB.Model(&model.CpTask{}).Where("id = ?", tasks[i].ID).
|
||||
Updates(map[string]interface{}{"task_status": "pending", "error_message": ""})
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试获取今日任务进行匹配
|
||||
var user model.SysUser
|
||||
var todayTasks []cpd.TodayTaskInfo
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Home, User, Bell, X, CheckCircle, ChevronDown, MessageSquare } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
@@ -98,6 +98,15 @@ function closeGuide() {
|
||||
}
|
||||
}
|
||||
|
||||
// 个人中心「再次查看引导」入口:重播新手引导。GuideOverlay 对 show 重新置真会自动
|
||||
// 回到第一步;关闭时走 closeGuide 正常上报完成,顺带把未完成账号的引导版本补齐,
|
||||
// 语义与首次观看一致,故不沿用 guideForced 的"关闭不写库"逻辑。
|
||||
function restartGuide() {
|
||||
showGuide.value = true
|
||||
loadInviteRewardRules()
|
||||
}
|
||||
provide('restartGuide', restartGuide)
|
||||
|
||||
/** 数据库判定:guideVersion 未达当前引导版本的用户需展示引导 */
|
||||
function needGuide(user: UserInfo): boolean {
|
||||
return (user.guideVersion || '') !== GUIDE_VERSION
|
||||
|
||||
@@ -214,6 +214,15 @@ function formatLogTime(iso: string) {
|
||||
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 '查寝'
|
||||
@@ -461,7 +470,11 @@ function todayStatusLabel(s: string) {
|
||||
<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>
|
||||
|
||||
@@ -92,6 +92,22 @@ async function removeTaskLog(id: number) {
|
||||
const deviceCode = ref('')
|
||||
const showBindDeviceDialog = ref(false)
|
||||
|
||||
// 执行成功后的延迟校正刷新定时器(今日校园侧状态同步延迟时兜底)
|
||||
let refreshTimer: number | null = null
|
||||
|
||||
// ======== 切回页面自动刷新 ========
|
||||
// 覆盖"在今日校园 App 里打卡后切回本站"的场景:列表/匹配状态自动刷新,无需手动重进页面
|
||||
let lastVisibleRefreshAt = 0
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
// 节流 15 秒:避免浏览器内频繁切换标签页时重复请求今日校园接口
|
||||
const now = Date.now()
|
||||
if (now - lastVisibleRefreshAt < 15000) return
|
||||
lastVisibleRefreshAt = now
|
||||
loadTasks()
|
||||
loadTaskLogs()
|
||||
}
|
||||
|
||||
// ======== 初始化 ========
|
||||
onMounted(async () => {
|
||||
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
|
||||
@@ -108,10 +124,13 @@ onMounted(async () => {
|
||||
|
||||
// 监听 cpdaily 密码错误事件(从 axios 拦截器发出)
|
||||
window.addEventListener(CPD_PASSWORD_ERROR, onCpdPasswordError)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(CPD_PASSWORD_ERROR, onCpdPasswordError)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
if (refreshTimer) clearTimeout(refreshTimer)
|
||||
})
|
||||
|
||||
// cpdaily 密码错误处理:弹出重新登录确认框
|
||||
@@ -490,8 +509,22 @@ function executeTask(task: authApi.TaskInfo) {
|
||||
onSuccess: () => {
|
||||
execStatus.value = 'success'
|
||||
execMessage.value = '签到成功!'
|
||||
// 本地乐观更新:立即把该任务标记为当天已完成。
|
||||
// 列表接口需实时请求今日校园,可能较慢或状态暂时滞后,先本地刷新保证用户马上看到正确状态
|
||||
const t = tasks.value.find(x => x.id === task.id)
|
||||
if (t) {
|
||||
t.taskStatus = 'success'
|
||||
t.errorMessage = ''
|
||||
t.matchStatus = 'matched_signed'
|
||||
}
|
||||
loadTasks()
|
||||
loadTaskLogs()
|
||||
// 今日校园侧状态同步有延迟:稍后再拉一次列表校正匹配状态(如被判定为未签到/未匹配的兜底修正)
|
||||
if (refreshTimer) clearTimeout(refreshTimer)
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = null
|
||||
loadTasks()
|
||||
}, 6000)
|
||||
setTimeout(() => {
|
||||
executingId.value = null
|
||||
execStatus.value = ''
|
||||
@@ -587,15 +620,17 @@ function statusLabel(status: string) {
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// 匹配状态背景色
|
||||
function matchBgClass(status: string) {
|
||||
// 匹配状态背景色:当天已成功执行的任务一律显示绿色
|
||||
// (今日校园侧状态同步存在延迟,不能因 matchStatus 未及时更新就显示成"未签到"的蓝色)
|
||||
function matchBgClass(task: authApi.TaskInfo) {
|
||||
if (task.taskStatus === 'success') return 'bg-green-50/60'
|
||||
const map: Record<string, string> = {
|
||||
matched_unsigned: 'bg-blue-50/60',
|
||||
matched_signed: 'bg-green-50/60',
|
||||
no_match: 'bg-amber-50/60',
|
||||
check_failed: 'bg-gray-50',
|
||||
}
|
||||
return map[status] || 'bg-gray-50'
|
||||
return map[task.matchStatus] || 'bg-gray-50'
|
||||
}
|
||||
|
||||
function taskTypeLabel(t: string) {
|
||||
@@ -807,7 +842,7 @@ function getGPSLocation(source: string) {
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg transition-colors"
|
||||
:class="matchBgClass(task.matchStatus)"
|
||||
:class="matchBgClass(task)"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -815,8 +850,9 @@ function getGPSLocation(source: string) {
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(task.taskType) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<!-- 每任务只显示一个最相关的状态标签 -->
|
||||
<template v-if="task.matchStatus === 'matched_signed'">
|
||||
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
|
||||
今日校园侧签到状态同步有延迟时 matchStatus 可能仍是未签到,应以本地成功为准 -->
|
||||
<template v-if="task.taskStatus === 'success' || task.matchStatus === 'matched_signed'">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-green-100 text-green-600">已签到</span>
|
||||
</template>
|
||||
<template v-else-if="task.matchStatus === 'matched_unsigned'">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next'
|
||||
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert, Sparkles } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
@@ -13,6 +13,9 @@ const message = useMessage()
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const avatarFailed = ref(false)
|
||||
|
||||
// 「再次查看引导」:注入 MobileLayout 提供的重播函数(重新弹出新手引导,从第一步开始)
|
||||
const restartGuide = inject<() => void>('restartGuide', () => {})
|
||||
|
||||
// 邀请信息
|
||||
const inviteCode = ref('')
|
||||
const inviteReward = ref(0)
|
||||
@@ -428,30 +431,7 @@ async function handleRedeemInvite() {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 真实姓名 -->
|
||||
<div 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-blue-50 flex items-center justify-center">
|
||||
<User class="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">真实姓名</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ userInfo?.realName || '--' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 学号 -->
|
||||
<div 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-blue-50 flex items-center justify-center">
|
||||
<IdCard class="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">学号</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ userInfo?.studentId || '--' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 真实姓名/学号已取消展示:避免在页面明文暴露敏感信息 -->
|
||||
<!-- 设备码 -->
|
||||
<div data-guide="profile-device-code" class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -500,6 +480,15 @@ async function handleRedeemInvite() {
|
||||
<p class="text-center text-xs text-gray-300 pb-2">copyright © 2026</p>
|
||||
</div>
|
||||
|
||||
<!-- 忘记操作?再次查看引导:右侧悬浮气泡(跳动吸引注意),点击重播新手引导 -->
|
||||
<button
|
||||
class="guide-bubble fixed right-3 bottom-20 z-40 flex items-center gap-1.5 rounded-full bg-blue-500/95 pl-3 pr-3.5 py-2 text-white text-xs font-medium shadow-lg shadow-blue-500/30 hover:bg-blue-600 active:scale-95 transition-all"
|
||||
@click="restartGuide"
|
||||
>
|
||||
<Sparkles class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
忘记操作?再次查看引导
|
||||
</button>
|
||||
|
||||
<!-- 兑换弹窗遮罩 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
@@ -638,6 +627,18 @@ async function handleRedeemInvite() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 引导气泡:上下浮动跳动,吸引注意但不刺眼 */
|
||||
@keyframes guide-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
.guide-bubble {
|
||||
animation: guide-bob 1.5s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.guide-bubble { animation: none; }
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
Reference in New Issue
Block a user