Compare commits

...

7 Commits

Author SHA1 Message Date
becomer 02dcd6fc32 v1.2.113:重构个人中心
ci/woodpecker/tag/build Pipeline was successful
- 头图改左对齐:头像 + 姓名 + 学校,补充学院/班级标签,细网格纹理
- 新增会员卡压在头图下沿:VIP 黑金(显示到期日与剩余天数)/ 普通用户珍珠白,主按钮「开通 VIP / 续费」
- 数据三宫格:可执行次数(点击兑换)/ 邀请积分(够 1 直接换次数,不够提示规则)/ 已邀请人数
- 邀请卡:薰衣草渐变,邀请码大字展示 + 复制按钮,奖励规则一句话说明,「点此填写」他人邀请码
- 功能菜单改 iOS 分组列表:设备码带「已授权/随机/未绑定」状态标签;工单显示新回复数
- 新手引导文案同步新布局;暗色下紫色按钮改固定色保证白字对比度
2026-10-02 19:58:12 +08:00
becomer 51f3641a0c v1.2.112:重构首页置顶公告;打卡日志改时间线、任务行精修
ci/woodpecker/tag/build Pipeline was successful
- 置顶公告新组件 PinnedNotice:一次一条上下翻页轮播(N/M 计数),长文本先停顿再横向滚到末尾,
  点击打开底部面板查看全部全文;切后台 / 减弱动态效果时暂停;柠檬暖色卡片,亮暗各一套配色
- 打卡日志:状态圆点 + 竖线时间线,相对时间(刚刚 / N 分钟前 / 今天 / 昨天),失败原因直接展示,空状态带说明
- 任务行:左侧状态色条,执行按钮改品牌渐变圆形主按钮,编辑/删除降级为图标按钮
- 贴边竖条(收藏本站 / 无法请销假 / 网课)位置统一上移避开引导入口;收藏本站改柠檬底深棕字
- 修复 scoped 样式中 `:global(.dark) .x` 被编译为仅 `.dark` 导致暗色覆盖失效(请销假头卡暗色此前未生效)
2026-10-02 19:38:36 +08:00
becomer 9f2f705457 v1.2.111:修复线上暗色主题全站灰底;重做外观切换控件
ci/woodpecker/tag/build Pipeline was successful
- 根因:postbuild-css 解包 :where() 时直接去壳,Tailwind 暗色变体 `.x:where(.dark, .dark *)` 被拆成
  `.x.dark, .dark *`,`.dark *` 成为全局规则,暗色下所有元素被刷上同一背景/边框(开发服务器不经后处理,故本地截图正常)。
  改为先展开逗号列表(`.dark .x`),并在产物中出现全局 `.dark *` 规则时直接构建失败
