Compare commits

...

4 Commits

6 changed files with 134 additions and 34 deletions
+23
View File
@@ -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
+18
View File
@@ -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()
}
}
+10 -1
View File
@@ -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
+14 -1
View File
@@ -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>
+42 -6
View File
@@ -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'">
+27 -26
View File
@@ -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;