Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02dcd6fc32 | |||
| 51f3641a0c | |||
| 9f2f705457 | |||
| 301a4fb971 | |||
| df40b2ae73 | |||
| 317bf4b5a1 | |||
| 1c112fe3dc |
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user