- 外观切换改为整宽三段分段控件 + 滑动高亮,窄屏不再挤压换行
- 我的页对比度:续费 VIP / 兑换按钮改固定深棕字(≥4.6:1),奖励数字改 amber-600
2026-10-02 15:21:16 +08:00
becomer 301a4fb971 v1.2.110:打卡/请销假页头像行上下间距统一为 16px(原上 12px、下 28px)
ci/woodpecker/tag/build Pipeline was successful
2026-10-02 14:31:36 +08:00
becomer df40b2ae73 v1.2.109:顶部安全区仅在主屏幕全屏模式生效,修复浏览器/App 内顶栏下移
ci/woodpecker/tag/build Pipeline was successful
部分浏览器与 App 内置 WebView 已把网页放在状态栏下方,却仍上报 safe-area-inset-top,
叠加后头像行凭空下移约 47px。改为 --safe-top 变量,仅 display-mode: standalone/fullscreen 时取 env() 值。
2026-10-02 14:13:41 +08:00
becomer 317bf4b5a1 v1.2.108:修复新手引导末步页面左移;新版本提示更及时
ci/woodpecker/tag/build Pipeline was successful
- 引导定位 scrollIntoView 改为 inline:nearest,不再横向滚动 overflow:hidden 的头卡容器(装饰光斑超出边界导致整块左移 45px);头卡改用 overflow:clip 兜底
- UpdateChecker:检查间隔 5 分钟→2 分钟、首检 30s→10s;页面回到前台(切回标签/唤起 App)时补查;修正 prompting 在 finally 中被清零导致的并发防护失效
2026-10-02 13:59:40 +08:00
becomer 1c112fe3dc v1.2.107:修复打卡页横向撑宽、底部导航滚动时缩放抖动
ci/woodpecker/tag/build Pipeline was successful
- 页面包裹层 grid 列宽锁定为容器宽度(grid-cols-1),置顶公告走马灯不再把整页撑到数千像素宽
- TabBar 收窄逻辑防抖:可滚动距离 <200px 的页面不收窄;scrollTop 夹在 [0,max] 忽略 iOS 回弹;同向滚动超过 32px 才切换;到底部不再强制展开
2026-10-02 13:47:41 +08:00
14 changed files with 881 additions and 311 deletions
+107 -3
View File
@@ -27,7 +27,106 @@ const TARGETS = {
android: 78 << 16,
}
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则) */
/** 按顶层逗号拆分(忽略括号/方括号内的逗号) */
function splitTopLevel(s) {
const parts = []
let depth = 0
let start = 0
for (let k = 0; k < s.length; k++) {
const ch = s[k]
if (ch === '(' || ch === '[') depth++
else if (ch === ')' || ch === ']') depth--
else if (ch === ',' && depth === 0) {
parts.push(s.slice(start, k))
start = k + 1
}
}
parts.push(s.slice(start))
return parts.map((p) => p.trim())
}
/**
* 把 :where 的单个备选项 alt 合并回原选择器(before + :where(alt) + after)。
* - alt 是单个复合选择器(如 `.dark`、`[type=button]`):直接拼进同一复合选择器;
* - alt 形如 `A *`(祖先 A 的任意后代,Tailwind dark 变体即 `.dark *`):
* 语义为「元素满足 before/after,且有祖先满足 A」→ `A ` + before + after。
* 若 before 自身含组合符(祖先链),A 放在最前面;dark 变体的 `.dark` 在 <html> 上,
* 是所有元素的祖先,结果与原语义一致。
* - 其他带组合符的形式无法在不支持 :is() 的老内核上等价表达 → 直接报错,绝不静默产出错误 CSS。
*/
function expandAlternative(before, alt, after, whole) {
const parts = alt.split(/\s+/).filter(Boolean)
if (parts.length === 1 && !/[>+~]/.test(alt)) return before + alt + after
if (parts.length === 2 && parts[1] === '*' && !/[>+~]/.test(parts[0])) {
// `before` 为空(规则本身就是 `:where(A *)`)时退化为 `A *`
const rest = before + after
return parts[0] + ' ' + (rest.trim() ? rest : '*')
}
throw new Error(`[postbuild-css] 无法安全展开的 :where 备选项「${alt}」(选择器:${whole})`)
}
/**
* 展开选择器里「带逗号列表」的 :where(A, B):
* X:where(A, B)Y → XAY, XBY(含组合符的备选项见 expandAlternative)
* 直接去壳会得到 `XA, BY`:逗号把选择器拆开,后半段 B 变成一条独立的全局规则。
* 典型事故:dark 变体 `.dark\:bg-x:where(.dark, .dark *)` 去壳成
* `.dark\:bg-x.dark, .dark *` —— `.dark *` 命中暗色下所有元素,全站被刷成同一背景色。
* 只处理规则选择器(`{` 之前的部分),声明块内容原样保留。
*/
function expandWhereLists(css) {
return css.replace(/([^{}]+)\{/g, (whole, selectorList) => {
if (!selectorList.includes(':where(')) return whole
// 跳过 at-rule 前缀(如 @media (...) / @supports (...)),这些不是选择器
if (selectorList.trim().startsWith('@')) return whole
const expanded = []
for (const sel of splitTopLevel(selectorList)) {
let queue = [sel]
for (let guard = 0; guard < 8; guard++) {
let changed = false
const next = []
for (const s of queue) {
const m = s.indexOf(':where(')
if (m < 0) {
next.push(s)
continue
}
let depth = 0
let end = -1
for (let k = m + 6; k < s.length; k++) {
if (s[k] === '(') depth++
else if (s[k] === ')') {
depth--
if (depth === 0) {
end = k
break
}
}
}
if (end < 0) {
next.push(s)
continue
}
const inner = splitTopLevel(s.slice(m + 7, end))
if (inner.length < 2) {
next.push(s)
continue
}
changed = true
const before = s.slice(0, m)
const after = s.slice(end + 1)
for (const alt of inner) next.push(expandAlternative(before, alt, after, s))
}
queue = next
if (!changed) break
}
expanded.push(...queue)
}
return expanded.join(',') + '{'
})
}
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则)。
* 须先经 expandWhereLists 展开逗号列表,此处只剩单项 :where(A),去壳即等价 */
function unwrapWhere(css) {
let out = ''
let i = 0
@@ -122,8 +221,9 @@ for (const file of files) {
const path = join(assetsDir, file)
const src = await readFile(path)
// ① 解包 :where()(lightningcss 不会处理它,需先做)
let prepared = Buffer.from(unwrapWhere(src.toString('utf8')))
// ① 解包 :where()(lightningcss 不会处理它,需先做):
// 先把 :where(A, B) 展开成多条选择器,再对剩余单项 :where(A) 去壳
let prepared = Buffer.from(unwrapWhere(expandWhereLists(src.toString('utf8'))))
const whereCount = (src.toString('utf8').match(/:where\(/g) || []).length
// ② 按目标内核降级(oklch 等新颜色函数 → 老内核可识别的色值)
@@ -142,6 +242,10 @@ for (const file of files) {
if (/@layer/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 @layer`)
if (/oklch\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 oklch(`)
if (/:where\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 :where(`)
// 暗色变体去壳错误的特征:出现一条以 `.dark *` 开头的独立全局规则(会把暗色下所有元素刷成同一样式)
if (/(^|[{},])\s*\.dark \*\s*\{/.test(out)) {
throw new Error(`[postbuild-css] ${file} 出现全局 \`.dark *\` 规则,:where 逗号列表展开失败`)
}
assertBalanced(out, file)
await writeFile(path, out)
+1 -1
View File
@@ -40,7 +40,7 @@ watch(
<!-- 顶部居中胶囊 Toast(液态玻璃,iOS 风格灵动提示) -->
<div
class="fixed inset-x-0 top-0 z-[100] flex flex-col items-center gap-2 px-4 pointer-events-none"
style="padding-top: calc(env(safe-area-inset-top) + 12px)"
style="padding-top: calc(var(--safe-top) + 12px)"
role="status"
aria-live="polite"
>
+18
View File
@@ -163,6 +163,18 @@
border-color: var(--color-border);
}
/* 顶部安全区:只在「添加到主屏幕」全屏打开(standalone)时避让状态栏。
普通浏览器与 App 内置 WebView(今日校园/微信等)本身已把网页放在状态栏下方,
但部分内核仍会上报 safe-area-inset-top,直接叠加会让顶部内容凭空下移几十像素。 */
:root {
--safe-top: 0px;
}
@media (display-mode: standalone), (display-mode: fullscreen) {
:root {
--safe-top: env(safe-area-inset-top, 0px);
}
}
html, body {
margin: 0;
padding: 0;
@@ -282,6 +294,12 @@ body {
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 60%, #0b5f55 100%);
}
/* 头卡装饰光斑容器:overflow:hidden 仍可被 scrollIntoView 等程序化滚动横向推动;
clip 彻底禁止滚动(老内核不识别 clip 时回落为同元素上的 overflow-hidden) */
.overflow-hidden.hero-clip {
overflow: clip;
}
/* 按压反馈:轻微缩放,iOS 风格 */
.press {
transition: transform 0.18s var(--ease-out-soft), opacity 0.18s ease, background-color 0.2s ease, color 0.2s ease;
+10 -8
View File
@@ -55,7 +55,7 @@ const steps: GuideStep[] = [
title: '先绑定设备码',
targets: ['[data-guide="profile-device-code"]'],
descByKind: () =>
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
'签到任务依赖设备码执行。到这一栏点「去绑定 / 重绑」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
},
{
tab: 'profile',
@@ -63,15 +63,15 @@ const steps: GuideStep[] = [
targets: ['[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-row"]'],
descByKind: (kind) =>
kind === 'profile-fill-invite'
? '如果你有好友的邀请码,点这里的「填写邀请码」输入后,你和好友都能获得奖励积分。'
: '如果你有好友的邀请码,可在「推广信息」里点「填写邀请码」输入,你和好友都能获得奖励积分。',
? '如果你有好友的邀请码,点这里填写后,你和好友都能获得奖励积分。'
: '如果你有好友的邀请码,可在「邀请同学」卡片里点「点此填写」输入,你和好友都能获得奖励积分。',
},
{
tab: 'profile',
title: '兑换积分',
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
targets: ['[data-guide="profile-invite-redeem"]'],
descByKind: () =>
'邀请好友可赚取积分,积分满 1 后,点「推广信息」里的「兑换」按钮即可换成执行次数,1 积分 = 1 次,可重复兑换。',
'邀请好友可赚取积分,积分满 1 后,点这个「邀请积分」格子即可换成执行次数,1 积分 = 1 次,可重复兑换。',
},
{
tab: 'home',
@@ -98,7 +98,7 @@ const steps: GuideStep[] = [
const inviter = fmtReward(props.inviterRewardPerInvite)
const invitee = fmtReward(props.inviteeRewardPerInvite)
if (kind === 'profile-fill-invite') {
return '你还没填写邀请码。点「填写邀请码」输入好友的邀请码,双方都能获得奖励积分。'
return '你还没填写邀请码。点「点此填写」输入好友的邀请码,双方都能获得奖励积分。'
}
if (kind === 'profile-invite-generate') {
return '把你的邀请码分享给同学,TA 在「我的」里填写你的邀请码后,你们都能获得奖励积分,多邀多得。'
@@ -114,7 +114,7 @@ const steps: GuideStep[] = [
title: 'VIP 会员:执行不限次',
targets: ['[data-guide="profile-vip-btn"]'],
descByKind: () =>
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点会员卡上的「开通 VIP / 续费」输入 VIP 兑换码即可开通,到期时间自动顺延。',
},
]
@@ -329,7 +329,9 @@ async function locate() {
// 排在目标上/下方,不会停留在顶部兜底样式遮住目标
measure(el)
try {
if (moved) el.scrollIntoView({ block: 'center', inline: 'center' })
// 只做纵向居中(inline: 'nearest'):inline: 'center' 会把 overflow:hidden 的祖先
// (如头卡装饰光斑超出边界的容器)横向滚动,导致整块内容向左错位
if (moved) el.scrollIntoView({ block: 'center', inline: 'nearest' })
} catch {
// ignore(个别浏览器不支持 options)
}
+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>
+24 -4
View File
@@ -5,8 +5,11 @@
import { onMounted, onUnmounted, ref } from 'vue'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟
const FIRST_DELAY = 30 * 1000 // 启动 30 秒后首次检查(避免与首屏请求抢占)
const CHECK_INTERVAL = 2 * 60 * 1000 // 2 分钟
const FIRST_DELAY = 10 * 1000 // 启动 10 秒后首次检查(避免与首屏请求抢占)
// 回到前台时补查:手机浏览器/App 内 WebView 切后台会暂停定时器,用户切回来时立即检查一次;
// 两次检查至少间隔 MIN_GAP,避免频繁切换造成请求风暴
const MIN_GAP = 20 * 1000
const DISMISS_KEY = 'xz_update_dismissed_version'
const showUpdate = ref(false)
@@ -16,6 +19,7 @@ let timer: ReturnType<typeof setTimeout> | null = null
let stopped = false
let prompting = false
let pendingVersion = ''
let lastCheckAt = 0
/** 从 HTML 文本中提取 /assets/ 下的 js/css 资源名 */
function extractAssets(html: string): Set<string> {
@@ -43,7 +47,9 @@ function collectCurrentAssets(): Set<string> {
}
async function checkUpdate() {
if (stopped || prompting) return
if (stopped || prompting || showUpdate.value) return
lastCheckAt = Date.now()
prompting = true
try {
// 版本探测必须绕过 HTTP 缓存(后端 index.html 有 1 小时 MaxAge),故带时间戳参数
const res = await fetch(`/?_v=${Date.now()}`, {
@@ -63,7 +69,6 @@ async function checkUpdate() {
if (sessionStorage.getItem(DISMISS_KEY) === newVersion) return // 本会话已提示过
pendingVersion = newVersion
prompting = true
showUpdate.value = true
} catch {
// 网络异常静默跳过,等待下一轮
@@ -101,15 +106,30 @@ function onDialogOpenChange(value: boolean) {
dismiss()
}
/** 页面回到前台(切回标签页 / 从后台唤起 App / bfcache 恢复)时补查一次 */
function onForeground() {
if (document.visibilityState !== 'visible') return
if (Date.now() - lastCheckAt < MIN_GAP) return
void checkUpdate()
}
onMounted(() => {
currentAssets.clear()
collectCurrentAssets().forEach((a) => currentAssets.add(a))
// 刚加载的页面本身就是最新版:以挂载时刻为基线,避免首屏 focus/pageshow 立即触发探测
lastCheckAt = Date.now()
schedule(FIRST_DELAY)
document.addEventListener('visibilitychange', onForeground)
window.addEventListener('focus', onForeground)
window.addEventListener('pageshow', onForeground)
})
onUnmounted(() => {
stopped = true
if (timer) clearTimeout(timer)
document.removeEventListener('visibilitychange', onForeground)
window.removeEventListener('focus', onForeground)
window.removeEventListener('pageshow', onForeground)
})
</script>
+35 -12
View File
@@ -41,21 +41,41 @@ const greeting = computed(() => {
return '晚上好'
})
// 滚动联动:内容离开顶部 → 顶栏显出玻璃底;向下滚动 → 胶囊 TabBar 收窄,向上滚动 → 展开
// 滚动联动:内容离开顶部 → 顶栏显出玻璃底;向下滚动 → 胶囊 TabBar 收窄,向上滚动 → 展开。
// 防抖动规则(避免"刚缩就弹回"):
// 1. 页面可滚动距离不足 COLLAPSE_MIN_RANGE(如「我的」页只多出几十像素)时不收窄;
// 2. scrollTop 夹在 [0, max]:iOS 顶/底回弹产生的越界位移不算反向滚动;
// 3. 同一方向连续滚动超过 DIR_THRESHOLD 才切换状态(方向改变时重设起点),
// 小幅来回抖动不会触发;
// 4. 回到顶部附近(< TOP_ZONE)始终展开;到达底部不再强制展开(保持当前状态)。
const COLLAPSE_MIN_RANGE = 200
const DIR_THRESHOLD = 32
const TOP_ZONE = 40
const scrollEl = ref<HTMLElement | null>(null)
const scrolled = ref(false)
const tabCollapsed = ref(false)
let lastScrollTop = 0
let dirAnchor = 0
let lastDir = 0
function onContentScroll() {
const el = scrollEl.value
if (!el) return
const top = el.scrollTop
const max = Math.max(0, el.scrollHeight - el.clientHeight)
const top = Math.min(Math.max(el.scrollTop, 0), max)
scrolled.value = top > 4
const delta = top - lastScrollTop
const nearBottom = el.scrollHeight - el.clientHeight - top < 24
if (top < 60 || nearBottom) tabCollapsed.value = false
else if (delta > 6) tabCollapsed.value = true
else if (delta < -6) tabCollapsed.value = false
if (max < COLLAPSE_MIN_RANGE || top < TOP_ZONE) {
tabCollapsed.value = false
lastScrollTop = dirAnchor = top
lastDir = 0
return
}
const dir = Math.sign(top - lastScrollTop)
if (dir !== 0 && dir !== lastDir) {
dirAnchor = lastScrollTop
lastDir = dir
}
if (dir > 0 && top - dirAnchor > DIR_THRESHOLD) tabCollapsed.value = true
else if (dir < 0 && dirAnchor - top > DIR_THRESHOLD) tabCollapsed.value = false
lastScrollTop = top
}
@@ -64,7 +84,7 @@ watch(
() => route.name,
() => {
if (scrollEl.value) scrollEl.value.scrollTop = 0
lastScrollTop = 0
lastScrollTop = dirAnchor = lastDir = 0
scrolled.value = false
tabCollapsed.value = false
},
@@ -306,9 +326,9 @@ onUnmounted(() => {
悬浮于内容之上:顶部时透明融入背景光斑,内容滚动后渐显液态玻璃底与细分隔线 -->
<div
v-if="showHeader"
class="absolute inset-x-0 top-0 z-30 px-4 pb-2.5 transition-[background-color,box-shadow,backdrop-filter] duration-300"
class="absolute inset-x-0 top-0 z-30 px-4 pb-3 transition-[background-color,box-shadow,backdrop-filter] duration-300"
:class="scrolled ? 'glass-bar shadow-[0_1px_0_rgb(14_40_34/0.06)] dark:shadow-[0_1px_0_rgb(255_255_255/0.06)]' : ''"
style="padding-top: calc(env(safe-area-inset-top) + 12px)"
style="padding-top: calc(var(--safe-top) + 16px)"
>
<div v-if="userInfo" class="flex items-center gap-3">
<div class="w-11 h-11 rounded-full bg-brand-gradient ring-2 ring-white shadow-md shadow-brand-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden dark:ring-gray-200">
@@ -339,14 +359,17 @@ onUnmounted(() => {
ref="scrollEl"
class="flex-1 overflow-y-auto overscroll-contain"
:style="{
paddingTop: showHeader ? 'calc(env(safe-area-inset-top) + 68px)' : undefined,
// 预留 60 = 顶栏上内边距 16 + 头像行 44;页面自带 p-4 的 16 即头像行到首张卡片的间距 → 上下均为 16
paddingTop: showHeader ? 'calc(var(--safe-top) + 60px)' : undefined,
paddingBottom: 'calc(env(safe-area-inset-bottom) + 92px)',
}"
@scroll.passive="onContentScroll"
>
<router-view v-slot="{ Component }">
<!-- 切页淡入(仅 opacity:不引入 transform,避免页面内 fixed 元素临时以容器为定位基准) -->
<div :key="String(route.name)" class="page-fade min-h-full grid">
<!-- grid-cols-1 = minmax(0,1fr):列宽锁定为容器宽度,避免不换行内容(如置顶公告走马灯)撑宽整页;
grid 拉伸让子页面的 min-h-full 生效(我的页靠它把退出按钮推到底部) -->
<div :key="String(route.name)" class="page-fade min-h-full grid grid-cols-1">
<component :is="Component" />
</div>
</router-view>
+1 -1
View File
@@ -162,7 +162,7 @@ onUnmounted(() => {
<template>
<div class="min-h-full flex flex-col">
<!-- 顶部说明 -->
<div class="px-6 pb-4 text-center rise-in" style="padding-top: calc(env(safe-area-inset-top) + 32px)">
<div class="px-6 pb-4 text-center rise-in" style="padding-top: calc(var(--safe-top) + 32px)">
<div class="w-16 h-16 rounded-[20px] bg-brand-gradient flex items-center justify-center mx-auto mb-3 shadow-xl shadow-brand-500/30">
<Smartphone class="w-7 h-7 text-white" />
</div>
+2 -2
View File
@@ -15,7 +15,7 @@ function goTicket() {
<template>
<div class="flex flex-col min-h-full">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: env(safe-area-inset-top)">
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
<div class="flex items-center px-4 h-12">
<h1 class="flex-1 text-center text-base font-semibold text-gray-900">网课</h1>
</div>
@@ -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" />
+121 -92
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,36 +1256,18 @@ 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 rounded-3xl bg-brand-gradient p-5 text-white shadow-lg shadow-brand-500/25 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">
<!-- 装饰光斑 -->
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-white/15" />
<div class="pointer-events-none absolute -right-2 top-16 w-20 h-20 rounded-full bg-white/10" />
<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>
+9 -5
View File
@@ -3,7 +3,7 @@
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
<div class="p-4 space-y-4">
<!-- 入口卡片:请假 / 销假 按钮并排左右平分(珊瑚渐变头卡) -->
<div class="relative overflow-hidden rounded-3xl p-5 text-white shadow-lg shadow-rose-500/25 leave-hero rise-in">
<div class="relative overflow-hidden hero-clip rounded-3xl p-5 text-white shadow-lg shadow-rose-500/25 leave-hero rise-in">
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-white/15" />
<div class="pointer-events-none absolute -right-2 top-16 w-20 h-20 rounded-full bg-white/10" />
<div class="relative flex items-start justify-between mb-4">
@@ -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>
+284 -181
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert, SunMoon, Sun, Moon } from 'lucide-vue-next'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Users, Coins, Zap, UserPlus, GraduationCap, Headset } from 'lucide-vue-next'
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -17,6 +17,10 @@ const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
]
const themeIndex = computed(() => Math.max(0, themeOptions.findIndex((o) => o.value === themeMode.value)))
const themeHint = computed(() =>
themeMode.value === 'auto' ? '跟随时间:18:00–06:00 为深色' : themeMode.value === 'dark' ? '始终使用深色' : '始终使用浅色',
)
const router = useRouter()
const message = useMessage()
@@ -125,6 +129,35 @@ function maskDeviceCode(code: string): string {
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
// 头部信息标签:学院 / 专业 / 班级(有值才显示,最多 2 个,避免挤满)
const profileTags = computed(() => {
const u = userInfo.value
if (!u) return []
return [u.academy, u.className || u.major].filter((t): t is string => !!t && !!t.trim()).slice(0, 2)
})
// VIP 剩余天数(到期时间无效 / 已过期返回 null)
const vipDaysLeft = computed(() => {
if (!isVip.value || !vipExpireAt.value) return null
const t = new Date(vipExpireAt.value).getTime()
if (Number.isNaN(t)) return null
const d = Math.ceil((t - Date.now()) / 86400000)
return d >= 0 ? d : null
})
// 「邀请积分」格子:积分够则直接兑换,不够提示规则(不再出现一个点不动的灰按钮)
function onRedeemInviteTap() {
if (!inviteCode.value) {
message.info('先生成邀请码,邀请同学后即可获得积分')
return
}
if (!canRedeem.value) {
message.info('邀请积分满 1 即可兑换 1 次执行次数')
return
}
void handleRedeemInvite()
}
onMounted(async () => {
try {
const res = await authApi.getUserInfo()
@@ -364,208 +397,208 @@ async function handleRedeemInvite() {
<template>
<div class="flex flex-col min-h-full">
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
<div class="relative overflow-hidden bg-brand-gradient px-6 pb-16 text-white text-center flex-shrink-0 rounded-b-[36px]" style="padding-top: calc(env(safe-area-inset-top) + 40px)">
<!-- 装饰光斑 -->
<div class="pointer-events-none absolute -left-16 -top-16 w-56 h-56 rounded-full bg-white/10" />
<div class="pointer-events-none absolute -right-12 top-10 w-40 h-40 rounded-full bg-white/10" />
<!-- 头像 + VIP 徽标(头像右上角,反光动画) -->
<div class="relative w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-visible ring-4 ring-white/30 shadow-lg shadow-black/10">
<div class="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
<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 z-10 whitespace-nowrap">VIP</div>
</div>
<p class="relative text-[22px] font-bold tracking-tight">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="relative text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-20 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 28px)">
<!-- 装饰:柔光圆 + 细网格纹理 -->
<div class="pointer-events-none absolute -right-16 -top-20 w-64 h-64 rounded-full bg-white/10" />
<div class="pointer-events-none absolute right-10 top-24 w-28 h-28 rounded-full bg-white/10" />
<div class="pointer-events-none absolute inset-0 profile-hero-grid" />
<!-- VIP 状态行:到期时间 + 开通/续费按钮 -->
<div class="relative mt-3 flex items-center justify-center gap-2">
<span
v-if="isVip"
class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95"
>
{{ vipExpireShort ? `VIP 至 ${vipExpireShort}` : 'VIP 会员' }}
</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')"
>
续费 VIP
</button>
<!-- 头像 + 姓名 + 学校 -->
<div class="relative flex items-center gap-4 rise-in">
<div class="relative flex-shrink-0">
<div class="w-[68px] h-[68px] rounded-full overflow-hidden ring-[3px] ring-white/60 shadow-lg shadow-black/15 bg-white/20 flex items-center justify-center">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-2xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-2 -bottom-1 z-10 whitespace-nowrap">VIP</div>
</div>
<div class="min-w-0 flex-1">
<p class="text-[22px] font-bold tracking-tight leading-tight truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="mt-1 flex items-center gap-1 text-sm text-white/90 truncate">
<GraduationCap class="w-3.5 h-3.5 flex-shrink-0" />
<span class="truncate">{{ userInfo?.schoolName || '--' }}</span>
</p>
<div v-if="profileTags.length" class="mt-2 flex flex-wrap gap-1.5">
<span v-for="t in profileTags" :key="t" class="max-w-[10rem] truncate text-xs rounded-md bg-white/15 ring-1 ring-white/20 px-1.5 py-0.5 text-white/95">{{ t }}</span>
</div>
</div>
</div>
</div>
<!-- 详情列表 -->
<div class="relative px-4 -mt-10 flex-1 flex flex-col space-y-3 pb-4">
<div class="bg-white rounded-2xl border border-gray-100/80 divide-y divide-gray-100 shadow-sm">
<!-- 可签到次数 + 兑换按钮 -->
<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-green-50 flex items-center justify-center">
<ClipboardCheck class="w-4 h-4 text-green-500" />
</div>
<div>
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 执行次数' : '可执行次数' }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${fmtCount(userInfo?.signCount ?? 0)} 次` }}</p>
</div>
<!-- ======== 会员卡(压在头图下沿) ======== -->
<div class="relative px-4 -mt-14 rise-in" style="animation-delay: 40ms">
<div class="member-card relative overflow-hidden hero-clip rounded-3xl p-4 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
<div class="pointer-events-none absolute -right-8 -top-10 w-36 h-36 rounded-full member-card-glow" />
<div class="relative flex items-center gap-3">
<div class="member-icon w-11 h-11 rounded-2xl flex items-center justify-center flex-shrink-0">
<Crown class="w-5 h-5" :stroke-width="2.4" />
</div>
<div class="min-w-0 flex-1">
<p class="member-title text-base font-bold tracking-tight">{{ isVip ? 'VIP 会员' : '普通用户' }}</p>
<p class="member-sub text-xs mt-0.5 truncate">
<template v-if="isVip">{{ vipExpireShort ? `有效期至 ${vipExpireShort}` : '会员生效中' }}<template v-if="vipDaysLeft !== null"> · 剩 {{ vipDaysLeft }} 天</template></template>
<template v-else>开通后执行任务不限次数</template>
</p>
</div>
<button
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:brightness-105 transition-all"
@click="openRedeemModal('count')"
data-guide="profile-vip-btn"
class="vip-btn press flex-shrink-0 text-sm font-semibold px-4 py-2 rounded-full shadow-md"
@click="openRedeemModal('vip')"
>
<Gift class="w-3 h-3" />
兑换
{{ isVip ? '续费' : '开通 VIP' }}
</button>
</div>
<!-- 推广信息(邀请链接 + 邀请信息合并) -->
<div data-guide="profile-invite-row" class="px-4 py-3.5">
<div class="flex items-start justify-between gap-3">
<!-- 左:邀请码 + 复制 -->
<div class="flex items-start gap-3 flex-1 min-w-0">
<div class="w-8 h-8 rounded-full bg-purple-50 flex items-center justify-center flex-shrink-0 mt-0.5">
<Share2 class="w-4 h-4 text-purple-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-400">推广信息</p>
<div v-if="inviteCode" class="mt-1 flex items-center gap-1.5">
<code class="font-mono text-sm text-purple-600 bg-purple-50 px-2 py-1 rounded font-bold tracking-wider">{{ inviteCode }}</code>
<button
data-guide="profile-invite-copy"
class="flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded bg-purple-100 text-purple-600 text-xs font-medium hover:bg-purple-200 transition-colors"
@click="copyInviteCode"
>
复制
</button>
</div>
<div v-else class="mt-1">
<button
data-guide="profile-invite-generate"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-purple-500 text-white text-xs font-medium hover:bg-purple-600 transition-colors"
:disabled="generatingInvite"
@click="handleGenerateInvite"
>
<RefreshCw v-if="generatingInvite" class="w-3 h-3 animate-spin" />
<template v-else>生成邀请码</template>
</button>
</div>
<p class="text-xs text-gray-400 mt-1 leading-relaxed">
邀请好友即可获得
<span class="text-amber-500 font-medium">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
积分
</p>
</div>
</div>
<!-- 右:奖励积分 + 兑换 -->
<div class="flex flex-col items-end gap-1.5 flex-shrink-0">
<p class="text-xs text-gray-500">
奖励 <span class="text-amber-600 font-bold">{{ fmtReward(inviteReward) }}</span>/1
</p>
<button
v-if="inviteCode"
data-guide="profile-invite-redeem"
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
:disabled="!canRedeem || redeemingInvite"
@click="handleRedeemInvite"
>
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<template v-else>兑换</template>
</button>
</div>
</div>
<!-- 无邀请人:可填写他人邀请码 -->
<button
v-if="!hasInviter"
data-guide="profile-fill-invite"
class="mt-2.5 w-full flex items-center justify-center gap-1 rounded-lg border border-dashed border-purple-200 py-2 text-xs text-purple-500 hover:bg-purple-50 transition-colors"
@click="openFillInviteModal"
>
填写邀请码
</button>
</div>
<!-- 工单系统 -->
<div
class="flex items-center justify-between px-4 py-3.5 cursor-pointer active:bg-gray-50"
@click="router.push('/ticket')"
</div>
</div>
<div class="relative px-4 pt-3 flex-1 flex flex-col space-y-3 pb-4">
<!-- ======== 数据三宫格:可执行次数 / 邀请积分 / 已邀请 ======== -->
<div class="grid grid-cols-3 gap-2.5 rise-in" style="animation-delay: 80ms">
<button class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center" @click="openRedeemModal('count')">
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ isVip ? '∞' : fmtCount(userInfo?.signCount ?? 0) }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ isVip ? '不限次数' : '可执行次数' }}</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold text-brand-600"><Gift class="w-3 h-3" />兑换</p>
</button>
<button
data-guide="profile-invite-redeem"
class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center disabled:active:scale-100"
:disabled="redeemingInvite"
@click="onRedeemInviteTap"
>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center relative">
<MessageSquare class="w-4 h-4 text-rose-500" />
<span
v-if="ticketUnreadCount > 0"
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none"
>{{ ticketUnreadCount > 99 ? '99+' : ticketUnreadCount }}</span>
</div>
<div>
<p class="text-xs text-gray-400">工单系统</p>
<p class="text-sm font-medium text-gray-900">咨询与反馈</p>
</div>
</div>
<svg class="w-4 h-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</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">
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 11c0 1.657-1.343 3-3 3s-3-1.343-3-3m6 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m6 0h6M5 11H3m9 0h6" />
</svg>
</div>
<div>
<p class="text-xs text-gray-400">设备码</p>
<p class="text-sm font-medium text-gray-900 font-mono">
{{ userInfo?.mobileCode ? maskDeviceCode(userInfo.mobileCode) : '未绑定' }}
</p>
</div>
</div>
<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" />
{{ hasDeviceCode ? '重新绑定' : '立即绑定' }}
</button>
</div>
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ fmtReward(inviteReward) }}</p>
<p class="text-xs text-gray-500 mt-0.5">邀请积分</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold" :class="canRedeem ? 'text-amber-600' : 'text-gray-400'">
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<template v-else><Coins class="w-3 h-3" />{{ canRedeem ? '换次数' : '满 1 可换' }}</template>
</p>
</button>
<div class="stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center">
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ invitedCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">已邀请</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-medium text-gray-400"><Users class="w-3 h-3" />位同学</p>
</div>
</div>
<!-- 外观(主题三档切换) -->
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 py-3.5 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center">
<SunMoon class="w-4 h-4 text-brand-500" />
</div>
<div>
<p class="text-sm font-medium text-gray-900">外观</p>
<p class="text-xs text-gray-400">自动:18:00–06:00 深色</p>
<!-- ======== 邀请好友卡 ======== -->
<div data-guide="profile-invite-row" class="invite-card relative overflow-hidden hero-clip rounded-3xl p-4 rise-in" style="animation-delay: 120ms">
<div class="pointer-events-none absolute -right-6 -bottom-10 w-32 h-32 rounded-full invite-glow" />
<div class="relative flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-bold text-gray-900 flex items-center gap-1.5"><UserPlus class="w-4 h-4 text-purple-600" />邀请同学 一起省心</p>
<p class="text-xs text-gray-500 mt-1 leading-relaxed">
每邀请 1 人得
<span class="font-semibold text-purple-600">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
积分<template v-if="inviteeRewardPerInvite !== null">,TA 得 <span class="font-semibold text-purple-600">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分</template>
</p>
</div>
</div>
<div class="flex p-1 bg-gray-100 rounded-full" role="radiogroup" aria-label="外观">
<!-- 邀请码 + 复制 -->
<div class="relative mt-3">
<div v-if="inviteCode" class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 pl-4 pr-1.5 py-1.5">
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
<code class="flex-1 min-w-0 font-mono text-lg font-bold tracking-[0.18em] text-purple-600 truncate">{{ inviteCode }}</code>
<button
data-guide="profile-invite-copy"
class="press flex-shrink-0 flex items-center gap-1 h-9 px-3.5 rounded-xl invite-btn text-white text-sm font-semibold shadow-sm shadow-purple-500/30"
@click="copyInviteCode"
>
<Copy class="w-3.5 h-3.5" />复制
</button>
</div>
<button
v-else
data-guide="profile-invite-generate"
class="press w-full flex items-center justify-center gap-1.5 h-11 rounded-2xl invite-btn text-white text-sm font-semibold shadow-sm shadow-purple-500/30 disabled:opacity-60"
:disabled="generatingInvite"
@click="handleGenerateInvite"
>
<RefreshCw v-if="generatingInvite" class="w-4 h-4 animate-spin" />
<template v-else><Zap class="w-4 h-4" />生成我的邀请码</template>
</button>
</div>
<!-- 无邀请人:可填写他人邀请码 -->
<button
v-if="!hasInviter"
data-guide="profile-fill-invite"
class="press relative mt-2.5 w-full flex items-center justify-center gap-1 h-9 rounded-xl border border-dashed border-purple-300 text-xs font-medium text-purple-600 hover:bg-white/50"
@click="openFillInviteModal"
>
有同学的邀请码?点此填写
</button>
</div>
<!-- ======== 功能菜单(iOS 分组列表) ======== -->
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm overflow-hidden rise-in" style="animation-delay: 160ms">
<!-- 设备码 -->
<div data-guide="profile-device-code" class="flex items-center gap-3 px-4 py-3.5">
<div class="w-9 h-9 rounded-xl bg-sky-50 flex items-center justify-center flex-shrink-0">
<Smartphone class="w-[18px] h-[18px] text-sky-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 flex items-center gap-1.5">
设备码
<span
class="text-xs px-1.5 py-px rounded-md font-medium"
:class="!hasDeviceCode ? 'bg-red-50 text-red-600' : userInfo?.mobileType === 'manual' ? 'bg-amber-100 text-amber-700' : 'bg-green-50 text-green-600'"
>{{ !hasDeviceCode ? '未绑定' : userInfo?.mobileType === 'manual' ? '随机' : '已授权' }}</span>
</p>
<p class="text-xs text-gray-400 font-mono mt-0.5 truncate">{{ hasDeviceCode ? maskDeviceCode(userInfo!.mobileCode) : '绑定后才能执行签到' }}</p>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<button class="press h-8 px-2.5 rounded-full text-xs font-medium text-gray-500 bg-gray-100 flex items-center gap-1" @click="openRandomBind">
<Dices class="w-3.5 h-3.5" />随机
</button>
<button class="press h-8 px-3 rounded-full text-xs font-semibold text-white bg-brand-gradient shadow-sm shadow-brand-500/20 flex items-center gap-1" @click="goRebind">
<RefreshCw class="w-3.5 h-3.5" />{{ hasDeviceCode ? '重绑' : '去绑定' }}
</button>
</div>
</div>
<div class="ml-16 border-t border-gray-100" />
<!-- 工单 -->
<button class="press w-full flex items-center gap-3 px-4 py-3.5 text-left active:bg-gray-50" @click="router.push('/ticket')">
<div class="relative w-9 h-9 rounded-xl bg-rose-50 flex items-center justify-center flex-shrink-0">
<Headset class="w-[18px] h-[18px] text-rose-500" />
<span
v-if="ticketUnreadCount > 0"
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none ring-2 ring-white dark:ring-gray-50"
>{{ ticketUnreadCount > 99 ? '99+' : ticketUnreadCount }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">咨询与反馈</p>
<p class="text-xs text-gray-400 mt-0.5">{{ ticketUnreadCount > 0 ? `${ticketUnreadCount} 条新回复` : '遇到问题提交工单,客服及时回复' }}</p>
</div>
<ChevronRight class="w-4 h-4 text-gray-300 flex-shrink-0" />
</button>
</div>
<!-- 外观(主题三档切换):标题一行 + 整宽分段控件一行,窄屏也不挤压换行 -->
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 pt-3.5 pb-4 rise-in" style="animation-delay: 200ms">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-brand-50 flex items-center justify-center flex-shrink-0">
<SunMoon class="w-[18px] h-[18px] text-brand-500" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">外观</p>
<p class="text-xs text-gray-400">{{ themeHint }}</p>
</div>
</div>
<!-- 分段控件:滑动高亮块指示当前项 -->
<div class="relative mt-3 grid grid-cols-3 p-1 rounded-xl bg-gray-100" role="radiogroup" aria-label="外观">
<div
class="absolute top-1 bottom-1 left-1 rounded-lg bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-300 ease-[var(--ease-spring)] dark:bg-gray-300 dark:ring-white/5"
:style="{ width: 'calc((100% - 8px) / 3)', transform: `translateX(${themeIndex * 100}%)` }"
/>
<button
v-for="opt in themeOptions"
:key="opt.value"
role="radio"
:aria-checked="themeMode === opt.value"
class="press flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold transition-all duration-300"
:class="themeMode === opt.value ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
class="relative z-10 flex items-center justify-center gap-1.5 h-9 rounded-lg text-sm whitespace-nowrap transition-colors duration-200"
:class="themeMode === opt.value ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
@click="setThemeMode(opt.value)"
>
<component :is="opt.icon" class="w-3.5 h-3.5" />
<component :is="opt.icon" class="w-4 h-4" />
{{ opt.label }}
</button>
</div>
@@ -819,10 +852,64 @@ async function handleRedeemInvite() {
}
}
/* 头图细网格纹理(极淡,增加质感) */
.profile-hero-grid {
background-image:
linear-gradient(rgb(255 255 255 / 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.06) 1px, transparent 1px);
background-size: 22px 22px;
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
}
/* 会员卡:VIP 为黑金,普通用户为珍珠白(固定色,亮暗下质感一致) */
.member-card.is-vip {
background: linear-gradient(135deg, #2b2418 0%, #1b1710 60%, #2d2516 100%);
border: 1px solid rgb(246 196 69 / 0.35);
}
.member-card.is-vip .member-title { color: #ffe39a; }
.member-card.is-vip .member-sub { color: rgb(255 227 154 / 0.75); }
.member-card.is-vip .member-icon {
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8, #f6c445 55%, #e8a020);
}
.member-card.is-vip .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.35), transparent 70%); }
.member-card.is-free {
background: linear-gradient(135deg, #ffffff 0%, #f7fbf9 100%);
border: 1px solid rgb(14 40 34 / 0.06);
}
.member-card.is-free .member-title { color: #17221f; }
.member-card.is-free .member-sub { color: #66756f; }
.member-card.is-free .member-icon {
color: #8a5a00;
background: linear-gradient(135deg, #fff1c7, #ffe08a);
}
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(245 189 46 / 0.22), transparent 70%); }
/* 邀请卡主按钮:固定紫(白字 6:1)。不用 bg-purple-600:暗色色板把它映射为浅紫作文字色,白字会看不清 */
.invite-btn {
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
}
/* 邀请卡:薰衣草渐变 */
.invite-card {
background: linear-gradient(135deg, #f5f2ff 0%, #ede7ff 60%, #f3eefe 100%);
border: 1px solid rgb(139 108 230 / 0.18);
}
.invite-glow { background: radial-gradient(circle, rgb(139 108 230 / 0.25), transparent 70%); }
/* 兑换按钮:柠檬金底 + 深棕字(固定色,亮暗主题下对比度一致 ≥ 7:1) */
.redeem-btn {
color: #5b3a00;
background: linear-gradient(135deg, #ffe08a 0%, #f5bd2e 100%);
}
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
.vip-btn {
position: relative;
overflow: hidden;
/* 金底固定不随主题变化,文字同样固定深棕(不用 text-amber-900:暗色下它被映射为浅色) */
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
}
.vip-btn::after {
@@ -843,3 +930,19 @@ async function handleRedeemInvite() {
pointer-events: none;
}
</style>
<style>
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
html.dark .member-card.is-free {
background: linear-gradient(135deg, #1e2c28 0%, #18221f 100%);
border-color: rgb(255 255 255 / 0.06);
}
html.dark .member-card.is-free .member-title { color: #eaf2ef; }
html.dark .member-card.is-free .member-sub { color: #98aba4; }
html.dark .member-card.is-vip { border-color: rgb(246 196 69 / 0.28); }
html.dark .invite-card {
background: linear-gradient(135deg, #262040 0%, #211b38 100%);
border-color: rgb(185 165 255 / 0.16);
}
html.dark .invite-glow { background: radial-gradient(circle, rgb(185 165 255 / 0.18), transparent 70%); }
</style>
+1 -1
View File
@@ -66,7 +66,7 @@ async function handleReply() {
<template>
<div class="flex flex-col min-h-full">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: env(safe-area-inset-top)">
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
<div class="flex items-center px-4 h-12">
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
<ArrowLeft class="w-5 h-5" />
+1 -1
View File
@@ -113,7 +113,7 @@ async function handleDelete(t: TicketListItem, e: Event) {
<template>
<div class="flex flex-col min-h-full">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: env(safe-area-inset-top)">
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
<div class="flex items-center px-4 h-12">
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
<ArrowLeft class="w-5 h-5" />