v1.2.112:重构首页置顶公告;打卡日志改时间线、任务行精修
ci/woodpecker/tag/build Pipeline was successful

- 置顶公告新组件 PinnedNotice:一次一条上下翻页轮播(N/M 计数),长文本先停顿再横向滚到末尾,
  点击打开底部面板查看全部全文;切后台 / 减弱动态效果时暂停;柠檬暖色卡片,亮暗各一套配色
- 打卡日志:状态圆点 + 竖线时间线,相对时间(刚刚 / N 分钟前 / 今天 / 昨天),失败原因直接展示,空状态带说明
- 任务行:左侧状态色条,执行按钮改品牌渐变圆形主按钮,编辑/删除降级为图标按钮
- 贴边竖条(收藏本站 / 无法请销假 / 网课)位置统一上移避开引导入口;收藏本站改柠檬底深棕字
- 修复 scoped 样式中 `:global(.dark) .x` 被编译为仅 `.dark` 导致暗色覆盖失效(请销假头卡暗色此前未生效)
This commit is contained in:
becomer
2026-10-02 19:38:36 +08:00
parent 9f2f705457
commit 51f3641a0c
4 changed files with 396 additions and 96 deletions
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
// 置顶公告条(首页顶部)。
// 交互:一次展示一条,每 4 秒向上翻页切换;内容过长时先横向缓慢滚动到末尾再翻页;
// 点击整条打开底部面板查看全部置顶公告全文。页面切到后台 / 系统「减弱动态效果」时暂停轮播。
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Megaphone, ChevronRight, X } from 'lucide-vue-next'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import type { PinnedAnnouncement } from '@/api/announcement'
const props = defineProps<{ items: PinnedAnnouncement[] }>()
const index = ref(0)
const showAll = ref(false)
const current = computed(() => props.items[index.value] || null)
// —— 长文本横向滚动:测量溢出宽度,按 40px/s 速度滚动到末尾 ——
const viewport = ref<HTMLElement | null>(null)
const track = ref<HTMLElement | null>(null)
const overflow = ref(0)
const DWELL = 4000 // 短内容停留时长
const SCROLL_SPEED = 55 // px/s(长文本按此速度滚动,总时长再夹在 3–9 秒之间)
const reduceMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
let timer: ReturnType<typeof setTimeout> | null = null
function clearTimer() {
if (timer) clearTimeout(timer)
timer = null
}
async function measure() {
overflow.value = 0
await nextTick()
const v = viewport.value
const t = track.value
if (!v || !t) return
overflow.value = Math.max(0, Math.ceil(t.scrollWidth - v.clientWidth))
}
/** 当前条展示时长:短内容固定停留;长内容 = 起步停顿 + 滚动时长 + 末尾停顿 */
const scrollDuration = computed(() => (overflow.value > 0 ? Math.min(9, Math.max(3, overflow.value / SCROLL_SPEED)) : 0))
function schedule() {
clearTimer()
if (props.items.length < 2 && overflow.value === 0) return
if (reduceMotion || document.visibilityState !== 'visible' || showAll.value) return
const ms = overflow.value > 0 ? 1000 + scrollDuration.value * 1000 + 1200 : DWELL
timer = setTimeout(next, ms)
}
function next() {
if (props.items.length > 1) {
index.value = (index.value + 1) % props.items.length
} else {
// 只有一条且内容过长:重置后重新滚动一遍
overflow.value = 0
void measure().then(schedule)
}
}
watch(index, async () => {
await measure()
schedule()
})
watch(
() => props.items,
async () => {
if (index.value >= props.items.length) index.value = 0
await measure()
schedule()
},
{ deep: false },
)
function onVisibility() {
if (document.visibilityState === 'visible') schedule()
else clearTimer()
}
function openAll() {
showAll.value = true
clearTimer()
}
function closeAll(v: boolean) {
if (v) return
showAll.value = false
schedule()
}
function fmtDate(s: string) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return ''
return `${d.getMonth() + 1}月${d.getDate()}日`
}
onMounted(async () => {
await measure()
schedule()
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('resize', measure)
})
onUnmounted(() => {
clearTimer()
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('resize', measure)
})
</script>
<template>
<button
v-if="current"
type="button"
class="press notice-card w-full flex items-center gap-2.5 rounded-2xl pl-2 pr-2.5 py-2 text-left"
:aria-label="`置顶公告:${current.title},点击查看全部`"
@click="openAll"
>
<!-- 图标:柠檬色圆底 + 轻微摆动 -->
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0">
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
</span>
<!-- 内容视窗:上下翻页切换 + 长文本横向滚动 -->
<span ref="viewport" class="relative flex-1 min-w-0 h-5 overflow-hidden">
<Transition name="notice-flip">
<span
:key="current.id + '-' + index"
class="absolute inset-0 flex items-center"
>
<span
ref="track"
class="notice-track whitespace-nowrap text-[13px] leading-5"
:class="overflow > 0 && 'is-scrolling'"
:style="overflow > 0 ? { '--notice-shift': `-${overflow}px`, '--notice-dur': `${scrollDuration}s` } : undefined"
>
<span class="notice-title font-semibold">{{ current.title }}</span>
<span class="notice-sep mx-1.5">·</span>
<span class="notice-text">{{ current.content }}</span>
</span>
</span>
</Transition>
</span>
<!-- 计数 + 箭头 -->
<span class="flex items-center gap-0.5 flex-shrink-0">
<span v-if="items.length > 1" class="notice-count text-xs tabular-nums font-medium">{{ index + 1 }}/{{ items.length }}</span>
<ChevronRight class="notice-arrow w-4 h-4" />
</span>
</button>
<!-- 全部置顶公告(底部面板) -->
<AppDialog
:open="showAll"
overlay-class="bg-black/40"
panel-class="sm:max-w-sm max-h-[75vh] shadow-2xl"
:fade="true"
title="置顶公告"
@update:open="closeAll"
>
<div class="flex justify-center pt-2.5 pb-1 flex-shrink-0">
<div class="w-9 h-1 rounded-full bg-gray-300" />
</div>
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center">
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
</span>
<span class="font-bold text-gray-900 text-base">置顶公告</span>
</div>
<button class="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center" aria-label="关闭" @click="closeAll(false)">
<X class="w-4 h-4 text-gray-400" />
</button>
</div>
<div class="flex-1 overflow-y-auto px-5 pt-1 pb-6 space-y-3">
<article
v-for="(a, i) in items"
:key="a.id"
class="rounded-2xl border p-4"
:class="i === index ? 'border-amber-200 bg-amber-50' : 'border-gray-100 bg-gray-50'"
>
<div class="flex items-start justify-between gap-3 mb-1.5">
<h4 class="text-sm font-semibold text-gray-900 leading-snug">{{ a.title }}</h4>
<span v-if="fmtDate(a.createdAt)" class="text-xs text-gray-400 flex-shrink-0 mt-0.5">{{ fmtDate(a.createdAt) }}</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap break-words">{{ a.content }}</p>
</article>
</div>
</AppDialog>
</template>
<style scoped>
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
.notice-card {
background: linear-gradient(135deg, rgb(255 246 214 / 0.92), rgb(255 250 235 / 0.86));
border: 1px solid rgb(245 189 46 / 0.32);
box-shadow: 0 1px 2px rgb(138 90 0 / 0.06), 0 6px 16px rgb(227 160 8 / 0.1);
-webkit-backdrop-filter: blur(12px) saturate(160%);
backdrop-filter: blur(12px) saturate(160%);
}
.notice-icon {
color: #8a5a00;
background: linear-gradient(135deg, #ffe08a, #f5bd2e);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);
animation: notice-wiggle 4s ease-in-out infinite;
transform-origin: 60% 60%;
}
.notice-title { color: #6e4700; }
.notice-sep { color: rgb(138 90 0 / 0.7); }
.notice-text { color: #8a5a00; }
.notice-count { color: rgb(138 90 0 / 0.75); }
.notice-arrow { color: rgb(138 90 0 / 0.55); }
/* 喇叭轻摆:每 4 秒摆两下,吸引注意但不吵闹 */
@keyframes notice-wiggle {
0%, 86%, 100% { transform: rotate(0); }
89% { transform: rotate(-12deg); }
92% { transform: rotate(9deg); }
95% { transform: rotate(-5deg); }
}
/* 长文本:起步停顿 1s → 匀速滚到末尾 → 停住(翻页由脚本计时) */
.notice-track.is-scrolling {
animation: notice-scroll var(--notice-dur, 6s) linear 1s forwards;
}
@keyframes notice-scroll {
to { transform: translateX(var(--notice-shift, 0)); }
}
/* 上下翻页切换 */
.notice-flip-enter-active,
.notice-flip-leave-active {
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.35s ease;
}
.notice-flip-enter-from {
transform: translateY(100%);
opacity: 0;
}
.notice-flip-leave-to {
transform: translateY(-100%);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.notice-icon,
.notice-track.is-scrolling {
animation: none;
}
}
</style>
<style>
/* 暗色覆盖:放在非 scoped 块里(scoped 中的 :global(.dark) .x 会被编译成仅 `.dark`,规则失效);
用 html.dark 提高优先级,稳定压过 scoped 的 .x[data-v] */
html.dark .notice-card {
background: linear-gradient(135deg, rgb(64 51 26 / 0.85), rgb(51 42 22 / 0.8));
border-color: rgb(247 197 74 / 0.22);
box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
}
html.dark .notice-title { color: #ffe39a; }
html.dark .notice-sep { color: rgb(255 217 120 / 0.65); }
html.dark .notice-text { color: #f2d48a; }
html.dark .notice-count { color: rgb(255 217 120 / 0.7); }
html.dark .notice-arrow { color: rgb(255 217 120 / 0.5); }
</style>
+1 -1
View File
@@ -34,7 +34,7 @@ function goTicket() {
<!-- ======== 我有网课:右侧贴边窄竖条(样式同打卡页「收藏本站」/请假页「无法请销假」),点击跳转工单页 ======== -->
<button
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-rose-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
class="fixed right-0 bottom-40 z-40 flex flex-col items-center gap-1.5 rounded-l-xl bg-rose-500 py-3 px-1.5 text-white text-xs font-semibold shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
@click="goTicket"
>
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
+120 -91
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircle, Clock, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star, Megaphone } from 'lucide-vue-next'
import { CheckCircle, Clock, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import PinnedNotice from '@/components/PinnedNotice.vue'
import jsQR from 'jsqr'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
@@ -174,7 +175,7 @@ function onVisibilityChange() {
loadTaskLogs()
}
// ======== 置顶公告(顶部横向循环滚动) ========
// ======== 置顶公告(顶部轮播条,渲染见 PinnedNotice) ========
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
async function loadPinnedAnnouncements() {
@@ -1056,16 +1057,48 @@ function statusLabel(status: string) {
// 匹配状态背景色:当天已成功执行的任务一律显示绿色
// (今日校园侧状态同步存在延迟,不能因 matchStatus 未及时更新就显示成"未签到"的蓝色)
function matchBgClass(task: authApi.TaskInfo) {
if (task.taskStatus === 'success') return 'bg-green-50/60'
if (task.taskStatus === 'success') return 'bg-green-50'
const map: Record<string, string> = {
matched_unsigned: 'bg-blue-50/60',
matched_signed: 'bg-green-50/60',
no_match: 'bg-amber-50/60',
matched_unsigned: 'bg-brand-50',
matched_signed: 'bg-green-50',
no_match: 'bg-amber-50',
check_failed: 'bg-gray-50',
}
return map[task.matchStatus] || 'bg-gray-50'
}
/** 任务行左侧状态色条 */
function matchAccentClass(task: authApi.TaskInfo) {
if (task.taskStatus === 'success' || task.matchStatus === 'matched_signed') return 'bg-green-500'
if (task.taskStatus === 'fail') return 'bg-red-500'
if (task.matchStatus === 'matched_unsigned') return 'bg-brand-400'
if (task.matchStatus === 'no_match') return 'bg-amber-400'
return 'bg-gray-300'
}
/** 任务类型标签配色 */
function taskTypeClass(t: string) {
if (t === 'sleep_check') return 'bg-purple-100 text-purple-600'
if (t === 'return_register') return 'bg-amber-100 text-amber-700'
return 'bg-brand-50 text-brand-600'
}
/** 相对时间:刚刚 / N 分钟前 / 今天 HH:mm / 昨天 HH:mm / M月D日 HH:mm */
function relTime(s: string) {
const d = new Date(s)
if (Number.isNaN(d.getTime())) return ''
const now = new Date()
const diff = (now.getTime() - d.getTime()) / 1000
const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
if (diff >= 0 && diff < 60) return '刚刚'
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
const day = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
const dd = (day(now) - day(d)) / 86400000
if (dd === 0) return `今天 ${hm}`
if (dd === 1) return `昨天 ${hm}`
return `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`
}
function taskTypeLabel(t: string) {
if (t === 'sleep_check') return '查寝'
if (t === 'return_register') return '返校登记'
@@ -1223,26 +1256,8 @@ function getGPSLocation(source: string) {
<!-- ======== 主内容 ======== -->
<div v-else class="p-4 space-y-4">
<!-- 置顶公告:整行满宽、右→左无缝循环滚动(标题:详情,滚动完自动接回开头) -->
<div
v-if="pinnedAnnouncements.length"
class="-mx-4 flex items-center gap-2 bg-amber-50 px-3 py-2 overflow-hidden"
>
<Megaphone class="w-4 h-4 text-amber-500 flex-shrink-0" />
<div class="relative flex-1 overflow-hidden">
<div class="announcement-marquee flex items-center whitespace-nowrap">
<!-- 渲染两组相同内容:translateX(-50%) 位移正好等于一组宽度,实现无缝衔接 -->
<template v-for="copy in 2" :key="'copy-' + copy">
<template v-for="a in pinnedAnnouncements" :key="'copy-' + copy + '-' + a.id">
<span class="text-xs text-amber-700 flex-shrink-0">
<span class="font-semibold text-amber-800">{{ a.title }}</span>:{{ a.content }}
</span>
<span class="text-amber-300 mx-4 flex-shrink-0">◆</span>
</template>
</template>
</div>
</div>
</div>
<!-- 置顶公告:一次一条上下翻页、长文本横向滚动,点击查看全部(见 PinnedNotice) -->
<PinnedNotice v-if="pinnedAnnouncements.length" :items="pinnedAnnouncements" class="rise-in" />
<!-- 签到创建入口(品牌渐变头卡)+ 统计 -->
<div class="relative overflow-hidden hero-clip rounded-3xl bg-brand-gradient p-5 text-white shadow-lg shadow-brand-500/25 rise-in">
@@ -1252,7 +1267,7 @@ function getGPSLocation(source: string) {
<div class="relative flex items-start justify-between">
<div>
<p class="text-lg font-bold tracking-tight">打卡管理</p>
<p class="text-xs text-white/80 mt-0.5">创建并执行每日签到任务</p>
<p class="text-xs text-white/90 mt-0.5">创建并执行每日签到任务</p>
</div>
<div class="w-11 h-11 rounded-2xl bg-white/20 flex items-center justify-center">
<ListTodo class="w-5 h-5 text-white" />
@@ -1262,19 +1277,19 @@ function getGPSLocation(source: string) {
<div class="relative grid grid-cols-4 gap-2 mt-4">
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.filter(t => t.taskStatus === 'success').length }}</p>
<p class="text-xs text-white/80 mt-0.5">已完成</p>
<p class="text-xs text-white/90 mt-0.5">已完成</p>
</div>
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.filter(t => t.matchStatus === 'matched_signed').length }}</p>
<p class="text-xs text-white/80 mt-0.5">已签到</p>
<p class="text-xs text-white/90 mt-0.5">已签到</p>
</div>
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.filter(t => t.taskStatus === 'pending' && t.matchStatus !== 'matched_signed').length }}</p>
<p class="text-xs text-white/80 mt-0.5">待签到</p>
<p class="text-xs text-white/90 mt-0.5">待签到</p>
</div>
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.length }}</p>
<p class="text-xs text-white/80 mt-0.5">任务数</p>
<p class="text-xs text-white/90 mt-0.5">任务数</p>
</div>
</div>
<button
@@ -1295,13 +1310,14 @@ function getGPSLocation(source: string) {
<div
v-for="task in tasks"
:key="task.id"
class="flex items-center justify-between p-3 rounded-lg transition-colors"
class="relative overflow-hidden flex items-center justify-between pl-4 pr-2.5 py-3 rounded-2xl transition-colors"
:class="matchBgClass(task)"
>
<span class="absolute left-0 top-3 bottom-3 w-1 rounded-r-full" :class="matchAccentClass(task)" aria-hidden="true" />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-gray-900 truncate min-w-0">{{ task.title }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (task.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(task.taskType) }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="taskTypeClass(task.taskType)">{{ taskTypeLabel(task.taskType) }}</span>
</div>
<div class="flex items-center gap-2 mt-1">
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
@@ -1335,8 +1351,9 @@ function getGPSLocation(source: string) {
<!-- 编辑按钮 -->
<button
@click="openEditModal(task)"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-200 text-gray-400 hover:text-blue-500 transition-colors"
class="press w-8 h-8 flex items-center justify-center rounded-full text-gray-400 hover:bg-white/70 hover:text-brand-600"
title="编辑表单"
aria-label="编辑表单"
>
<Pencil class="w-4 h-4" />
</button>
@@ -1346,7 +1363,7 @@ function getGPSLocation(source: string) {
data-guide="home-task-exec"
@click="executeTask(task)"
:disabled="executingId !== null"
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white transition-colors"
class="press w-9 h-9 flex items-center justify-center rounded-full bg-brand-gradient text-white shadow-md shadow-brand-500/25 disabled:opacity-50"
:title="task.taskStatus === 'fail' ? '重新执行' : '执行'"
>
<Play v-if="executingId !== task.id" class="w-4 h-4" />
@@ -1357,7 +1374,7 @@ function getGPSLocation(source: string) {
v-if="task.taskStatus === 'success'"
@click="executeTask(task)"
:disabled="executingId !== null"
class="w-8 h-8 flex items-center justify-center rounded-lg transition-colors disabled:opacity-60 hover:bg-green-50"
class="press w-9 h-9 flex items-center justify-center rounded-full disabled:opacity-60 hover:bg-white/70"
:title="executingId === task.id ? '执行中…' : '已打卡,点击可再次打卡'"
>
<LoaderCircle v-if="executingId === task.id" class="w-4 h-4 animate-spin" />
@@ -1367,7 +1384,8 @@ function getGPSLocation(source: string) {
<button
@click="removeTask(task.id)"
:disabled="deletingId !== null"
class="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 transition-colors disabled:opacity-60 hover:bg-gray-200 hover:text-red-500"
class="press w-8 h-8 flex items-center justify-center rounded-full text-gray-300 disabled:opacity-60 hover:bg-white/70 hover:text-red-500"
aria-label="删除任务"
:title="deletingId === task.id ? '删除中…' : '删除任务'"
>
<LoaderCircle v-if="deletingId === task.id" class="w-4 h-4 animate-spin" />
@@ -1385,62 +1403,75 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 任务日志列表 -->
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm mt-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">打卡日志</h3>
<div v-if="loadingLogs" class="flex items-center justify-center py-6">
<LoaderCircle class="w-5 h-5 text-blue-400 animate-spin" />
<!-- 任务日志列表:时间线样式(状态圆点 + 竖线串联),时间用相对表述 -->
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">打卡日志</h3>
<span v-if="taskLogs.length" class="text-xs text-gray-400">
近 {{ Math.min(taskLogs.length, 10) }} 条 · 成功 {{ taskLogs.slice(0, 10).filter((l) => l.isSuccess).length }}
</span>
</div>
<div v-else-if="taskLogs.length > 0" class="space-y-1.5">
<div
v-for="log in taskLogs.slice(0, 10)"
:key="log.id"
class="flex items-center justify-between p-2.5 rounded-lg bg-gray-50"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-xs font-medium text-gray-900 truncate">{{ log.title }}</span>
<span v-if="log.realTitle && log.realTitle !== log.title" class="text-xs text-gray-400 truncate">→ {{ log.realTitle }}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span
class="text-xs px-1.5 py-0.5 rounded-full font-medium"
:class="log.isSuccess ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
<span class="text-xs text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</span>
</div>
<div v-if="!log.isSuccess && log.errorMessage" class="mt-0.5">
<span class="text-xs text-red-500 leading-tight block" v-html="log.errorMessage"></span>
</div>
<div v-if="loadingLogs" class="space-y-3 py-1">
<div v-for="i in 3" :key="'ls' + i" class="flex gap-3">
<div class="w-2.5 h-2.5 mt-1 rounded-full bg-gray-200 animate-pulse" />
<div class="flex-1 space-y-1.5">
<div class="h-3.5 w-32 bg-gray-100 rounded animate-pulse" />
<div class="h-3 w-20 bg-gray-100 rounded animate-pulse" />
</div>
<span
class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ml-2"
:class="log.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (log.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')"
>
{{ taskTypeLabel(log.taskType) }}
</span>
<button
@click="removeTaskLog(log.id)"
class="shrink-0 ml-1 w-6 h-6 flex items-center justify-center rounded hover:bg-gray-200 text-gray-400 hover:text-red-500 transition-colors"
title="删除日志"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div v-else class="flex items-center justify-center py-6">
<p class="text-xs text-gray-400">暂无打卡日志</p>
<ol v-else-if="taskLogs.length > 0" class="relative">
<li
v-for="(log, i) in taskLogs.slice(0, 10)"
:key="log.id"
class="group relative flex gap-3 pb-3 last:pb-0"
>
<!-- 竖线(最后一条不画) -->
<span v-if="i < Math.min(taskLogs.length, 10) - 1" class="absolute left-[5px] top-4 bottom-0 w-px bg-gray-200" aria-hidden="true" />
<!-- 状态圆点 -->
<span
class="relative mt-1 w-[11px] h-[11px] rounded-full flex-shrink-0 ring-4"
:class="log.isSuccess ? 'bg-green-500 ring-green-100' : 'bg-red-500 ring-red-100'"
aria-hidden="true"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-gray-900 truncate">{{ log.title }}</span>
<span class="text-xs px-1.5 py-px rounded-md shrink-0 font-medium" :class="taskTypeClass(log.taskType)">{{ taskTypeLabel(log.taskType) }}</span>
<span class="ml-auto pl-2 text-xs text-gray-400 shrink-0 tabular-nums" :title="new Date(log.createdAt).toLocaleString('zh-CN')">{{ relTime(log.createdAt) }}</span>
</div>
<p v-if="log.realTitle && log.realTitle !== log.title" class="text-xs text-gray-400 truncate mt-0.5">{{ log.realTitle }}</p>
<p class="text-xs mt-0.5" :class="log.isSuccess ? 'text-green-600' : 'text-red-500'">
<template v-if="log.isSuccess">签到成功</template>
<span v-else-if="log.errorMessage" class="leading-snug block break-words" v-html="log.errorMessage"></span>
<template v-else>签到失败</template>
</p>
</div>
<button
@click="removeTaskLog(log.id)"
class="press shrink-0 -mr-1 w-7 h-7 flex items-center justify-center rounded-lg text-gray-300 hover:bg-gray-100 hover:text-red-500"
title="删除日志"
aria-label="删除日志"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</li>
</ol>
<div v-else class="flex flex-col items-center justify-center py-6 text-center">
<div class="w-11 h-11 rounded-2xl bg-gray-100 flex items-center justify-center mb-2">
<Clock class="w-5 h-5 text-gray-400" />
</div>
<p class="text-sm text-gray-500">暂无打卡日志</p>
<p class="text-xs text-gray-400 mt-0.5">执行任务后会在这里记录结果</p>
</div>
</div>
<!-- 收藏本站:右侧贴边窄竖条(图标在上、文字竖排在下;避开新手引导入口按钮),点击展示添加书签指引 -->
<button
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-amber-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-amber-500/30 hover:bg-amber-600 active:scale-95 transition-all"
class="bookmark-tab fixed right-0 bottom-40 z-40 flex flex-col items-center gap-1.5 rounded-l-xl py-3 px-1.5 text-xs font-semibold shadow-lg active:scale-95 transition-all"
@click="openBookmarkDialog"
>
<Star class="w-3.5 h-3.5 flex-shrink-0" />
@@ -2108,19 +2139,17 @@ function getGPSLocation(source: string) {
</template>
<style scoped>
/* 收藏本站贴边竖条:柠檬渐变 + 深棕字(固定色,亮暗一致) */
.bookmark-tab {
color: #5b3a00;
background: linear-gradient(180deg, #ffe08a, #f5bd2e);
box-shadow: -4px 6px 16px rgb(227 160 8 / 0.28);
}
/* 收藏按钮竖排文字:仅文字本身竖排(容器保持常规纵排,图标在上文字在下) */
.bookmark-vtext {
writing-mode: vertical-rl;
letter-spacing: 2px;
}
/* 置顶公告:右→左无缝循环滚动(渲染两组,位移 -50% 恰好一组宽度,滚动完接回开头) */
.announcement-marquee {
animation: announcement-scroll 20s linear infinite;
will-change: transform;
}
@keyframes announcement-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
</style>
+8 -4
View File
@@ -711,7 +711,7 @@
</AppDialog>
<!-- ======== 无法请/销假:右侧贴边窄竖条(样式同打卡页「收藏本站」),点击跳转工单页 ======== -->
<button
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-rose-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
class="fixed right-0 bottom-40 z-40 flex flex-col items-center gap-1.5 rounded-l-xl bg-rose-500 py-3 px-1.5 text-white text-xs font-semibold shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
@click="goTicket"
>
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
@@ -1692,9 +1692,6 @@ async function submitApply() {
background-color: #d23c68;
background-image: linear-gradient(135deg, #f0708c 0%, #d9466d 55%, #c23a62 100%);
}
:global(.dark) .leave-hero {
background-image: linear-gradient(135deg, #b8435f 0%, #9e3352 60%, #8a2c49 100%);
}
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
@@ -1703,3 +1700,10 @@ async function submitApply() {
letter-spacing: 2px;
}
</style>
<style>
/* 请/销假头卡暗色:非 scoped(scoped 中 :global(.dark) .x 会被编译成仅 .dark,规则失效) */
html.dark .leave-hero {
background-image: linear-gradient(135deg, #b8435f 0%, #9e3352 60%, #8a2c49 100%);
}
</style>