v1.2.106:前端改版——薄荷汽水配色 + 轻量液态玻璃(iOS 26 风格)
ci/woodpecker/tag/build Pipeline was successful

- 设计令牌:重映射 Tailwind 色阶为薄荷汽水色板(品牌薄荷青/天空蓝/柠檬/珊瑚),统一圆角、柔和阴影;亮暗两套色板按 WCAG AA 校验
- 外壳:悬浮液态玻璃胶囊 TabBar(滑动高亮、滚动收窄)、透明→玻璃顶栏、切页淡入、居中玻璃 Toast
- 主题:新增「自动/浅色/深色」三档切换(我的页),自动模式保留按时间切换
- 页面:首页/请销假渐变头卡、登录/绑定设备/工单/个人中心精修;后台换主色与导航样式
- 可读性:清理 8–11px 小字号(最小 12px),提高辅助文字灰度对比
- 降级:不支持 backdrop-filter / 减弱透明度 → 实底;减弱动态效果 → 关闭动画
This commit is contained in:
becomer
2026-10-02 13:33:46 +08:00
parent 441c40a8cc
commit 2f7113ba5e
28 changed files with 964 additions and 418 deletions
+7 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#f5f5f5" />
<meta name="theme-color" content="#f3faf7" />
<title>小助手</title>
<script>
// 老内核(Chrome 78~92,即微信 XWEB / 国产浏览器)缺失的 ES2022 运行时 API 补齐。
@@ -29,13 +29,15 @@
})()
</script>
<script>
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一规则
// (用户选择 light/dark 优先;auto 时 18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
;(function () {
var mode = 'auto'
try { mode = localStorage.getItem('xz_theme') || 'auto' } catch (e) {}
var h = new Date().getHours()
if (h >= 18 || h < 6) {
if (mode === 'dark' || (mode !== 'light' && (h >= 18 || h < 6))) {
document.documentElement.classList.add('dark')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#15151f')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#0f1815')
}
})()
</script>
+29 -14
View File
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { useMessage } from '@/composables/useMessage'
import UpdateChecker from '@/components/UpdateChecker.vue'
import ConfirmHost from '@/components/ConfirmHost.vue'
import { Check, X, Info } from 'lucide-vue-next'
const route = useRoute()
const { toasts, removeToast } = useMessage()
@@ -36,23 +37,37 @@ watch(
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
<!-- 顶部居中胶囊 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)"
role="status"
aria-live="polite"
>
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'px-4 py-3 rounded-lg shadow-lg border text-sm flex items-center gap-2 transition-all',
toast.type === 'success' && 'bg-green-50 border-green-200 text-green-700',
toast.type === 'error' && 'bg-red-50 border-red-200 text-red-700',
toast.type === 'info' && 'bg-blue-50 border-blue-200 text-blue-700',
]"
class="glass pointer-events-auto max-w-sm w-fit pl-3 pr-2.5 py-2.5 rounded-full text-sm font-medium text-gray-800 flex items-center gap-2 transition-all"
>
<span class="flex-1">{{ toast.message }}</span>
<span
:class="[
'w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 text-white',
toast.type === 'success' && 'bg-green-500',
toast.type === 'error' && 'bg-red-500',
toast.type === 'info' && 'bg-brand-500',
]"
>
<Check v-if="toast.type === 'success'" class="w-3.5 h-3.5" :stroke-width="3" />
<X v-else-if="toast.type === 'error'" class="w-3.5 h-3.5" :stroke-width="3" />
<Info v-else class="w-3.5 h-3.5" :stroke-width="3" />
</span>
<span class="flex-1 min-w-0 break-words">{{ toast.message }}</span>
<button
class="text-current opacity-50 hover:opacity-100 text-lg leading-none"
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-600 flex-shrink-0"
aria-label="关闭提示"
@click="removeToast(toast.id)"
>&times;</button>
><X class="w-3.5 h-3.5" /></button>
</div>
</TransitionGroup>
</div>
@@ -61,17 +76,17 @@ watch(
<style scoped>
.toast-enter-active {
transition: all 0.3s ease-out;
transition: all 0.42s cubic-bezier(0.34, 1.36, 0.64, 1);
}
.toast-leave-active {
transition: all 0.2s ease-in;
transition: all 0.22s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(40px);
transform: translateY(-16px) scale(0.92);
}
.toast-leave-to {
opacity: 0;
transform: translateX(40px);
transform: translateY(-10px) scale(0.96);
}
</style>
+385 -67
View File
@@ -1,28 +1,162 @@
@import "tailwindcss";
/* 基于 class 的暗色变体:html.dark 时启用 dark:* 工具类
(切换逻辑见 composables/useTheme.ts,按浏览器系统时间自动切换) */
(切换逻辑见 composables/useTheme.ts:自动 / 亮 / 暗 三档) */
@custom-variant dark (&:where(.dark, .dark *));
/* ============================================================
* 设计令牌 · 薄荷汽水(Mint Soda)× 轻量液态玻璃
*
* 换肤方式:直接重映射 Tailwind 色阶 —— blue → 薄荷品牌色、gray → 薄荷中性灰、
* red/rose → 珊瑚、amber → 柠檬、green → 清新绿。全站 1000+ 处色阶类无需逐个
* 改写即可统一换肤(历史代码里的 blue-* 即品牌色);新代码请优先用 brand-*。
*
* 对比度(WCAG AA,已逐色计算):亮色下 *-500/600 作文字或白字底色均 ≥ 4.5;
* 暗色下品牌按钮白字 ≥ 3(AA 大字/UI 组件级),正文文字 ≥ 4.5。
* ============================================================ */
@theme {
--color-background: #f5f5f5;
--color-foreground: #1a1a1a;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "HarmonyOS Sans SC",
"MiSans", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
/* —— 品牌 · 薄荷青(brand 与 blue 同值;blue 为历史类名兼容)—— */
--color-brand-50: #eafaf5;
--color-brand-100: #d2f4ea;
--color-brand-200: #a8e9d7;
--color-brand-300: #74dac2;
--color-brand-400: #2cc5a8;
--color-brand-500: #0b8672;
--color-brand-600: #0a7866;
--color-brand-700: #08604f;
--color-brand-800: #0a4d41;
--color-brand-900: #0b3f36;
--color-blue-50: #eafaf5;
--color-blue-100: #d2f4ea;
--color-blue-200: #a8e9d7;
--color-blue-300: #74dac2;
--color-blue-400: #2cc5a8;
--color-blue-500: #0b8672;
--color-blue-600: #0a7866;
--color-blue-700: #08604f;
--color-blue-800: #0a4d41;
--color-blue-900: #0b3f36;
/* —— 点缀 · 天空蓝(信息/次强调,取代原 indigo/sky 的位置)—— */
--color-sky-50: #eef6ff;
--color-sky-100: #dcecfe;
--color-sky-400: #5aa9f5;
--color-sky-500: #2373c4;
--color-sky-600: #1d63a9;
--color-indigo-50: #eef6ff;
--color-indigo-100: #dcecfe;
--color-indigo-500: #2373c4;
--color-cyan-400: #5aa9f5;
/* —— 中性 · 薄荷灰(带一丝青调,比纯灰更通透)—— */
--color-gray-50: #f6faf8;
--color-gray-100: #edf3f0;
--color-gray-200: #dfe8e4;
--color-gray-300: #c9d5d0;
--color-gray-400: #808e89;
--color-gray-500: #66756f;
--color-gray-600: #4f5d58;
--color-gray-700: #3a4743;
--color-gray-800: #263330;
--color-gray-900: #17221f;
--color-gray-950: #0e1714;
/* —— 语义 · 珊瑚(危险/错误;red 与 rose 合一,比纯红柔和)—— */
--color-red-50: #fff1f2;
--color-red-100: #ffe1e4;
--color-red-200: #ffc7cd;
--color-red-300: #ffa1ab;
--color-red-400: #f77a87;
--color-red-500: #d63f52;
--color-red-600: #bf3446;
--color-red-700: #9e2a3a;
--color-rose-50: #fff1f4;
--color-rose-100: #ffe1e8;
--color-rose-200: #ffc8d5;
--color-rose-300: #ffa3b8;
--color-rose-400: #f7809c;
--color-rose-500: #d23c68;
--color-rose-600: #b8325a;
--color-rose-700: #96284a;
/* —— 语义 · 清新绿(成功)—— */
--color-green-50: #ecfaf2;
--color-green-100: #d5f4e2;
--color-green-200: #ade8c6;
--color-green-400: #4ccb86;
--color-green-500: #1f9d58;
--color-green-600: #16864a;
--color-green-700: #126b3b;
--color-emerald-50: #ecfaf2;
--color-emerald-200: #ade8c6;
--color-emerald-400: #4ccb86;
--color-emerald-600: #16864a;
/* —— 语义 · 柠檬(提醒/公告/积分)—— */
--color-amber-50: #fffaeb;
--color-amber-100: #fff1c7;
--color-amber-200: #ffe39a;
--color-amber-300: #ffd45c;
--color-amber-400: #f5bd2e;
--color-amber-500: #e3a008;
--color-amber-600: #9a6200;
--color-amber-700: #8a5a00;
--color-amber-800: #6e4700;
--color-amber-900: #573800;
--color-orange-50: #fff5ec;
--color-orange-100: #ffe8d4;
--color-orange-400: #ffa45c;
--color-orange-500: #f08a3c;
--color-orange-600: #b35a12;
/* —— 点缀 · 薰衣草(VIP/特殊标签)—— */
--color-purple-50: #f5f2ff;
--color-purple-100: #ebe5ff;
--color-purple-200: #d9cfff;
--color-purple-500: #8b6ce6;
--color-purple-600: #6a4bcb;
/* —— 语义 token(ui/ 组件与语义类使用)—— */
--color-background: #f3faf7;
--color-foreground: #17221f;
--color-card: #ffffff;
--color-card-foreground: #1a1a1a;
--color-primary: #2563eb;
--color-card-foreground: #17221f;
--color-primary: #0b8672;
--color-primary-foreground: #ffffff;
--color-secondary: #f3f4f6;
--color-secondary-foreground: #1f2937;
--color-muted: #f3f4f6;
--color-muted-foreground: #6b7280;
--color-accent: #f3f4f6;
--color-accent-foreground: #1f2937;
--color-destructive: #ef4444;
--color-secondary: #edf3f0;
--color-secondary-foreground: #263330;
--color-muted: #edf3f0;
--color-muted-foreground: #66756f;
--color-accent: #eafaf5;
--color-accent-foreground: #08604f;
--color-destructive: #d63f52;
--color-destructive-foreground: #ffffff;
--color-border: #e5e7eb;
--color-input: #e5e7eb;
--color-ring: #2563eb;
--radius: 0.5rem;
--color-border: #dfe8e4;
--color-input: #dfe8e4;
--color-ring: #2cc5a8;
/* —— 圆角:控件 / 卡片 / 浮层(同心圆角:内层 = 外层 − 内边距)—— */
--radius: 0.75rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1.25rem;
--radius-2xl: 1.5rem;
--radius-3xl: 1.75rem;
/* —— 阴影:柔和、带一点品牌色调 —— */
--shadow-sm: 0 1px 2px rgb(14 40 34 / 0.04), 0 2px 8px rgb(14 40 34 / 0.04);
--shadow: 0 2px 6px rgb(14 40 34 / 0.05), 0 6px 18px rgb(14 40 34 / 0.06);
--shadow-md: 0 4px 12px rgb(14 40 34 / 0.07), 0 10px 28px rgb(14 40 34 / 0.06);
--shadow-lg: 0 8px 24px rgb(14 40 34 / 0.09), 0 18px 44px rgb(14 40 34 / 0.08);
--shadow-xl: 0 12px 32px rgb(14 40 34 / 0.12), 0 24px 60px rgb(14 40 34 / 0.1);
--shadow-2xl: 0 20px 50px rgb(14 40 34 / 0.18);
/* —— 动效 —— */
--ease-spring: cubic-bezier(0.34, 1.36, 0.64, 1);
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
}
* {
@@ -37,12 +171,18 @@ html, body {
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #e8e8e8;
font-family: var(--font-sans);
background-color: #e4efea;
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
text-rendering: optimizeLegibility;
}
/* 手机容器 — 固定最大宽度,全屏高度,水平居中 */
/* 手机容器 — 固定最大宽度,全屏高度,水平居中。
背景 = 底色 + 三处柔光斑(薄荷 / 天空蓝 / 柠檬),为玻璃层提供可折射的底图;
纯 radial-gradient 实现,无 filter: blur,低端机零额外开销。 */
#app {
max-width: 430px;
width: 100%;
@@ -51,89 +191,267 @@ body {
height: 100vh;
height: 100dvh;
margin: 0 auto;
background-color: #ffffff;
background-color: var(--color-background);
background-image:
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.22), transparent 72%),
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.16), transparent 72%),
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.16), transparent 72%);
background-attachment: local;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.08);
box-shadow: 0 0 40px rgb(14 40 34 / 0.1);
position: relative;
}
/* 管理后台模式 — 全屏宽度 */
/* 管理后台模式 — 全屏宽度,不使用光斑背景 */
#app.admin-mode {
max-width: none;
box-shadow: none;
background-color: transparent;
background-image: none;
}
/* ============================================================
* 暗色主题(html.dark,由 useTheme.ts 按系统时间自动切换)
* 液态玻璃(Liquid Glass,网页近似实现)
* 仅用于「浮在内容之上的控件层」:底部胶囊 Tab、顶栏、弹层/抽屉。
* 内容卡片保持不透明,保证可读性与低端机滚动性能。
* 降级:不支持 backdrop-filter / 系统「减弱透明度」→ 高不透明实底。
* ============================================================ */
.glass {
background-color: rgb(255 255 255 / 0.66);
-webkit-backdrop-filter: blur(22px) saturate(180%);
backdrop-filter: blur(22px) saturate(180%);
border: 1px solid rgb(255 255 255 / 0.6);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.75),
inset 0 -1px 0 rgb(255 255 255 / 0.25),
0 8px 28px rgb(14 40 34 / 0.12);
}
.glass-bar {
background-color: rgb(243 250 247 / 0.72);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
}
.glass-panel {
background-color: rgb(255 255 255 / 0.86);
-webkit-backdrop-filter: blur(28px) saturate(180%);
backdrop-filter: blur(28px) saturate(180%);
}
.dark .glass {
background-color: rgb(30 44 40 / 0.62);
border-color: rgb(255 255 255 / 0.09);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.08),
0 10px 30px rgb(0 0 0 / 0.45);
}
.dark .glass-bar {
background-color: rgb(15 24 21 / 0.72);
}
.dark .glass-panel {
background-color: rgb(26 38 34 / 0.88);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass { background-color: rgb(255 255 255 / 0.96); }
.glass-bar { background-color: rgb(243 250 247 / 0.97); }
.glass-panel { background-color: #ffffff; }
.dark .glass { background-color: rgb(30 44 40 / 0.98); }
.dark .glass-bar { background-color: rgb(15 24 21 / 0.98); }
.dark .glass-panel { background-color: #1a2622; }
}
@media (prefers-reduced-transparency: reduce) {
.glass, .glass-bar, .glass-panel {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.glass { background-color: rgb(255 255 255 / 0.97); }
.glass-bar { background-color: rgb(243 250 247 / 0.98); }
.glass-panel { background-color: #ffffff; }
.dark .glass { background-color: #1e2c28; }
.dark .glass-bar { background-color: #0f1815; }
.dark .glass-panel { background-color: #1a2622; }
}
/* 品牌渐变(顶栏/头图/主按钮):薄荷 → 青绿,右上带一点天空蓝 */
.bg-brand-gradient {
background-color: #0b8672;
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 55%, #0a7a6e 100%);
}
.dark .bg-brand-gradient {
background-color: #0e6f60;
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 60%, #0b5f55 100%);
}
/* 按压反馈:轻微缩放,iOS 风格 */
.press {
transition: transform 0.18s var(--ease-out-soft), opacity 0.18s ease, background-color 0.2s ease, color 0.2s ease;
}
.press:active {
transform: scale(0.96);
}
/* 内容进场:轻微上浮淡入 */
@keyframes rise-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.rise-in {
animation: rise-in 0.42s var(--ease-out-soft) both;
}
/* 系统「减弱动态效果」:关闭位移/弹簧动画(保留极短时长,保证 animationend 仍会触发) */
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ============================================================
* 暗色主题(html.dark)· 墨绿夜
*
* Tailwind v4 的颜色工具类均引用 --color-* CSS 变量(bg-white →
* var(--color-white)),此处整体覆盖变量即可让全站 1000+ 处硬编码
* 颜色类自动适配,无需逐个加 dark: 变体。深彩色(blue-500 等
* 品牌/状态色)与遮罩黑保持原值。彩底上的白色元素用类名级规则恢复。
* var(--color-white)),此处整体覆盖变量即可让全站硬编码颜色类自动适配。
* 规则:
* - 白 / 灰阶反转(越浅的灰在暗色下越亮,保持层次感);
* - 彩色 50/100/200 浅底 → 对应深色调底;
* - 彩色 400–700 文字色提亮,保证在暗卡片上 ≥ 4.5 对比度;
* - 彩底上的白色元素用类名级规则恢复为纯白(见下方)。
* ============================================================ */
.dark {
/* —— 基础调色板:白与灰阶(越浅的灰在暗色下越亮,保持层次感)—— */
--color-white: #1e1e2a;
--color-gray-50: #232331;
--color-gray-100: #2a2a3a;
--color-gray-200: #36364a;
--color-gray-300: #474763;
--color-gray-400: #8f8fab;
--color-gray-500: #a5a5c3;
--color-gray-600: #bcbcd6;
--color-gray-700: #d4d4e6;
--color-gray-800: #e5e5f0;
--color-gray-900: #ededf6;
--color-gray-950: #f4f4fa;
/* —— 彩色浅底 → 对应暗色调 —— */
--color-blue-50: #1c2a4d;
--color-blue-100: #243660;
--color-red-50: #3d1c28;
--color-red-100: #522534;
--color-white: #18221f;
--color-gray-50: #1c2724;
--color-gray-100: #22302c;
--color-gray-200: #2c3b36;
--color-gray-300: #3b4c46;
--color-gray-400: #7f908a;
--color-gray-500: #98aba4;
--color-gray-600: #b6c5bf;
--color-gray-700: #cbd8d3;
--color-gray-800: #dde7e3;
--color-gray-900: #eaf2ef;
--color-gray-950: #f4f8f6;
--color-brand-50: #163a33;
--color-brand-100: #1b4840;
--color-brand-200: #22594f;
--color-brand-300: #2d7a6b;
--color-brand-400: #3ad3b6;
--color-brand-500: #16a88d;
--color-brand-600: #0e9079;
--color-brand-700: #7fe6d0;
--color-blue-50: #163a33;
--color-blue-100: #1b4840;
--color-blue-200: #22594f;
--color-blue-300: #2d7a6b;
--color-blue-400: #3ad3b6;
--color-blue-500: #16a88d;
--color-blue-600: #0e9079;
--color-blue-700: #7fe6d0;
--color-blue-800: #a6efdf;
--color-sky-50: #172c40;
--color-sky-100: #1d3850;
--color-sky-500: #6cb4ff;
--color-sky-600: #8cc4ff;
--color-indigo-50: #172c40;
--color-indigo-100: #1d3850;
--color-indigo-500: #6cb4ff;
--color-red-50: #3a1d24;
--color-red-100: #4a232c;
--color-red-200: #5e2b36;
--color-red-300: #7a3442;
--color-red-400: #ff8c96;
--color-red-500: #f2636f;
--color-red-600: #ff8c96;
--color-red-700: #ffb3ba;
--color-rose-50: #3a1c2b;
--color-rose-100: #4d2539;
--color-green-50: #173224;
--color-green-100: #1f4430;
--color-amber-50: #3a2f16;
--color-amber-100: #4d3f1e;
--color-purple-100: #322552;
/* —— 语义 token(ui/ 组件与语义类使用)—— */
--color-background: #15151f;
--color-foreground: #ededf6;
--color-card: #1e1e2a;
--color-card-foreground: #ededf6;
--color-secondary: #262636;
--color-secondary-foreground: #d4d4e6;
--color-muted: #262636;
--color-muted-foreground: #9c9cb8;
--color-accent: #262636;
--color-accent-foreground: #d4d4e6;
--color-border: #33334a;
--color-input: #3a3a55;
--color-ring: #3b82f6;
--color-rose-100: #4a2337;
--color-rose-200: #5e2c45;
--color-rose-300: #7a3758;
--color-rose-400: #ff8fab;
--color-rose-500: #ef6a8e;
--color-rose-600: #ff8fab;
--color-rose-700: #ffb8cb;
--color-green-50: #14301f;
--color-green-100: #183b27;
--color-green-200: #1f4d33;
--color-green-400: #4ccb86;
--color-green-500: #3fbf79;
--color-green-600: #4ccb86;
--color-green-700: #8fe3b5;
--color-emerald-50: #14301f;
--color-emerald-200: #1f4d33;
--color-emerald-600: #4ccb86;
--color-amber-50: #332a16;
--color-amber-100: #40331a;
--color-amber-200: #52411f;
--color-amber-300: #b08a2e;
--color-amber-400: #f7c54a;
--color-amber-500: #e9b23a;
--color-amber-600: #f7c54a;
--color-amber-700: #ffd978;
--color-amber-800: #ffe39a;
--color-amber-900: #fff0c2;
--color-orange-50: #35251a;
--color-orange-100: #44301f;
--color-orange-600: #ffb47a;
--color-purple-50: #261f3d;
--color-purple-100: #2f264d;
--color-purple-200: #3b3061;
--color-purple-600: #b9a5ff;
--color-background: #0f1815;
--color-foreground: #eaf2ef;
--color-card: #18221f;
--color-card-foreground: #eaf2ef;
--color-primary: #0e9079;
--color-secondary: #22302c;
--color-secondary-foreground: #cbd8d3;
--color-muted: #22302c;
--color-muted-foreground: #98aba4;
--color-accent: #163a33;
--color-accent-foreground: #7fe6d0;
--color-destructive: #e0505e;
--color-border: #2a3934;
--color-input: #33443e;
--color-ring: #3ad3b6;
}
/* —— 彩底上的白色元素恢复纯白(--color-white 已被覆盖为卡片暗色)—— */
.dark .text-white { color: #fff; }
.dark .text-white\/95 { color: rgb(255 255 255 / 0.95); }
.dark .text-white\/90 { color: rgb(255 255 255 / 0.9); }
.dark .text-white\/80 { color: rgb(255 255 255 / 0.8); }
.dark .text-white\/75 { color: rgb(255 255 255 / 0.75); }
.dark .text-white\/70 { color: rgb(255 255 255 / 0.7); }
.dark .bg-white\/10 { background-color: rgb(255 255 255 / 0.1); }
.dark .bg-white\/15 { background-color: rgb(255 255 255 / 0.15); }
.dark .bg-white\/20 { background-color: rgb(255 255 255 / 0.2); }
.dark .bg-white\/25 { background-color: rgb(255 255 255 / 0.25); }
.dark .hover\:bg-white\/25:hover { background-color: rgb(255 255 255 / 0.25); }
.dark .ring-white\/30 { --tw-ring-color: rgb(255 255 255 / 0.3); }
.dark .border-white\/30 { border-color: rgb(255 255 255 / 0.3); }
/* —— 全局容器暗色 —— */
.dark body {
background-color: #0d0d14;
background-color: #070c0a;
}
.dark #app {
background-color: #15151f;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
background-color: var(--color-background);
background-image:
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.14), transparent 72%),
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.09), transparent 72%),
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.06), transparent 72%);
box-shadow: 0 0 40px rgb(0 0 0 / 0.5);
}
.dark #app.admin-mode {
background-color: transparent;
background-image: none;
}
+3 -3
View File
@@ -26,7 +26,7 @@ const { state, respond } = useConfirm()
<div
:class="[
'w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4',
state.danger ? 'bg-red-100' : 'bg-blue-100',
state.danger ? 'bg-red-100' : 'bg-brand-100',
]"
>
<AlertTriangle v-if="state.danger" class="w-7 h-7 text-red-500" />
@@ -43,14 +43,14 @@ const { state, respond } = useConfirm()
<Button
v-if="!state.alertOnly"
variant="outline"
class="flex-1 h-10 text-sm font-medium"
class="flex-1 h-11 text-sm"
@click="respond(false)"
>
{{ state.cancelText || '取消' }}
</Button>
<Button
:variant="state.danger ? 'destructive' : 'default'"
class="flex-1 h-10 text-sm font-medium"
class="flex-1 h-11 text-sm"
@click="respond(true)"
>
{{ state.confirmText || '确定' }}
+5 -5
View File
@@ -540,11 +540,11 @@ const bubbleStyle = computed(() => {
<div class="flex-1 min-w-0 mr-2">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-gray-900 whitespace-nowrap">晚签到</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
<span class="text-xs text-gray-400 whitespace-nowrap">示例任务</span>
</div>
</div>
<button
@@ -563,7 +563,7 @@ const bubbleStyle = computed(() => {
<div ref="bubbleEl" :style="bubbleStyle" class="guide-bubble absolute z-10">
<!-- 顶部:标识 + 步骤 + 跳过 -->
<div class="flex items-center gap-2">
<span class="flex items-center gap-1 text-[10px] font-semibold text-blue-600 flex-shrink-0">
<span class="flex items-center gap-1 text-xs font-semibold text-blue-600 flex-shrink-0">
<Sparkles class="w-3 h-3" />
新手引导
</span>
@@ -586,7 +586,7 @@ const bubbleStyle = computed(() => {
<!-- 标题与说明 -->
<h3 class="text-[15px] font-bold text-gray-900 mt-2.5">{{ current.title }}</h3>
<p v-if="waitingTarget" class="text-[11px] text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
<p v-if="waitingTarget" class="text-xs text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
<p class="text-xs text-gray-500 leading-relaxed whitespace-pre-line mt-1.5 guide-desc">{{ guideDesc }}</p>
<!-- 操作按钮 -->
+2 -2
View File
@@ -130,13 +130,13 @@ onUnmounted(() => {
</p>
<div class="flex gap-2">
<button
class="flex-1 py-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 active:bg-gray-50"
class="press flex-1 py-3 rounded-full border border-gray-200 text-sm font-semibold text-gray-600 active:bg-gray-50"
@click="dismiss"
>
稍后
</button>
<button
class="flex-1 py-2.5 rounded-lg bg-blue-600 text-white text-sm font-medium active:bg-blue-700"
class="press flex-1 py-3 rounded-full bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
@click="reloadNow"
>
立即刷新
+9 -9
View File
@@ -3,25 +3,25 @@ import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from './button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-all duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
default: 'bg-primary text-primary-foreground shadow-md shadow-brand-500/20 hover:brightness-105',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-md shadow-red-500/20 hover:brightness-105',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
'border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'bg-secondary text-secondary-foreground hover:bg-gray-200',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
default: 'h-10 px-5 py-2',
sm: 'h-8 px-3.5 text-xs',
lg: 'h-12 px-8 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
+1 -1
View File
@@ -9,7 +9,7 @@ const props = defineProps<Props>()
</script>
<template>
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
<div :class="cn('rounded-2xl border border-gray-100 bg-card text-card-foreground shadow-sm', props.class)">
<slot />
</div>
</template>
@@ -104,7 +104,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
<DialogPortal>
<!-- 遮罩:reka 会自动加 pointer-events:auto 并响应「点遮罩关闭」 -->
<DialogOverlay
:class="cn('fixed inset-0', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
:class="cn('fixed inset-0 app-dialog-overlay', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
/>
<!-- 事件/焦点容器:本身不承载布局(零尺寸),只作为焦点/事件容器。
@@ -137,7 +137,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
:class="
cn(
'relative w-full bg-white flex flex-col overflow-hidden shadow-xl max-h-[85vh]',
props.variant === 'sheet' ? 'rounded-t-2xl sm:rounded-2xl' : 'rounded-2xl',
props.variant === 'sheet' ? 'rounded-t-3xl sm:rounded-3xl app-dialog-sheet' : 'rounded-3xl app-dialog-pop',
props.panelClass,
)
"
@@ -168,6 +168,42 @@ function onEscapeKeyDown(e: KeyboardEvent) {
.app-dialog-fade[data-state='closed'] {
animation: app-dialog-fade-out 0.2s ease;
}
/* 遮罩轻度虚化(iOS 弹层背景效果);低端机仅 2px,开销可控;「减弱透明度」时关闭 */
.app-dialog-overlay {
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
@media (prefers-reduced-transparency: reduce) {
.app-dialog-overlay {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}
/* 面板进场:sheet 自底部滑入,center 轻微放大弹出(仅进场;离场随容器一起淡出/卸载)。
* fill-mode 用 backwards:动画结束后不残留 transform,避免给内部 fixed 元素制造包含块。 */
.app-dialog-sheet {
animation: app-dialog-sheet-in 0.36s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
.app-dialog-pop {
animation: app-dialog-pop-in 0.28s cubic-bezier(0.34, 1.36, 0.64, 1) backwards;
}
@media (min-width: 640px) {
.app-dialog-sheet {
animation-name: app-dialog-pop-in;
}
}
@keyframes app-dialog-sheet-in {
from {
transform: translateY(40%);
opacity: 0.4;
}
}
@keyframes app-dialog-pop-in {
from {
transform: scale(0.94);
opacity: 0;
}
}
@keyframes app-dialog-fade-in {
from {
opacity: 0;
+1 -1
View File
@@ -31,7 +31,7 @@ function onInput(e: Event) {
:placeholder="placeholder"
:disabled="disabled"
:class="cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'flex h-11 w-full rounded-xl border border-input bg-gray-50 px-3.5 py-1 text-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:bg-card focus-visible:border-brand-400 focus-visible:ring-4 focus-visible:ring-brand-400/15 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)"
@input="onInput"
+43 -8
View File
@@ -1,21 +1,44 @@
// 亮暗主题:按浏览器系统时间自动切换(18:00–次日 06:00 为暗色,其余亮色)。
// 首屏防闪烁由 index.html 内联脚本完成(同一规则,渲染前预设 .dark);
// 本模块在应用启动后接管:立即校正一次,并按分钟轮询,跨过时间阈值时自动切换。
// 亮暗主题:三档模式 —— auto(默认,按系统时间 18:00–次日 06:00 为暗色)/ light / dark。
// 用户选择持久化在 localStorage(THEME_KEY);首屏防闪烁由 index.html 内联脚本完成
// (同一规则,渲染前预设 .dark);本模块在应用启动后接管:立即校正一次,
// auto 模式下按分钟轮询,跨过时间阈值时自动切换。
// 切换载体为 <html> 上的 .dark class,配合 globals.css 中的暗色变量板生效。
import { ref } from 'vue'
export type ThemeMode = 'auto' | 'light' | 'dark'
/** 与 index.html 内联脚本保持一致 */
export const THEME_KEY = 'xz_theme'
const DARK_START_HOUR = 18
const DARK_END_HOUR = 6
const THEME_COLOR_LIGHT = '#f5f5f5'
const THEME_COLOR_DARK = '#15151f'
const THEME_COLOR_LIGHT = '#f3faf7'
const THEME_COLOR_DARK = '#0f1815'
export function isDarkTime(date: Date = new Date()): boolean {
const h = date.getHours()
return h >= DARK_START_HOUR || h < DARK_END_HOUR
}
/** 应用当前时段对应主题,返回是否处于暗色 */
function readMode(): ThemeMode {
try {
const v = localStorage.getItem(THEME_KEY)
if (v === 'light' || v === 'dark' || v === 'auto') return v
} catch {
// 隐私模式等场景 localStorage 不可用,按自动处理
}
return 'auto'
}
/** 当前主题模式(响应式,供设置入口绑定) */
export const themeMode = ref<ThemeMode>(readMode())
/** 当前是否处于暗色(响应式) */
export const isDark = ref(false)
/** 应用当前模式对应主题,返回是否处于暗色 */
export function applyTheme(): boolean {
const dark = isDarkTime()
const mode = themeMode.value
const dark = mode === 'dark' || (mode === 'auto' && isDarkTime())
isDark.value = dark
document.documentElement.classList.toggle('dark', dark)
document
.querySelector('meta[name="theme-color"]')
@@ -23,9 +46,21 @@ export function applyTheme(): boolean {
return dark
}
/** 切换主题模式并持久化 */
export function setThemeMode(mode: ThemeMode): void {
themeMode.value = mode
try {
if (mode === 'auto') localStorage.removeItem(THEME_KEY)
else localStorage.setItem(THEME_KEY, mode)
} catch {
// 忽略持久化失败,本次会话内仍生效
}
applyTheme()
}
let timer: ReturnType<typeof setInterval> | null = null
/** 应用入口调用:立即应用主题并启动分钟级自动切换 */
/** 应用入口调用:立即应用主题并启动分钟级自动切换(仅 auto 模式下会产生变化) */
export function initTheme(): void {
applyTheme()
if (timer) return
+136 -45
View File
@@ -26,6 +26,50 @@ const tabs = [
]
const activeTab = ref(route.name as string || 'home')
const activeTabIndex = computed(() => tabs.findIndex((t) => t.name === activeTab.value))
// 悬浮顶栏:仅首页与请/销假页显示
const showHeader = computed(() => activeTab.value === 'home' || activeTab.value === 'leave')
// 问候语(按本地时间)
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 11) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
// 滚动联动:内容离开顶部 → 顶栏显出玻璃底;向下滚动 → 胶囊 TabBar 收窄,向上滚动 → 展开
const scrollEl = ref<HTMLElement | null>(null)
const scrolled = ref(false)
const tabCollapsed = ref(false)
let lastScrollTop = 0
function onContentScroll() {
const el = scrollEl.value
if (!el) return
const top = el.scrollTop
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
lastScrollTop = top
}
// 切页回到顶部并复位滚动联动状态(各页共享同一滚动容器)
watch(
() => route.name,
() => {
if (scrollEl.value) scrollEl.value.scrollTop = 0
lastScrollTop = 0
scrolled.value = false
tabCollapsed.value = false
},
{ flush: 'post' },
)
// 路由变化时同步底部 tab 高亮;回到首页时尝试加载公告强制弹窗(若引导已完成)
watch(() => route.name, (name) => {
@@ -257,51 +301,90 @@ onUnmounted(() => {
</script>
<template>
<div class="flex flex-col h-full">
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致) -->
<div v-if="activeTab === 'home' || activeTab === 'leave'" class="flex-shrink-0 bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 pt-3 pb-3">
<div class="relative flex flex-col h-full">
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致)。
悬浮于内容之上:顶部时透明融入背景光斑,内容滚动后渐显液态玻璃底与细分隔线 -->
<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="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)"
>
<div v-if="userInfo" class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
<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">
<img v-show="userInfo.avatar && !avatarFailed" :src="userInfo.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<User v-if="!userInfo.avatar || avatarFailed" class="w-5 h-5 text-white" />
<span v-if="!userInfo.avatar || avatarFailed" class="text-base font-bold text-white">{{ (userInfo.realName || userInfo.username || '?')[0] }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">{{ userInfo.realName || userInfo.username }}</p>
<p class="text-xs text-white/70 truncate">{{ userInfo.schoolName }}</p>
<p class="text-xs text-gray-500 truncate">{{ greeting }} · {{ userInfo.schoolName }}</p>
<p class="text-[17px] font-bold text-gray-900 tracking-tight truncate">{{ userInfo.realName || userInfo.username }}</p>
</div>
<!-- 公告铃铛(首页与请/销假页均显示) -->
<button class="relative w-9 h-9 rounded-full bg-white/15 flex items-center justify-center hover:bg-white/25 transition-colors flex-shrink-0" @click="openAnnouncementPanel">
<Bell class="w-5 h-5 text-white" />
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
<button class="press glass relative w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0" aria-label="通知" @click="openAnnouncementPanel">
<Bell class="w-[18px] h-[18px] text-gray-700" />
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 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">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
</button>
</div>
<div v-else class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-white/20 animate-pulse" />
<div class="w-11 h-11 rounded-full bg-gray-200 animate-pulse" />
<div class="flex-1 space-y-1.5">
<div class="h-3 w-28 bg-white/20 rounded animate-pulse" />
<div class="h-2.5 w-40 bg-white/20 rounded animate-pulse" />
<div class="h-3 w-28 bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-36 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</div>
<!-- 中间内容区(可滚动;bg-background 语义类,亮暗主题自动切换) -->
<div class="flex-1 overflow-y-auto bg-background">
<router-view />
<!-- 中间内容区(可滚动):上下留出悬浮顶栏 / 胶囊 TabBar 的空间,内容可在玻璃层下方滚动 -->
<div
ref="scrollEl"
class="flex-1 overflow-y-auto overscroll-contain"
:style="{
paddingTop: showHeader ? 'calc(env(safe-area-inset-top) + 68px)' : 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">
<component :is="Component" />
</div>
</router-view>
</div>
<!-- 底部 TabBar(env 安全区适配 iPhone/iPad 底部 Home 条) -->
<div class="flex-shrink-0 bg-white border-t border-gray-100 flex" style="padding-bottom: env(safe-area-inset-bottom)">
<button
v-for="tab in tabs"
:key="tab.name"
class="flex-1 flex flex-col items-center justify-center h-14 gap-0.5 transition-colors"
:class="activeTab === tab.name ? 'text-blue-500' : 'text-gray-400'"
@click="switchTab(tab.name)"
<!-- 底部悬浮胶囊 TabBar(液态玻璃):滑动高亮块指示当前页;向下滚动时收窄、向上滚动时展开。
外层 pointer-events:none 让两侧空白处的点击穿透到内容 -->
<nav
class="absolute inset-x-0 bottom-0 z-30 flex justify-center px-5 pointer-events-none"
style="padding-bottom: calc(env(safe-area-inset-bottom) + 12px)"
aria-label="主导航"
>
<div
class="glass pointer-events-auto relative flex rounded-full p-1.5 transition-all duration-500 ease-[var(--ease-spring)]"
:class="tabCollapsed ? 'w-[64%] h-14' : 'w-full h-[66px]'"
>
<component :is="tab.icon" class="w-5 h-5" />
<span class="text-[10px]">{{ tab.label }}</span>
</button>
</div>
<!-- 滑动高亮块 -->
<div
v-if="activeTabIndex >= 0"
class="absolute top-1.5 bottom-1.5 left-1.5 rounded-full bg-brand-500/12 dark:bg-brand-400/15 transition-transform duration-500 ease-[var(--ease-spring)]"
:style="{ width: `calc((100% - 12px) / ${tabs.length})`, transform: `translateX(${activeTabIndex * 100}%)` }"
/>
<button
v-for="tab in tabs"
:key="tab.name"
class="press relative z-10 flex-1 flex flex-col items-center justify-center gap-0.5 rounded-full"
:class="activeTab === tab.name ? 'text-brand-500 dark:text-brand-400' : 'text-gray-500'"
:aria-current="activeTab === tab.name ? 'page' : undefined"
@click="switchTab(tab.name)"
>
<component :is="tab.icon" class="w-[22px] h-[22px] transition-transform duration-300" :class="activeTab === tab.name ? 'scale-110' : ''" :stroke-width="activeTab === tab.name ? 2.4 : 2" />
<span
class="text-xs leading-none overflow-hidden transition-all duration-300"
:class="[activeTab === tab.name ? 'font-semibold' : 'font-medium', tabCollapsed ? 'max-h-0 opacity-0' : 'max-h-4 opacity-100']"
>{{ tab.label }}</span>
</button>
</div>
</nav>
<!-- ======== 公告面板(遮罩 + 底部面板) ======== -->
<AppDialog
@@ -320,11 +403,11 @@ onUnmounted(() => {
<!-- 标题栏 -->
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center">
<Bell class="w-4 h-4 text-blue-500" />
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
<Bell class="w-4 h-4 text-white" />
</div>
<span class="font-semibold text-gray-900 text-sm">通知</span>
<span v-if="totalUnread > 0" class="text-[10px] px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
<span class="font-bold text-gray-900 text-base">通知</span>
<span v-if="totalUnread > 0" class="text-xs px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center transition-colors" @click="showAnnPanel = false">
<X class="w-4 h-4 text-gray-400" />
@@ -338,15 +421,15 @@ onUnmounted(() => {
<div class="flex-1 overflow-y-auto px-5 py-2">
<!-- ===== 公告分区 ===== -->
<div v-if="announcements.length > 0" class="mb-1">
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2 mt-1">公告</p>
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2 mt-1">公告</p>
<div class="space-y-2">
<div
v-for="item in announcements"
:key="'ann-'+item.id"
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
class="press rounded-2xl p-3.5 cursor-pointer border"
:class="item.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-blue-50/50 border-blue-100 hover:border-blue-200'"
: 'bg-brand-50 border-brand-100 hover:border-brand-200'"
@click="openDetail(item)"
>
<div class="flex items-start gap-3">
@@ -359,7 +442,7 @@ onUnmounted(() => {
<p class="text-sm font-medium text-gray-900 truncate">{{ item.title }}</p>
</div>
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ item.content }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
</div>
@@ -370,15 +453,15 @@ onUnmounted(() => {
<!-- ===== 工单通知分区 ===== -->
<div v-if="ticketNotifications.length > 0" class="mb-1">
<div v-if="announcements.length > 0" class="border-t border-gray-100 my-3" />
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2">工单通知</p>
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2">工单通知</p>
<div class="space-y-2">
<div
v-for="n in ticketNotifications"
:key="'tkt-'+n.id"
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
class="press rounded-2xl p-3.5 cursor-pointer border"
:class="n.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-rose-50/50 border-rose-100 hover:border-rose-200'"
: 'bg-rose-50 border-rose-100 hover:border-rose-200'"
@click="handleTicketNotifClick(n)"
>
<div class="flex items-start gap-3">
@@ -392,7 +475,7 @@ onUnmounted(() => {
<p class="text-sm font-medium text-gray-900 truncate">{{ n.title }}</p>
</div>
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ n.content }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ n.createdAt }}</p>
<p class="text-xs text-gray-400 mt-1">{{ n.createdAt }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
</div>
@@ -430,7 +513,7 @@ onUnmounted(() => {
:title="detailItem?.title || '公告详情'"
@update:open="(v: boolean) => { if (!v) showDetail = false }"
>
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
<div class="p-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2.5">
@@ -444,7 +527,7 @@ onUnmounted(() => {
</button>
</div>
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
</div>
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
@@ -468,7 +551,7 @@ onUnmounted(() => {
@update:open="(v: boolean) => { if (!v) handleForcePopupDismiss() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
<div class="p-6">
<div class="flex items-center gap-2.5 mb-2">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
@@ -488,7 +571,8 @@ onUnmounted(() => {
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
<button
v-if="!showGuide"
class="guide-entry fixed right-3 bottom-20 z-40 flex items-center gap-1.5 rounded-full bg-blue-500/95 pl-3 pr-3.5 py-2 text-white text-xs font-medium shadow-lg shadow-blue-500/30 hover:bg-blue-600 active:scale-95 transition-all"
class="guide-entry fixed right-4 z-40 flex items-center gap-1.5 rounded-full bg-brand-gradient pl-3 pr-3.5 py-2 text-white text-xs font-semibold shadow-lg shadow-brand-500/30 active:scale-95 transition-all"
style="bottom: calc(env(safe-area-inset-bottom) + 96px)"
@click="restartGuide"
>
<Sparkles class="w-3.5 h-3.5 flex-shrink-0" />
@@ -513,6 +597,13 @@ onUnmounted(() => {
50% { transform: translateY(-6px); }
}
.guide-entry {
animation: guide-bob 1.5s ease-in-out infinite;
animation: guide-bob 1.8s ease-in-out infinite;
}
/* 切页淡入:仅 opacity,避免 transform 让页面内 fixed 元素临时相对容器定位 */
.page-fade {
animation: page-fade-in 0.32s ease backwards;
}
@keyframes page-fade-in {
from { opacity: 0; }
}
</style>
@@ -258,7 +258,7 @@ function scopeLabel(item: AnnouncementEx) {
<span class="inline-flex items-center gap-1.5">
<span
v-if="item.isPinned"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-rose-100 text-rose-600 whitespace-nowrap"
class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-rose-100 text-rose-600 whitespace-nowrap"
>置顶</span>
<span class="font-medium text-gray-900 truncate">{{ item.title }}</span>
</span>
@@ -270,7 +270,7 @@ function scopeLabel(item: AnnouncementEx) {
<!-- 来源 -->
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-blue-100 text-blue-700'"
>
<Bot v-if="item.source === 'system'" class="w-3 h-3 inline mr-0.5" />
@@ -279,7 +279,7 @@ function scopeLabel(item: AnnouncementEx) {
</td>
<!-- 推送范围 -->
<td class="px-4 py-3">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700')"
>
<User v-if="item.targetUserId" class="w-3 h-3 inline mr-0.5" />
@@ -423,7 +423,7 @@ function scopeLabel(item: AnnouncementEx) {
placeholder="输入用户ID"
class="h-10 text-sm"
/>
<p class="text-[10px] text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
<p class="text-xs text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
</div>
<div class="flex items-center gap-3">
@@ -439,7 +439,7 @@ function scopeLabel(item: AnnouncementEx) {
</button>
<div>
<label class="text-sm font-medium text-gray-700">强制弹出</label>
<p class="text-[10px] text-gray-400">用户未读时自动弹出公告对话框</p>
<p class="text-xs text-gray-400">用户未读时自动弹出公告对话框</p>
</div>
</div>
@@ -456,7 +456,7 @@ function scopeLabel(item: AnnouncementEx) {
</button>
<div>
<label class="text-sm font-medium text-gray-700">循环弹窗</label>
<p class="text-[10px] text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
<p class="text-xs text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
</div>
</div>
@@ -474,7 +474,7 @@ function scopeLabel(item: AnnouncementEx) {
</button>
<div>
<label class="text-sm font-medium text-gray-700">置顶公告</label>
<p class="text-[10px] text-gray-400">显示在打卡页面顶部,横向循环滚动展示(仅普通用户,不进入用户公告列表)</p>
<p class="text-xs text-gray-400">显示在打卡页面顶部,横向循环滚动展示(仅普通用户,不进入用户公告列表)</p>
</div>
</div>
+2 -2
View File
@@ -543,7 +543,7 @@ function statusClass(s: string) {
>
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
<button
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors"
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors"
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
>
@@ -565,7 +565,7 @@ function statusClass(s: string) {
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
</Button>
</div>
<p class="text-center text-[10px] text-gray-300 mt-2">
<p class="text-center text-xs text-gray-300 mt-2">
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
</p>
</template>
+14 -14
View File
@@ -154,9 +154,9 @@ function handleLogout() {
</script>
<template>
<div class="min-h-dvh bg-gray-100 flex">
<div class="min-h-dvh bg-gray-50 flex">
<!-- 手机端顶栏 -->
<div class="fixed top-0 left-0 right-0 h-14 bg-white border-b border-gray-200 flex items-center px-4 gap-3 z-30 md:hidden">
<div class="fixed top-0 left-0 right-0 h-14 glass-bar border-b border-gray-200/70 flex items-center px-4 gap-3 z-30 md:hidden">
<button
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors"
@click="toggleSidebar"
@@ -165,7 +165,7 @@ function handleLogout() {
<X v-else class="w-5 h-5 text-gray-700" />
</button>
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
<div class="w-7 h-7 rounded-lg bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
<Shield class="w-3.5 h-3.5 text-white" />
</div>
<span class="font-medium text-gray-900 text-sm">管理后台</span>
@@ -174,7 +174,7 @@ function handleLogout() {
<!-- 手机端铃铛 -->
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
<Bell class="w-5 h-5 text-gray-600" />
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 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">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
</div>
@@ -196,21 +196,21 @@ function handleLogout() {
>
<!-- Logo(桌面) -->
<div class="h-16 flex items-center gap-2.5 px-5 border-b border-gray-100 hidden md:flex">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
<Shield class="w-4 h-4 text-white" />
</div>
<span class="font-semibold text-gray-900 text-sm flex-1">管理后台</span>
<span class="font-bold tracking-tight text-gray-900 text-[15px] flex-1">管理后台</span>
<!-- 桌面端铃铛 -->
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
<Bell class="w-4 h-4 text-gray-500" />
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[14px] h-3.5 px-1 rounded-full bg-red-500 text-white text-[8px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 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">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
</div>
<!-- 手机端顶部关闭 -->
<div class="h-14 flex items-center justify-between px-4 border-b border-gray-100 md:hidden">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
<div class="w-7 h-7 rounded-lg bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
<Shield class="w-3.5 h-3.5 text-white" />
</div>
<span class="font-medium text-gray-900 text-sm">管理后台</span>
@@ -225,10 +225,10 @@ function handleLogout() {
<button
v-for="item in visibleNavItems"
:key="item.name"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-colors"
:class="activeNav === item.name
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
? 'bg-brand-50 text-brand-700 font-semibold'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
@click="navigateTo(item)"
>
<component :is="item.icon" class="w-4 h-4 flex-shrink-0" />
@@ -284,7 +284,7 @@ function handleLogout() {
<div class="flex items-center gap-2">
<Bell class="w-5 h-5 text-blue-500" />
<span class="font-semibold text-gray-900 text-sm">公告</span>
<span v-if="unreadCount > 0" class="text-[10px] px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
<span v-if="unreadCount > 0" class="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center" @click="showAnnPanel = false">
<X class="w-4 h-4 text-gray-400" />
@@ -319,7 +319,7 @@ function handleLogout() {
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900" :class="{ 'font-semibold': !item.isRead }">{{ item.title }}</p>
<p class="text-xs text-gray-500 leading-relaxed mt-0.5 line-clamp-1">{{ item.content }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
</div>
</div>
</div>
@@ -354,7 +354,7 @@ function handleLogout() {
</button>
</div>
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
</div>
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
+2 -2
View File
@@ -49,11 +49,11 @@ async function handleLogin() {
</script>
<template>
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
<div class="w-full max-w-md">
<!-- 标题 -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
<Shield class="w-8 h-8 text-white" />
</div>
<h1 class="text-2xl font-bold text-gray-900">管理后台</h1>
@@ -209,7 +209,7 @@ async function copyText(text: string) {
{{ name }}<span v-if="i < adaptedSchools.size - 1">、</span>
</template>
</p>
<p class="text-[10px] text-green-500 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
<p class="text-xs text-green-500 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
</div>
<!-- 搜索 -->
@@ -250,14 +250,14 @@ async function copyText(text: string) {
<span class="text-sm font-semibold text-gray-900">{{ group.schoolName }}</span>
<span
v-if="adaptedSchools.has(group.schoolId)"
class="text-[10px] px-1.5 py-0.5 rounded-full bg-green-50 text-green-600 font-medium border border-green-100"
class="text-xs px-1.5 py-0.5 rounded-full bg-green-50 text-green-600 font-medium border border-green-100"
>
已适配
</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-600 font-medium border border-amber-100">
<span class="text-xs px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-600 font-medium border border-amber-100">
{{ group.count }} 条
</span>
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
<span class="text-xs text-gray-400 font-mono">{{ group.schoolId }}</span>
</div>
<div class="flex items-center gap-1.5">
<Button
@@ -343,14 +343,14 @@ async function copyText(text: string) {
<!-- 状态 -->
<td class="px-5 py-3">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium"
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : r.status === 'rejected' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'"
>
{{ r.status === 'pending' ? '待处理' : r.status === 'rejected' ? '已拒绝' : '已完成' }}
</span>
<p
v-if="r.status === 'rejected' && r.rejectReason"
class="text-[10px] text-red-400 mt-1 max-w-[180px] leading-snug"
class="text-xs text-red-400 mt-1 max-w-[180px] leading-snug"
:title="r.rejectReason"
>理由:{{ r.rejectReason }}</p>
</td>
@@ -410,7 +410,7 @@ async function copyText(text: string) {
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
></textarea>
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
<p class="text-xs text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
<div class="flex justify-end gap-2 mt-5">
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
取消
+2 -2
View File
@@ -59,11 +59,11 @@ async function handleSetup() {
</script>
<template>
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
<div class="w-full max-w-md">
<!-- 标题 -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
<Shield class="w-8 h-8 text-white" />
</div>
<h1 class="text-2xl font-bold text-gray-900">初始化管理员</h1>
+10 -10
View File
@@ -491,11 +491,11 @@ function todayStatusLabel(s: string) {
>
<td class="px-4 py-3">
<p class="font-mono text-xs text-gray-700 truncate" :title="user.studentId">{{ user.studentId || '--' }}</p>
<p class="text-[10px] text-gray-400 truncate">{{ user.realName || '--' }}</p>
<p class="text-xs text-gray-400 truncate">{{ user.realName || '--' }}</p>
</td>
<td class="px-4 py-3">
<p class="text-xs text-gray-700 truncate" :title="user.schoolName">{{ user.schoolName || '--' }}</p>
<p class="text-[10px] font-mono text-gray-400 flex items-center gap-1 min-w-0">
<p class="text-xs font-mono text-gray-400 flex items-center gap-1 min-w-0">
<span v-if="user.mobileCode" class="truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
<span v-else>未绑定</span>
<button
@@ -516,14 +516,14 @@ function todayStatusLabel(s: string) {
<td class="px-4 py-3">
<span
v-if="user.vipStatus === 'active'"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
:title="`VIP 到期时间:${user.vipExpireAt}`"
>
VIP · {{ user.vipExpireAt.slice(5, 10) }}
</span>
<span
v-else-if="user.vipStatus === 'expired'"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
:title="`VIP 已于 ${user.vipExpireAt} 过期`"
>
VIP · 已过期
@@ -541,7 +541,7 @@ function todayStatusLabel(s: string) {
</td>
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
class="text-xs px-1.5 py-0.5 rounded-full font-medium"
:class="user.todayStatus === 'success' ? 'bg-green-100 text-green-700' : user.todayStatus === 'fail' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-400'"
>
{{ todayStatusLabel(user.todayStatus) }}
@@ -550,7 +550,7 @@ function todayStatusLabel(s: string) {
<td class="px-4 py-3 text-xs text-gray-500">{{ user.createdAt }}</td>
<td class="px-4 py-3">
<p class="font-mono text-xs text-gray-700" :title="user.lastLoginIp || undefined">{{ user.lastLoginIp || '--' }}</p>
<p class="text-[10px] text-gray-400">{{ user.lastLoginAt || '未登录' }}</p>
<p class="text-xs text-gray-400">{{ user.lastLoginAt || '未登录' }}</p>
</td>
<td class="px-4 py-3 text-center whitespace-nowrap">
<div class="flex items-center justify-center gap-1">
@@ -888,10 +888,10 @@ function todayStatusLabel(s: string) {
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
<p class="text-xs text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
</td>
<td class="px-4 py-2.5">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
{{ taskTypeLabel(log.taskType) }}
</span>
</td>
@@ -901,14 +901,14 @@ function todayStatusLabel(s: string) {
<td class="px-4 py-2.5">
<div class="flex flex-col items-start gap-1">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
class="text-xs px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
<span
v-if="log.deleted"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
class="text-xs px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
title="用户已在客户端删除该日志,后台统计仍计入"
>
已删除
+8 -8
View File
@@ -47,7 +47,7 @@ async function generateQrImage(url: string): Promise<string> {
ctx.restore()
// 3. 绘制蓝色手机图标(SVG → Image)
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0b8672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`
const svgBlob = new Blob([svg], { type: 'image/svg+xml' })
const svgUrl = URL.createObjectURL(svgBlob)
@@ -160,14 +160,14 @@ onUnmounted(() => {
</script>
<template>
<div class="min-h-full flex flex-col bg-gradient-to-b from-blue-50 to-white">
<div class="min-h-full flex flex-col">
<!-- 顶部说明 -->
<div class="px-6 pt-6 pb-4 text-center">
<div class="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-3 shadow-lg shadow-blue-200">
<div class="px-6 pb-4 text-center rise-in" style="padding-top: calc(env(safe-area-inset-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>
<h1 class="text-xl font-bold text-gray-900">绑定设备</h1>
<p class="text-sm text-gray-400 mt-1">请扫描二维码,绑定设备信息</p>
<h1 class="text-[22px] font-bold tracking-tight text-gray-900">绑定设备</h1>
<p class="text-sm text-gray-500 mt-1">请扫描二维码,绑定设备信息</p>
</div>
<div class="flex-1 flex flex-col items-center px-6">
@@ -179,7 +179,7 @@ onUnmounted(() => {
<!-- 二维码 + 自动轮询 -->
<div v-else-if="status === 'show-qr' || status === 'polling'" class="w-full max-w-xs">
<div class="bg-white rounded-2xl border border-gray-100 shadow-lg p-6 text-center">
<div class="bg-white rounded-3xl border border-gray-100/80 shadow-xl p-6 text-center rise-in">
<!-- QR Code(单一合成图片,含 logo) -->
<div class="w-48 h-48 mx-auto bg-white border-2 border-gray-100 rounded-xl flex items-center justify-center mb-4">
<img
@@ -211,7 +211,7 @@ onUnmounted(() => {
</div>
<!-- 使用说明 -->
<div class="mt-4 bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="mt-4 bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-3">操作步骤</h3>
<div class="space-y-3">
<div class="flex items-start gap-3">
+2 -2
View File
@@ -13,9 +13,9 @@ function goTicket() {
</script>
<template>
<div class="flex flex-col min-h-full bg-gray-50">
<div class="flex flex-col min-h-full">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<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="flex items-center px-4 h-12">
<h1 class="flex-1 text-center text-base font-semibold text-gray-900">网课</h1>
</div>
@@ -143,7 +143,7 @@ function fallbackCopy(text: string) {
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
color: #0b8672;
background: #eff6ff;
}
+72 -74
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircle, Clock, Bell, Calendar, 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, Megaphone } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import jsQR from 'jsqr'
@@ -1175,7 +1175,7 @@ function getGPSLocation(source: string) {
<!-- ======== 加载骨架 ======== -->
<div v-if="loading" class="p-4 space-y-4">
<!-- 签到管理区域骨架 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<div class="space-y-2 flex-1">
<div class="h-4 w-24 bg-gray-100 rounded animate-pulse" />
@@ -1196,7 +1196,7 @@ function getGPSLocation(source: string) {
</div>
<!-- 任务列表骨架 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<div class="h-4 w-16 bg-gray-100 rounded animate-pulse mb-3" />
<div class="space-y-2">
<div v-for="i in 2" :key="'t'+i" class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
@@ -1244,53 +1244,51 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 签到创建入口 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<!-- 签到创建入口(品牌渐变头卡)+ 统计 -->
<div class="relative overflow-hidden 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-sm font-semibold text-gray-900">打卡管理</p>
<p class="text-xs text-gray-400 mt-0.5">创建并执行每日签到任务</p>
<p class="text-lg font-bold tracking-tight">打卡管理</p>
<p class="text-xs text-white/80 mt-0.5">创建并执行每日签到任务</p>
</div>
<div class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
<ListTodo class="w-5 h-5 text-blue-500" />
<div class="w-11 h-11 rounded-2xl bg-white/20 flex items-center justify-center">
<ListTodo class="w-5 h-5 text-white" />
</div>
</div>
<!-- 统计(嵌在头卡内,四宫格) -->
<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>
</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>
</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>
</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>
</div>
</div>
<button
data-guide="home-create-task"
@click="startCreateTask"
class="w-full flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
class="press relative mt-4 w-full flex items-center justify-center gap-2 bg-white text-brand-600 rounded-full py-3 text-sm font-semibold shadow-md shadow-black/5 dark:bg-[#f4fbf8] dark:text-[#0a6b5c]"
>
<Plus class="w-4 h-4" />
<Plus class="w-4 h-4" :stroke-width="2.6" />
新的打卡任务
</button>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-4 gap-2">
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
<Calendar class="w-4 h-4 text-blue-500 mx-auto mb-0.5" />
<p class="text-base font-bold text-gray-900">{{ tasks.filter(t => t.taskStatus === 'success').length }}</p>
<p class="text-[9px] text-gray-400">已完成</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
<CheckCircle class="w-4 h-4 text-green-500 mx-auto mb-0.5" />
<p class="text-base font-bold text-gray-900">{{ tasks.filter(t => t.matchStatus === 'matched_signed').length }}</p>
<p class="text-[9px] text-gray-400">已签到</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
<Bell class="w-4 h-4 text-amber-500 mx-auto mb-0.5" />
<p class="text-base font-bold text-gray-900">{{ tasks.filter(t => t.taskStatus === 'pending' && t.matchStatus !== 'matched_signed').length }}</p>
<p class="text-[9px] text-gray-400">待签到</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
<Clock class="w-4 h-4 text-amber-500 mx-auto mb-0.5" />
<p class="text-base font-bold text-gray-900">{{ tasks.length }}</p>
<p class="text-[9px] text-gray-400">任务数</p>
</div>
</div>
<!-- 任务列表(不显示 WID) -->
<div data-guide="home-task-list" class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div data-guide="home-task-list" class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-3">打卡任务</h3>
<div v-if="tasks.length > 0" class="space-y-2">
@@ -1303,34 +1301,34 @@ function getGPSLocation(source: string) {
<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-[10px] 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="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>
</div>
<div class="flex items-center gap-2 mt-1">
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
今日校园侧签到状态同步有延迟时 matchStatus 可能仍是未签到,应以本地成功为准 -->
<template v-if="task.taskStatus === 'success' || task.matchStatus === 'matched_signed'">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-green-100 text-green-600">已签到</span>
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-green-100 text-green-600">已签到</span>
</template>
<template v-else-if="task.matchStatus === 'matched_unsigned'">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-blue-100 text-blue-600">未签到</span>
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-blue-100 text-blue-600">未签到</span>
</template>
<template v-else-if="task.matchStatus === 'no_match'">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600">未匹配</span>
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600">未匹配</span>
</template>
<template v-else>
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium" :class="statusBadge(task.taskStatus)">
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium" :class="statusBadge(task.taskStatus)">
{{ statusLabel(task.taskStatus) }}
</span>
</template>
<span class="text-[10px] text-gray-400">{{ new Date(task.createdAt).toLocaleString('zh-CN') }}</span>
<span class="text-xs text-gray-400">{{ new Date(task.createdAt).toLocaleString('zh-CN') }}</span>
</div>
<!-- 错误信息:仅在没有 SSE 执行状态时显示(避免与 execMessage 重复) -->
<div v-if="task.taskStatus === 'fail' && task.errorMessage && executingId !== task.id" class="mt-1">
<span class="text-[10px] text-red-500 leading-tight block" v-html="task.errorMessage"></span>
<span class="text-xs text-red-500 leading-tight block" v-html="task.errorMessage"></span>
</div>
<!-- 执行状态信息 -->
<div v-if="executingId === task.id && execMessage" class="mt-1">
<span class="text-[10px] text-blue-500 leading-tight block">{{ execMessage }}</span>
<span class="text-xs text-blue-500 leading-tight block">{{ execMessage }}</span>
</div>
</div>
<div class="flex items-center gap-1 ml-2">
@@ -1388,7 +1386,7 @@ function getGPSLocation(source: string) {
</div>
<!-- 任务日志列表 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm mt-4">
<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">
@@ -1404,23 +1402,23 @@ function getGPSLocation(source: string) {
<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-[10px] text-gray-400 truncate">→ {{ log.realTitle }}</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-[10px] px-1.5 py-0.5 rounded-full font-medium"
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-[10px] text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</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-[10px] text-red-500 leading-tight block" v-html="log.errorMessage"></span>
<span class="text-xs text-red-500 leading-tight block" v-html="log.errorMessage"></span>
</div>
</div>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ml-2"
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) }}
@@ -1481,7 +1479,7 @@ function getGPSLocation(source: string) {
>
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-gray-900 flex-1 min-w-0 truncate">{{ item.taskName }}</p>
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (item.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(item.taskType) }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (item.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(item.taskType) }}</span>
</div>
</div>
<div v-if="titles.length === 0 && !loadingTitles" class="text-center py-8">
@@ -1510,23 +1508,23 @@ function getGPSLocation(source: string) {
<!-- 名称匹配实时校验:匹配不到现有任务时立即提示(与执行打卡时的匹配规则一致) -->
<div
v-if="matchedTitleNames && matchedTitleNames.length === 0"
class="text-[11px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 leading-relaxed"
class="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 leading-relaxed"
>
当前名称匹配不到现有的打卡任务,请修改名称后再进入下一步。
</div>
<div
v-else-if="matchedTitleNames"
class="text-[11px] text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-lg px-3 py-2 leading-relaxed"
class="text-xs text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-lg px-3 py-2 leading-relaxed"
>
可匹配到现有任务:{{ matchedTitleHint }}
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p class="text-xs text-amber-700 font-medium">💡 标题匹配提示</p>
<p class="text-[11px] text-amber-600 mt-1 leading-relaxed">
如果任务标题含有日期等会变化的部分,可以用 <code class="bg-amber-100 px-1 rounded text-[11px] font-mono">*</code> 代替。
<p class="text-xs text-amber-600 mt-1 leading-relaxed">
如果任务标题含有日期等会变化的部分,可以用 <code class="bg-amber-100 px-1 rounded text-xs font-mono">*</code> 代替。
</p>
<p class="text-[11px] text-amber-600 mt-1 leading-relaxed">
例如:<code class="bg-amber-100 px-1 rounded text-[11px] font-mono">*晚自习签到</code> 会匹配 "8月21日晚自习签到"、"9月1日晚自习签到" 等。
<p class="text-xs text-amber-600 mt-1 leading-relaxed">
例如:<code class="bg-amber-100 px-1 rounded text-xs font-mono">*晚自习签到</code> 会匹配 "8月21日晚自习签到"、"9月1日晚自习签到" 等。
</p>
</div>
</div>
@@ -1565,8 +1563,8 @@ function getGPSLocation(source: string) {
<MapPin v-else class="w-4 h-4" />
</button>
</div>
<p v-if="item.colName === 'lon' && gpsError" class="text-[10px] text-red-400">{{ gpsError }}</p>
<p v-if="item.tip && !gpsError" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.colName === 'lon' && gpsError" class="text-xs text-red-400">{{ gpsError }}</p>
<p v-if="item.tip && !gpsError" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 普通输入框(地址、原因等) -->
@@ -1578,7 +1576,7 @@ function getGPSLocation(source: string) {
:placeholder="'请输入' + item.title"
class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
/>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 单选项 -->
@@ -1597,7 +1595,7 @@ function getGPSLocation(source: string) {
{{ opt.content }}
</button>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
<!-- 范围外签到原因联动 -->
<div v-if="item.colName === 'isMalposition' && item.content === '是'" class="mt-2 space-y-1">
<label class="text-xs font-medium text-gray-600">范围外签到原因 <span class="text-red-400">*</span></label>
@@ -1636,7 +1634,7 @@ function getGPSLocation(source: string) {
<span>上传二维码图片</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
@@ -1652,7 +1650,7 @@ function getGPSLocation(source: string) {
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-[10px]">{{ uploadingLabel }}</span>
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<!-- 已有图片:显示缩略图,点击替换 -->
<img
@@ -1666,10 +1664,10 @@ function getGPSLocation(source: string) {
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-[10px] mt-1">上传</span>
<span class="text-xs mt-1">上传</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
</div>
</div>
@@ -1758,8 +1756,8 @@ function getGPSLocation(source: string) {
<MapPin v-else class="w-4 h-4" />
</button>
</div>
<p v-if="item.colName === 'lon' && gpsError" class="text-[10px] text-red-400">{{ gpsError }}</p>
<p v-if="item.tip && !gpsError" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.colName === 'lon' && gpsError" class="text-xs text-red-400">{{ gpsError }}</p>
<p v-if="item.tip && !gpsError" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 普通输入框 -->
@@ -1771,7 +1769,7 @@ function getGPSLocation(source: string) {
:placeholder="'请输入' + item.title"
class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
/>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 单选项 -->
@@ -1790,7 +1788,7 @@ function getGPSLocation(source: string) {
{{ opt.content }}
</button>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
<!-- 范围外签到原因 -->
<div v-if="item.colName === 'isMalposition' && item.content === '是'" class="mt-2 space-y-1">
<label class="text-xs font-medium text-gray-600">范围外签到原因 <span class="text-red-400">*</span></label>
@@ -1829,7 +1827,7 @@ function getGPSLocation(source: string) {
<span>上传二维码图片</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换 -->
@@ -1845,7 +1843,7 @@ function getGPSLocation(source: string) {
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-[10px]">{{ uploadingLabel }}</span>
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<img
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
@@ -1857,10 +1855,10 @@ function getGPSLocation(source: string) {
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-[10px] mt-1">上传</span>
<span class="text-xs mt-1">上传</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
</div>
</template>
+73 -61
View File
@@ -2,22 +2,24 @@
<!-- 请/销假页:上方请假+销假按钮(左右平分),下方请假/销假日志列表。
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
<div class="p-4 space-y-4">
<!-- 入口卡片:请假 / 销假 按钮并排左右平分 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<!-- 入口卡片:请假 / 销假 按钮并排左右平分(珊瑚渐变头卡) -->
<div class="relative overflow-hidden 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">
<div>
<p class="text-sm font-semibold text-gray-900">请假与销假</p>
<p class="text-xs text-gray-400 mt-0.5">异地一键请假销假,自行设置位置</p>
<p class="text-lg font-bold tracking-tight">请假与销假</p>
<p class="text-xs text-white/80 mt-0.5">异地一键请假销假,自行设置位置</p>
</div>
<div class="w-10 h-10 rounded-full bg-rose-50 flex items-center justify-center">
<CalendarCheck class="w-5 h-5 text-rose-500" />
<div class="w-11 h-11 rounded-2xl bg-white/20 flex items-center justify-center">
<CalendarCheck class="w-5 h-5 text-white" />
</div>
</div>
<div class="flex gap-2">
<div class="relative flex gap-2.5">
<button
@click="openApplyModal"
:disabled="executing || applying"
class="flex-1 flex items-center justify-center gap-2 bg-white border border-rose-300 text-rose-500 hover:bg-rose-50 disabled:opacity-40 rounded-lg py-2.5 text-sm font-medium transition-colors"
class="press flex-1 flex items-center justify-center gap-2 bg-white/20 ring-1 ring-white/30 text-white disabled:opacity-50 rounded-full py-3 text-sm font-semibold"
>
<CalendarPlus class="w-4 h-4" />
请假
@@ -25,7 +27,7 @@
<button
@click="openPendingModal"
:disabled="executing || applying"
class="flex-1 flex items-center justify-center gap-2 bg-rose-500 hover:bg-rose-600 disabled:bg-rose-300 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
class="press flex-1 flex items-center justify-center gap-2 bg-white text-rose-600 disabled:opacity-50 rounded-full py-3 text-sm font-semibold shadow-md shadow-black/5 dark:bg-[#fff5f7] dark:text-[#b8325a]"
>
<CalendarCheck class="w-4 h-4" />
销假
@@ -34,7 +36,7 @@
</div>
<!-- 请假/销假日志列表 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-3">请假/销假日志</h3>
<div v-if="loadingLogs" class="flex items-center justify-center py-6">
@@ -50,22 +52,22 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 font-medium"
class="text-xs px-1.5 py-0.5 rounded-full shrink-0 font-medium"
:class="log.action === 'apply' ? 'bg-blue-100 text-blue-600' : 'bg-rose-100 text-rose-600'"
>{{ log.action === 'apply' ? '请假' : '销假' }}</span>
<span class="text-xs font-medium text-gray-900 truncate">{{ log.title }}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
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-[10px] text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</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-[10px] text-red-500 leading-tight block">{{ log.errorMessage }}</span>
<span class="text-xs text-red-500 leading-tight block">{{ log.errorMessage }}</span>
</div>
</div>
<button
@@ -118,14 +120,14 @@
class="relative p-3 rounded-lg border border-gray-100 hover:border-rose-300 hover:bg-rose-50 cursor-pointer transition-colors"
>
<div class="flex items-center gap-1.5 mb-1.5">
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 bg-amber-100 text-amber-600 font-medium">{{ leave.leaveType || '请假' }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 bg-amber-100 text-amber-600 font-medium">{{ leave.leaveType || '请假' }}</span>
<p class="text-sm font-medium text-gray-900 flex-1 min-w-0 truncate">{{ leave.reason || '无请假原因' }}</p>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap font-medium"
class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap font-medium"
:style="{ backgroundColor: leaveStatusColor(leave.status) + '1a', color: leaveStatusColor(leave.status) }"
>{{ leaveStatusLabelOf(leave) }}</span>
</div>
<div class="text-[10px] text-gray-400 space-y-0.5">
<div class="text-xs text-gray-400 space-y-0.5">
<p>提交时间:{{ leave.createTime || '-' }}</p>
<p>预计结束:{{ leave.actEndTime || '-' }}</p>
</div>
@@ -156,17 +158,17 @@
<!-- 请假信息 -->
<div v-if="selectedLeave" class="rounded-lg bg-gray-50 p-3">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-600 font-medium">{{ selectedLeave.leaveType || '请假' }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-600 font-medium">{{ selectedLeave.leaveType || '请假' }}</span>
<span class="text-xs font-medium text-gray-900 truncate">{{ selectedLeave.reason || '无请假原因' }}</span>
</div>
<p class="text-[10px] text-gray-400">预计结束:{{ selectedLeave.actEndTime || '-' }}</p>
<p class="text-xs text-gray-400">预计结束:{{ selectedLeave.actEndTime || '-' }}</p>
</div>
<!-- 规定销假位置选择(reportPlaces) -->
<div v-if="reportPlaces.length > 0">
<div class="flex items-start gap-1.5 rounded-lg bg-green-50 border border-green-100 p-2.5 mb-2">
<MapPin class="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<p class="text-[10px] text-green-600 leading-relaxed">
<p class="text-xs text-green-600 leading-relaxed">
该请假要求在学校规定位置销假,请选择您当前所在的销假位置
</p>
</div>
@@ -183,15 +185,15 @@
<div class="flex items-center gap-1.5">
<MapPin class="w-3.5 h-3.5 shrink-0" :class="selectedPlaceIndex === idx ? 'text-rose-500' : 'text-gray-300'" />
<span class="text-xs font-medium text-gray-900 flex-1 min-w-0 truncate">{{ place.address }}</span>
<span class="text-[10px] text-gray-400 shrink-0">范围 {{ place.radius }}m</span>
<span class="text-xs text-gray-400 shrink-0">范围 {{ place.radius }}m</span>
</div>
<p class="text-[10px] text-gray-400 mt-0.5 ml-5">经纬度:{{ place.longitude }}, {{ place.latitude }}</p>
<p class="text-xs text-gray-400 mt-0.5 ml-5">经纬度:{{ place.longitude }}, {{ place.latitude }}</p>
</div>
</div>
</div>
<div v-else class="flex items-start gap-1.5 rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<MapPin class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<p class="text-[10px] text-amber-600 leading-relaxed">
<p class="text-xs text-amber-600 leading-relaxed">
该请假未设置规定销假位置,请手动填写当前所在位置的经纬度与地址
</p>
</div>
@@ -252,15 +254,15 @@
<div v-if="form.approverWid === a.userWid" class="w-1.5 h-1.5 rounded-full bg-rose-500"></div>
</div>
<span class="text-xs text-gray-900">{{ a.userName }}</span>
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
<span class="text-xs text-gray-400">{{ a.userId }}</span>
</div>
</div>
<div v-else class="rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<p class="text-[10px] text-amber-600">未获取到可选的审批人,请稍后重试</p>
<p class="text-xs text-amber-600">未获取到可选的审批人,请稍后重试</p>
<button
@click="loadApprovers"
:disabled="loadingApprovers"
class="mt-1.5 text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40"
class="mt-1.5 text-xs text-rose-500 hover:text-rose-600 disabled:opacity-40"
>重新获取</button>
</div>
</div>
@@ -270,8 +272,8 @@
<div>
<label class="text-xs text-gray-500 mb-1 block">
销假图片
<span v-if="selectedLeave?.enableAttach" class="text-[10px] text-gray-400">(可选)</span>
<span v-else class="text-[10px] text-amber-500">(部分学校销假要求上传,如提交提示需要附件请在此上传)</span>
<span v-if="selectedLeave?.enableAttach" class="text-xs text-gray-400">(可选)</span>
<span v-else class="text-xs text-amber-500">(部分学校销假要求上传,如提交提示需要附件请在此上传)</span>
</label>
<div v-if="form.imageUrl" class="relative inline-block">
<img :src="form.imageUrl" class="w-20 h-20 rounded-lg object-cover border border-gray-200" />
@@ -292,13 +294,13 @@
<LoaderCircle v-if="uploadingImage" class="w-5 h-5 text-rose-400 animate-spin" />
<template v-else>
<Upload class="w-5 h-5" />
<span class="text-[10px] mt-1">上传图片</span>
<span class="text-xs mt-1">上传图片</span>
</template>
</button>
</div>
<!-- 执行状态 -->
<p v-if="execMessage" class="text-[11px] leading-relaxed" :class="execStatus === 'error' ? 'text-red-500' : execStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
<p v-if="execMessage" class="text-xs leading-relaxed" :class="execStatus === 'error' ? 'text-red-500' : execStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
{{ execMessage }}
</p>
</div>
@@ -357,7 +359,7 @@
{{ t.name }}{{ t.disableTime === 1 ? '(当前不可用)' : '' }}
</option>
</select>
<p v-if="currentType?.limitDay" class="text-[10px] text-gray-400 mt-1">单次最长可请 {{ currentType.limitDay }} 天</p>
<p v-if="currentType?.limitDay" class="text-xs text-gray-400 mt-1">单次最长可请 {{ currentType.limitDay }} 天</p>
</div>
<!-- 起止时间 -->
@@ -395,7 +397,7 @@
></span>
</button>
</div>
<p v-if="currentType?.mustOut === 1" class="text-[10px] text-gray-400 -mt-1">该请假类型要求必须离校</p>
<p v-if="currentType?.mustOut === 1" class="text-xs text-gray-400 -mt-1">该请假类型要求必须离校</p>
<!-- 离校字段 -->
<template v-if="applyForm.outStatus">
@@ -432,7 +434,7 @@
<div>
<label class="text-xs text-gray-500 mb-1 block">
请假原因
<span v-if="currentType?.reasonWordLimit" class="text-[10px] text-gray-400">(最少 {{ currentType.reasonWordLimit }} 字,当前 {{ applyForm.leaveReason.length }} 字)</span>
<span v-if="currentType?.reasonWordLimit" class="text-xs text-gray-400">(最少 {{ currentType.reasonWordLimit }} 字,当前 {{ applyForm.leaveReason.length }} 字)</span>
</label>
<textarea
v-model="applyForm.leaveReason"
@@ -449,7 +451,7 @@
<button
@click="getApplyGPS"
:disabled="gettingApplyGPS"
class="text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40 flex items-center gap-0.5"
class="text-xs text-rose-500 hover:text-rose-600 disabled:opacity-40 flex items-center gap-0.5"
>
<LoaderCircle v-if="gettingApplyGPS" class="w-3 h-3 animate-spin" />
<MapPin v-else class="w-3 h-3" />
@@ -478,7 +480,7 @@
placeholder="定位地址"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:border-rose-400"
/>
<p v-if="applyGpsError" class="text-[10px] text-amber-500 mt-1">{{ applyGpsError }}</p>
<p v-if="applyGpsError" class="text-xs text-amber-500 mt-1">{{ applyGpsError }}</p>
</div>
<!-- 手写承诺书签名(requireSign=1 时必填;该校要求先手写签名) -->
@@ -498,10 +500,10 @@
class="h-16 px-4 rounded-lg border border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-400 hover:border-rose-300 hover:text-rose-400 transition-colors"
>
<Edit3 class="w-4 h-4" />
<span class="text-[10px] mt-1">{{ signaturePreview ? '重新签名' : '点击签名' }}</span>
<span class="text-xs mt-1">{{ signaturePreview ? '重新签名' : '点击签名' }}</span>
</button>
</div>
<p class="text-[10px] text-gray-400 mt-1">本人已阅读知晓《请假安全承诺书》全部内容,并同意上述承诺</p>
<p class="text-xs text-gray-400 mt-1">本人已阅读知晓《请假安全承诺书》全部内容,并同意上述承诺</p>
</div>
<!-- 附件(enableAttach=1 或 attachmentNumberLimit>0 时显示;多图,最多 4 张且不超学校配置) -->
@@ -533,7 +535,7 @@
<LoaderCircle v-if="uploadingApplyImage" class="w-5 h-5 text-rose-400 animate-spin" />
<template v-else>
<Upload class="w-5 h-5" />
<span class="text-[10px] mt-1">上传图片</span>
<span class="text-xs mt-1">上传图片</span>
</template>
</button>
</div>
@@ -544,7 +546,7 @@
<label class="text-xs text-gray-500 mb-1 block">
审批人
<span v-if="applyApprovers.length > 0" class="text-rose-500">*</span>
<span v-else-if="!applyApproversError" class="text-[10px] text-gray-400">(该校未配置审批人,可不选)</span>
<span v-else-if="!applyApproversError" class="text-xs text-gray-400">(该校未配置审批人,可不选)</span>
</label>
<div v-if="loadingApplyApprovers" class="flex items-center justify-center py-3">
<LoaderCircle class="w-4 h-4 text-rose-400 animate-spin" />
@@ -566,19 +568,19 @@
<div v-if="applyForm.approverWid === a.userWid" class="w-1.5 h-1.5 rounded-full bg-rose-500"></div>
</div>
<span class="text-xs text-gray-900">{{ a.userName }}</span>
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
<span class="text-xs text-gray-400">{{ a.userId }}</span>
</div>
</div>
<div v-else class="rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<p v-if="applyApproversError" class="text-[10px] text-red-500">{{ applyApproversError }}</p>
<p v-else-if="canFetchApplyApprovers" class="text-[10px] text-amber-600">
<p v-if="applyApproversError" class="text-xs text-red-500">{{ applyApproversError }}</p>
<p v-else-if="canFetchApplyApprovers" class="text-xs text-amber-600">
该校该请假类型未配置审批人,无需选择,可直接提交
</p>
<p v-else class="text-[10px] text-amber-600">请先选择请假类型并填写开始/结束时间,再获取审批人</p>
<p v-else class="text-xs text-amber-600">请先选择请假类型并填写开始/结束时间,再获取审批人</p>
<button
@click="loadApplyApprovers"
:disabled="loadingApplyApprovers || !canFetchApplyApprovers"
class="mt-1.5 text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40"
class="mt-1.5 text-xs text-rose-500 hover:text-rose-600 disabled:opacity-40"
>重新获取</button>
</div>
</div>
@@ -587,7 +589,7 @@
<div v-if="canFetchApplyApprovers && applyApprovers.length > 0">
<label class="text-xs text-gray-500 mb-1 block">
抄送人
<span class="text-[10px] text-gray-400">(非必填,可多选)</span>
<span class="text-xs text-gray-400">(非必填,可多选)</span>
</label>
<div class="space-y-1.5">
<div
@@ -608,13 +610,13 @@
</svg>
</div>
<span class="text-xs text-gray-900">{{ a.userName }}</span>
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
<span class="text-xs text-gray-400">{{ a.userId }}</span>
</div>
</div>
</div>
<!-- 执行状态 -->
<p v-if="applyMessage" class="text-[11px] leading-relaxed" :class="applyStatus === 'error' ? 'text-red-500' : applyStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
<p v-if="applyMessage" class="text-xs leading-relaxed" :class="applyStatus === 'error' ? 'text-red-500' : applyStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
{{ applyMessage }}
</p>
</template>
@@ -663,7 +665,7 @@
@touchend.prevent="signEnd"
/>
</div>
<p v-if="signaturePreview" class="text-[10px] text-green-600 mt-2">已签名。如需重签,点「重置」后重新书写。</p>
<p v-if="signaturePreview" class="text-xs text-green-600 mt-2">已签名。如需重签,点「重置」后重新书写。</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="resetSignature">
重置
@@ -809,18 +811,19 @@ async function loadPendingLeaves() {
}
}
// 请假状态映射(status → 展示文案 / 颜色)
// 请假状态映射(status → 展示文案 / 颜色)。
// 颜色取薄荷汽水色板的中间调:作文字在亮/暗卡片上均可辨识,叠 10% 透明度作底色
const leaveStatusMap: Record<string, { label: string; color: string }> = {
'1': { label: '待审批', color: '#f0a020' },
'2': { label: '处理中', color: '#2080f0' },
'3': { label: '已驳回', color: '#d03050' },
'4': { label: '已完成', color: '#18a058' },
'5': { label: '即将休假', color: '#8a2be2' },
'6': { label: '正在休假中', color: '#36ad6a' },
'7': { label: '销假逾期', color: '#ff6b6b' },
'8': { label: '待销假', color: '#18a058' },
'9': { label: '自动通过', color: '#18a058' },
'10': { label: '逾期自动驳回', color: '#d03050' },
'1': { label: '待审批', color: '#c98a00' },
'2': { label: '处理中', color: '#2f86d6' },
'3': { label: '已驳回', color: '#d94a63' },
'4': { label: '已完成', color: '#1f9d68' },
'5': { label: '即将休假', color: '#8b6ce6' },
'6': { label: '正在休假中', color: '#14a88d' },
'7': { label: '销假逾期', color: '#e8606e' },
'8': { label: '待销假', color: '#1f9d68' },
'9': { label: '自动通过', color: '#1f9d68' },
'10': { label: '逾期自动驳回', color: '#d94a63' },
}
function leaveStatusLabel(status: string) {
@@ -833,7 +836,7 @@ function leaveStatusLabelOf(leave: { status: string; statusText?: string }) {
}
function leaveStatusColor(status: string) {
return leaveStatusMap[status]?.color || '#909399'
return leaveStatusMap[status]?.color || '#7f908a'
}
// ======== 销假表单弹窗 ========
@@ -1684,6 +1687,15 @@ async function submitApply() {
</script>
<style scoped>
/* 请/销假头卡:珊瑚 → 粉的柔和渐变(暗色下压暗一档) */
.leave-hero {
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%);
}
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
.bookmark-vtext {
+46 -45
View File
@@ -596,32 +596,33 @@ async function handleReportSubmit() {
</script>
<template>
<div class="h-full flex flex-col bg-white">
<!-- 顶部留白 -->
<div class="flex-1 flex flex-col items-center justify-center px-6">
<div class="h-full flex flex-col overflow-y-auto">
<!-- 顶部留白(背景光斑来自 #app,见 globals.css) -->
<div class="flex-1 flex flex-col items-center justify-center px-5 py-8">
<!-- Logo / 标题 -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
<GraduationCap class="w-8 h-8 text-white" />
<div class="text-center mb-7 rise-in">
<div class="relative w-[72px] h-[72px] rounded-[22px] bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
<div class="absolute inset-0 rounded-[22px] bg-gradient-to-b from-white/30 to-transparent" />
<GraduationCap class="relative w-9 h-9 text-white" />
</div>
<h1 class="text-xl font-bold text-gray-900">小助手</h1>
<p class="text-sm text-gray-400 mt-1">你的温馨小助手</p>
<h1 class="text-[26px] font-bold tracking-tight text-gray-900">小助手</h1>
<p class="text-sm text-gray-500 mt-1">你的温馨小助手 · 打卡请假更轻松</p>
</div>
<!-- 表单 -->
<div class="w-full max-w-sm space-y-4">
<!-- 登录方式切换 -->
<div class="flex gap-2 p-1 bg-gray-100 rounded-xl">
<!-- 表单(白卡片浮于光斑背景之上) -->
<div class="w-full max-w-sm space-y-4 bg-white/90 dark:bg-gray-50/90 rounded-3xl border border-white/70 dark:border-gray-200 shadow-xl p-5 rise-in" style="animation-delay: 60ms">
<!-- 登录方式切换(分段控件) -->
<div class="flex gap-1 p-1 bg-gray-100 rounded-full">
<button
class="flex-1 h-9 rounded-lg text-sm font-medium transition-colors"
:class="loginMode === 'school' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
class="flex-1 h-9 rounded-full text-sm font-semibold transition-all duration-300"
:class="loginMode === 'school' ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
@click="switchLoginMode('school')"
>
学工号登录
</button>
<button
class="flex-1 h-9 rounded-lg text-sm font-medium transition-colors"
:class="loginMode === 'sms' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
class="flex-1 h-9 rounded-full text-sm font-semibold transition-all duration-300"
:class="loginMode === 'sms' ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
@click="switchLoginMode('sms')"
>
手机号登录
@@ -634,7 +635,7 @@ async function handleReportSubmit() {
<Label class="mb-1.5 block text-xs font-medium text-gray-500">手机号</Label>
<div class="relative">
<Smartphone class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input v-model="smsPhone" placeholder="请输入手机号" class="pl-9 h-10 text-sm" />
<Input v-model="smsPhone" placeholder="请输入手机号" class="pl-9 h-11 text-sm" />
</div>
</div>
@@ -642,9 +643,9 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
<div class="flex gap-2">
<Input v-model="smsSendCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-10 text-sm" />
<Input v-model="smsSendCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-11 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadSmsCaptcha('send')"
>
<img
@@ -654,7 +655,7 @@ async function handleReportSubmit() {
class="w-full h-full object-cover"
/>
<div v-else-if="!smsSendCaptchaLoading && smsSendCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ smsSendCaptchaError }}</span>
<span class="text-xs leading-tight text-red-400 text-center">{{ smsSendCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="smsSendCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
@@ -667,10 +668,10 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">短信验证码</Label>
<div class="flex gap-2">
<Input v-model="smsCode" placeholder="请输入短信验证码" class="flex-1 h-10 text-sm" />
<Input v-model="smsCode" placeholder="请输入短信验证码" class="flex-1 h-11 text-sm" />
<Button
variant="outline"
class="flex-shrink-0 h-10 px-3 text-sm"
class="flex-shrink-0 h-11 px-4 text-sm"
:disabled="smsSending || smsCountdown > 0"
@click="handleSendSmsCode"
>
@@ -685,9 +686,9 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
<div class="flex gap-2">
<Input v-model="smsLoginCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-10 text-sm" />
<Input v-model="smsLoginCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-11 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadSmsCaptcha('login')"
>
<img
@@ -697,7 +698,7 @@ async function handleReportSubmit() {
class="w-full h-full object-cover"
/>
<div v-else-if="!smsLoginCaptchaLoading && smsLoginCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ smsLoginCaptchaError }}</span>
<span class="text-xs leading-tight text-red-400 text-center">{{ smsLoginCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="smsLoginCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
@@ -729,7 +730,7 @@ async function handleReportSubmit() {
<Input
v-model="schoolKeyword"
:placeholder="selectedSchool ? selectedSchool.name : '搜索学校...'"
class="pl-9 h-10 text-sm pr-9"
class="pl-9 h-11 text-sm pr-9"
@focus="showSchoolList = schools.length > 0"
@blur="handleBlur"
/>
@@ -761,7 +762,7 @@ async function handleReportSubmit() {
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学工号</Label>
<div class="relative">
<User class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input v-model="username" placeholder="请输入学工号" class="pl-9 h-10 text-sm" />
<Input v-model="username" placeholder="请输入学工号" class="pl-9 h-11 text-sm" />
</div>
</div>
@@ -774,7 +775,7 @@ async function handleReportSubmit() {
:type="showPassword ? 'text' : 'password'"
v-model="password"
placeholder="请输入密码"
class="pl-9 pr-9 h-10 text-sm"
class="pl-9 pr-9 h-11 text-sm"
/>
<button
type="button"
@@ -791,9 +792,9 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
<div class="flex gap-2">
<Input v-model="captchaCode" placeholder="输入验证码" class="flex-1 h-10 text-sm" />
<Input v-model="captchaCode" placeholder="输入验证码" class="flex-1 h-11 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadCaptcha"
>
<img
@@ -803,7 +804,7 @@ async function handleReportSubmit() {
class="w-full h-full object-cover"
/>
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
<span class="text-xs leading-tight text-red-400 text-center">{{ captchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="loadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
@@ -817,7 +818,7 @@ async function handleReportSubmit() {
<!-- 登录按钮 -->
<Button
class="w-full h-11 mt-2 text-sm font-medium"
class="w-full h-12 mt-2 text-[15px] bg-brand-gradient"
:disabled="logging"
@click="loginMode === 'sms' ? handleSmsLogin() : handleLogin()"
>
@@ -830,7 +831,7 @@ async function handleReportSubmit() {
<!-- 底部版本信息 -->
<div class="text-center pb-5">
<p class="text-xs text-gray-300">copyright © 2026</p>
<p class="text-xs text-gray-400">copyright © 2026</p>
</div>
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
@@ -845,7 +846,7 @@ async function handleReportSubmit() {
@update:open="(v: boolean) => { if (!v) closeMFADialog() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-3">
@@ -854,7 +855,7 @@ async function handleReportSubmit() {
</div>
<div>
<h3 class="text-base font-bold text-gray-900">多因子认证</h3>
<p class="text-[10px] text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
<p class="text-xs text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
</div>
</div>
@@ -867,7 +868,7 @@ async function handleReportSubmit() {
{{ mfaMessage || '请选择验证方式获取验证码' }}
</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
<p class="text-xs text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
</div>
<!-- 验证方式列表 -->
@@ -908,7 +909,7 @@ async function handleReportSubmit() {
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
<p class="text-xs text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
</div>
<!-- 验证码输入 -->
@@ -919,11 +920,11 @@ async function handleReportSubmit() {
<Input
v-model="mfaCode"
placeholder="请输入收到的验证码"
class="pl-9 pr-9 h-10 text-sm tracking-widest"
class="pl-9 pr-9 h-11 text-sm tracking-widest"
maxlength="8"
@keyup.enter="handleMFAVerify"
/>
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-300 tabular-nums">
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
</span>
</div>
@@ -1046,7 +1047,7 @@ async function handleReportSubmit() {
</div>
<div>
<h3 class="text-base font-bold text-gray-900">提交工单</h3>
<p class="text-[10px] text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
<p class="text-xs text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
</div>
</div>
@@ -1081,12 +1082,12 @@ async function handleReportSubmit() {
<Label class="mb-1 block text-xs font-medium text-gray-500">
联系邮箱 <span class="text-red-400">*</span>
</Label>
<p class="text-[10px] text-gray-400 mb-1">适配完成后将通过该邮箱联系您,仅支持国内知名邮箱</p>
<p class="text-xs text-gray-400 mb-1">适配完成后将通过该邮箱联系您,仅支持国内知名邮箱</p>
<div class="flex gap-2">
<Input
v-model="reportEmailPrefix"
placeholder="邮箱前缀"
class="flex-1 h-10 text-sm"
class="flex-1 h-11 text-sm"
/>
<select
v-model="reportEmailDomain"
@@ -1101,9 +1102,9 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">验证码</Label>
<div class="flex gap-2">
<Input v-model="reportCaptchaCode" placeholder="输入验证码" class="flex-1 h-10 text-sm" />
<Input v-model="reportCaptchaCode" placeholder="输入验证码" class="flex-1 h-11 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadReportCaptcha"
>
<img
@@ -1113,7 +1114,7 @@ async function handleReportSubmit() {
class="w-full h-full object-cover"
/>
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
<span class="text-xs leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="reportLoadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
+52 -14
View File
@@ -1,7 +1,8 @@
<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 } from 'lucide-vue-next'
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert, SunMoon, Sun, Moon } from 'lucide-vue-next'
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
@@ -10,6 +11,13 @@ import * as authApi from '@/api/auth'
import * as ticketApi from '@/api/ticket'
import type { UserInfo, MyInviteInfo } from '@/api/auth'
// 外观:自动(按时间 18:00–06:00 暗色)/ 浅色 / 深色
const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: 'auto', label: '自动', icon: SunMoon },
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
]
const router = useRouter()
const message = useMessage()
const userInfo = ref<UserInfo | null>(null)
@@ -357,20 +365,23 @@ async function handleRedeemInvite() {
<template>
<div class="flex flex-col min-h-full">
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 pt-10 pb-14 text-white text-center flex-shrink-0">
<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">
<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="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
<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>
<!-- VIP 状态行:到期时间 + 开通/续费按钮 -->
<div class="mt-3 flex items-center justify-center gap-2">
<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"
@@ -389,8 +400,8 @@ async function handleRedeemInvite() {
</div>
<!-- 详情列表 -->
<div class="px-4 -mt-8 flex-1 flex flex-col space-y-3 pb-4">
<div class="bg-white rounded-xl border border-gray-100 divide-y divide-gray-50 shadow-sm">
<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">
@@ -441,7 +452,7 @@ async function handleRedeemInvite() {
<template v-else>生成邀请码</template>
</button>
</div>
<p class="text-[10px] text-gray-400 mt-1 leading-relaxed">
<p class="text-xs text-gray-400 mt-1 leading-relaxed">
邀请好友即可获得
<span class="text-amber-500 font-medium">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
积分
@@ -486,7 +497,7 @@ async function handleRedeemInvite() {
<MessageSquare class="w-4 h-4 text-rose-500" />
<span
v-if="ticketUnreadCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none"
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>
@@ -533,18 +544,45 @@ async function handleRedeemInvite() {
</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>
</div>
<div class="flex p-1 bg-gray-100 rounded-full" role="radiogroup" aria-label="外观">
<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'"
@click="setThemeMode(opt.value)"
>
<component :is="opt.icon" class="w-3.5 h-3.5" />
{{ opt.label }}
</button>
</div>
</div>
<!-- 退出登录(底部,导航栏上方) -->
<div class="flex-1" />
<Button
variant="outline"
class="w-full h-11 border-red-200 text-red-500 hover:bg-red-50 text-sm"
class="w-full h-12 border-red-200 bg-white text-red-500 hover:bg-red-50 text-sm"
@click="handleLogout"
>
<LogOut class="w-4 h-4" />
退出登录
</Button>
<p class="text-center text-xs text-gray-300 pb-2">copyright © 2026</p>
<p class="text-center text-xs text-gray-400 pb-2">copyright © 2026</p>
</div>
<!-- 兑换弹窗遮罩 -->
@@ -611,7 +649,7 @@ async function handleRedeemInvite() {
/>
<div
v-else-if="captchaError"
class="h-10 w-[120px] rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
class="h-10 w-[120px] rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-xs leading-tight text-red-400 text-center px-1 cursor-pointer"
title="点击重试"
@click="refreshCaptcha"
>
@@ -663,7 +701,7 @@ async function handleRedeemInvite() {
@input="fillInviteError = ''"
/>
<p v-if="fillInviteError" class="text-xs text-red-500 mt-1">{{ fillInviteError }}</p>
<p class="text-[10px] text-gray-400 mt-1">填写后你与好友都将获得奖励积分(每个账号仅可填写一次)</p>
<p class="text-xs text-gray-400 mt-1">填写后你与好友都将获得奖励积分(每个账号仅可填写一次)</p>
</div>
<Button class="w-full h-10 text-sm font-medium" :disabled="bindingInvite" @click="submitFillInvite">
{{ bindingInvite ? '提交中...' : '提交' }}
+2 -2
View File
@@ -64,9 +64,9 @@ async function handleReply() {
</script>
<template>
<div class="flex flex-col min-h-full bg-gray-50">
<div class="flex flex-col min-h-full">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<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="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" />
+5 -5
View File
@@ -111,9 +111,9 @@ async function handleDelete(t: TicketListItem, e: Event) {
</script>
<template>
<div class="flex flex-col min-h-full bg-gray-50">
<div class="flex flex-col min-h-full">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<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="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" />
@@ -124,12 +124,12 @@ async function handleDelete(t: TicketListItem, e: Event) {
<!-- 新建按钮 -->
<div class="px-4 py-3">
<Button class="w-full h-10 text-sm font-medium" @click="openCreate">
<Button class="w-full h-12 text-[15px] bg-brand-gradient" @click="openCreate">
<Plus class="w-4 h-4 mr-1" />
新建工单
</Button>
<!-- 提示 -->
<p class="text-center text-[10px] text-gray-400 mt-2">您最多可同时拥有 2 个未关闭的工单</p>
<p class="text-center text-xs text-gray-400 mt-2">您最多可同时拥有 2 个未关闭的工单</p>
</div>
<!-- 工单列表 -->
@@ -143,7 +143,7 @@ async function handleDelete(t: TicketListItem, e: Event) {
<div
v-for="t in tickets"
:key="t.id"
class="bg-white rounded-xl border border-gray-100 px-4 py-3.5 active:bg-gray-50 transition-colors cursor-pointer"
class="press bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 py-3.5 active:bg-gray-50 cursor-pointer"
@click="goDetail(t.id)"
>
<div class="flex items-center justify-between mb-1">