mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
feat(new-frontend): align visual experience
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
CheckSquare,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
@@ -38,6 +39,9 @@ const adminLinks = [
|
||||
]
|
||||
|
||||
const title = computed(() => router.current.value.title)
|
||||
const isAdminRoute = computed(() => router.state.path.startsWith('/admin'))
|
||||
const approvalLabel = computed(() => (authState.user?.is_approved ? '已审批' : '待审批'))
|
||||
const roleLabel = computed(() => (isAdmin.value ? '管理员' : '普通用户'))
|
||||
|
||||
function go(path: string) {
|
||||
mobileOpen.value = false
|
||||
@@ -57,26 +61,33 @@ function signOut() {
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-9 items-center justify-center rounded-md border border-zinc-200 text-zinc-700 lg:hidden"
|
||||
class="inline-flex size-9 items-center justify-center rounded-md border border-zinc-200 bg-white text-zinc-700 shadow-sm lg:hidden"
|
||||
@click="mobileOpen = !mobileOpen"
|
||||
>
|
||||
<X v-if="mobileOpen" class="size-4" />
|
||||
<Menu v-else class="size-4" />
|
||||
</button>
|
||||
<button class="text-left" type="button" @click="go('/dashboard')">
|
||||
<div class="text-sm font-semibold leading-4">接龙自动打卡</div>
|
||||
<div class="text-xs text-zinc-500">CheckIn workspace</div>
|
||||
<button class="flex items-center gap-3 text-left" type="button" @click="go('/dashboard')">
|
||||
<span
|
||||
class="hidden size-9 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm sm:inline-flex"
|
||||
>
|
||||
<CheckCircle2 class="size-5" />
|
||||
</span>
|
||||
<span>
|
||||
<div class="text-sm font-semibold leading-4">接龙自动打卡</div>
|
||||
<div class="text-xs text-zinc-500">CheckIn workspace</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="hidden text-right sm:block">
|
||||
<div class="text-sm font-medium">{{ authState.user?.alias ?? '未登录' }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ isAdmin ? '管理员' : '普通用户' }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ roleLabel }} · {{ approvalLabel }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-50"
|
||||
class="inline-flex min-h-9 items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:translate-y-px"
|
||||
@click="signOut"
|
||||
>
|
||||
<LogOut class="size-4" />
|
||||
@@ -88,18 +99,25 @@ function signOut() {
|
||||
|
||||
<div class="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<aside
|
||||
class="border-b border-zinc-200 bg-white px-4 py-3 lg:min-h-[calc(100dvh-3.5rem)] lg:border-b-0 lg:border-r"
|
||||
class="border-b border-zinc-200 bg-white px-4 py-3 shadow-sm lg:min-h-[calc(100dvh-3.5rem)] lg:border-b-0 lg:border-r lg:shadow-none"
|
||||
:class="mobileOpen ? 'block' : 'hidden lg:block'"
|
||||
>
|
||||
<div class="mb-4 rounded-lg border border-zinc-200 bg-zinc-50 p-3 lg:hidden">
|
||||
<div class="text-sm font-semibold">{{ authState.user?.alias ?? '未登录' }}</div>
|
||||
<div class="mt-1 text-xs text-zinc-500">{{ roleLabel }} · {{ approvalLabel }}</div>
|
||||
</div>
|
||||
<div class="mb-2 px-3 text-xs font-semibold uppercase tracking-normal text-zinc-500">
|
||||
工作台
|
||||
</div>
|
||||
<nav class="grid gap-1">
|
||||
<button
|
||||
v-for="link in userLinks"
|
||||
:key="link.path"
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-zinc-100"
|
||||
class="flex min-h-9 items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium transition hover:bg-emerald-50 hover:text-emerald-900"
|
||||
:class="
|
||||
router.state.path === link.path
|
||||
? 'bg-zinc-900 text-white hover:bg-zinc-900'
|
||||
? 'bg-emerald-700 text-white shadow-sm hover:bg-emerald-700 hover:text-white'
|
||||
: 'text-zinc-700'
|
||||
"
|
||||
@click="go(link.path)"
|
||||
@@ -110,18 +128,21 @@ function signOut() {
|
||||
</nav>
|
||||
|
||||
<div v-if="isAdmin" class="mt-5 border-t border-zinc-200 pt-4">
|
||||
<div class="mb-2 px-3 text-xs font-semibold uppercase tracking-normal text-zinc-500">
|
||||
管理员
|
||||
<div
|
||||
class="mb-2 flex items-center justify-between rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-xs font-semibold text-sky-800"
|
||||
>
|
||||
<span>管理员工作区</span>
|
||||
<Shield class="size-3.5" />
|
||||
</div>
|
||||
<nav class="grid gap-1">
|
||||
<button
|
||||
v-for="link in adminLinks"
|
||||
:key="link.path"
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-zinc-100"
|
||||
class="flex min-h-9 items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium transition hover:bg-sky-50 hover:text-sky-900"
|
||||
:class="
|
||||
router.state.path === link.path
|
||||
? 'bg-zinc-900 text-white hover:bg-zinc-900'
|
||||
? 'bg-sky-700 text-white shadow-sm hover:bg-sky-700 hover:text-white'
|
||||
: 'text-zinc-700'
|
||||
"
|
||||
@click="go(link.path)"
|
||||
@@ -134,16 +155,37 @@ function signOut() {
|
||||
</aside>
|
||||
|
||||
<main class="min-w-0 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div
|
||||
class="mb-5 flex flex-wrap items-end justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm"
|
||||
:class="{ 'border-sky-200 bg-sky-50/70': isAdminRoute }"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
v-if="isAdminRoute"
|
||||
class="mb-1 inline-flex items-center gap-1 rounded-full border border-sky-200 bg-white px-2 py-0.5 text-xs font-medium text-sky-700"
|
||||
>
|
||||
<Shield class="size-3" />
|
||||
管理员
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold tracking-normal text-zinc-950">{{ title }}</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">管理打卡任务、授权状态和系统记录。</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{{
|
||||
isAdminRoute
|
||||
? '管理用户、模板、记录、日志和系统统计。'
|
||||
: '管理打卡任务、授权状态和系统记录。'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs text-zinc-600"
|
||||
class="inline-flex items-center gap-2 rounded-full border bg-white px-3 py-1 text-xs font-medium"
|
||||
:class="
|
||||
authState.user?.is_approved
|
||||
? 'border-emerald-200 text-emerald-700'
|
||||
: 'border-amber-200 text-amber-700'
|
||||
"
|
||||
>
|
||||
<UserRound class="size-3.5" />
|
||||
{{ authState.user?.is_approved ? '已审批' : '待审批' }}
|
||||
{{ approvalLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
|
||||
@@ -14,9 +14,13 @@ defineEmits<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-dashed border-zinc-200 bg-white p-6 text-center">
|
||||
<div class="rounded-lg border border-dashed border-zinc-200 bg-white p-6 text-center shadow-sm">
|
||||
<div
|
||||
class="mx-auto mb-3 flex size-10 items-center justify-center rounded-md bg-zinc-100 text-zinc-600"
|
||||
class="mx-auto mb-3 flex size-10 items-center justify-center rounded-md border border-zinc-200 bg-zinc-50 text-zinc-600"
|
||||
:class="{
|
||||
'border-rose-200 bg-rose-50 text-rose-700': type === 'error',
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-700': type === 'loading',
|
||||
}"
|
||||
>
|
||||
<Loader2 v-if="type === 'loading'" class="size-5 animate-spin" />
|
||||
<AlertCircle v-else-if="type === 'error'" class="size-5" />
|
||||
@@ -27,7 +31,7 @@ defineEmits<{
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
type="button"
|
||||
class="mt-4 inline-flex items-center rounded-md border border-zinc-200 px-3 py-2 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50 active:translate-y-px"
|
||||
class="mt-4 inline-flex min-h-9 items-center rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
|
||||
|
||||
export const buttonBase =
|
||||
'inline-flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50'
|
||||
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium shadow-sm transition duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
export const buttonTone = {
|
||||
primary: 'border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800',
|
||||
secondary: 'border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50',
|
||||
ghost: 'border-transparent bg-transparent text-zinc-700 hover:bg-zinc-100',
|
||||
danger: 'border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100',
|
||||
primary: 'border-emerald-700 bg-emerald-700 text-white hover:bg-emerald-800',
|
||||
secondary: 'border-zinc-200 bg-white text-zinc-900 hover:border-zinc-300 hover:bg-zinc-50',
|
||||
ghost: 'border-transparent bg-transparent text-zinc-700 shadow-none hover:bg-zinc-100',
|
||||
danger: 'border-rose-200 bg-rose-50 text-rose-700 hover:border-rose-300 hover:bg-rose-100',
|
||||
admin: 'border-sky-700 bg-sky-700 text-white hover:bg-sky-800',
|
||||
}
|
||||
|
||||
export const inputClass =
|
||||
'w-full rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-zinc-900 focus:ring-2 focus:ring-zinc-900/10'
|
||||
'w-full min-h-9 rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-emerald-700 focus:ring-2 focus:ring-emerald-700/10 disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500'
|
||||
|
||||
export const textareaClass = `${inputClass} min-h-24 resize-y font-mono text-xs leading-5`
|
||||
|
||||
export const cardClass = 'rounded-lg border border-zinc-200 bg-white shadow-sm'
|
||||
export const cardClass =
|
||||
'rounded-lg border border-zinc-200/80 bg-white shadow-[0_14px_32px_-28px_rgba(24,24,27,0.45)]'
|
||||
|
||||
export const sectionHeaderClass =
|
||||
'flex flex-wrap items-start justify-between gap-3 border-b border-zinc-200 bg-zinc-50/70 px-4 py-3'
|
||||
|
||||
export const actionBarClass =
|
||||
'flex flex-wrap items-center gap-2 border-b border-zinc-200 bg-zinc-50/70 p-4'
|
||||
|
||||
export const labelClass = 'text-xs font-semibold uppercase tracking-normal text-zinc-500'
|
||||
|
||||
export const mutedText = 'text-sm text-zinc-500'
|
||||
|
||||
export const alertClass = {
|
||||
info: 'rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-sm text-sky-800',
|
||||
success: 'rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800',
|
||||
warning: 'rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800',
|
||||
danger: 'rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800',
|
||||
}
|
||||
|
||||
export function toneClass(tone: Tone) {
|
||||
const tones: Record<Tone, string> = {
|
||||
neutral: 'border-zinc-200 bg-zinc-50 text-zinc-700',
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
warning: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
warning: 'border-amber-200 bg-amber-50 text-amber-800',
|
||||
danger: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
info: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-sans-app);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
@@ -45,25 +46,28 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans-app:
|
||||
'Noto Sans', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', sans-serif;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--background: oklch(0.985 0.004 145);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--primary: oklch(0.49 0.14 150);
|
||||
--primary-foreground: oklch(0.99 0.006 145);
|
||||
--secondary: oklch(0.965 0.015 150);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--accent: oklch(0.94 0.045 150);
|
||||
--accent-foreground: oklch(0.23 0.07 150);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--ring: oklch(0.58 0.12 150);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
@@ -71,10 +75,10 @@
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary: oklch(0.49 0.14 150);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.006 145);
|
||||
--sidebar-accent: oklch(0.94 0.045 150);
|
||||
--sidebar-accent-foreground: oklch(0.23 0.07 150);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
@@ -117,7 +121,19 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: var(--font-sans-app);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, CheckCircle2, Clock, KeyRound } from 'lucide-vue-next'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
KeyRound,
|
||||
QrCode,
|
||||
UserRound,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
checkInApi,
|
||||
taskApi,
|
||||
userApi,
|
||||
type CheckInRecord,
|
||||
type CheckInRecordStatus,
|
||||
type Task,
|
||||
type TokenStatus,
|
||||
} from '@/api'
|
||||
import { useAuth } from '@/app/auth'
|
||||
import { useRouter } from '@/app/router'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import { cardClass, toneClass } from '@/components/ui'
|
||||
import {
|
||||
alertClass,
|
||||
buttonBase,
|
||||
buttonTone,
|
||||
cardClass,
|
||||
inputClass,
|
||||
sectionHeaderClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
cronLabel,
|
||||
extractErrorMessage,
|
||||
@@ -21,16 +40,45 @@ import {
|
||||
} from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuth()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const message = ref('')
|
||||
const tasks = ref<Task[]>([])
|
||||
const records = ref<CheckInRecord[]>([])
|
||||
const tokenStatus = ref<TokenStatus | null>(null)
|
||||
const selectedTaskId = ref<number | null>(null)
|
||||
const checkInLoading = ref(false)
|
||||
const latestStatus = ref<CheckInRecordStatus | null>(null)
|
||||
let pollTimer: number | undefined
|
||||
|
||||
const activeTasks = computed(() => tasks.value.filter((task) => task.is_active).length)
|
||||
const inactiveTasks = computed(() => Math.max(0, tasks.value.length - activeTasks.value))
|
||||
const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
|
||||
const lastRecord = computed(() => records.value[0] ?? null)
|
||||
const successToday = computed(
|
||||
() => records.value.filter((record) => record.status === 'success').length,
|
||||
)
|
||||
const tokenTone = computed(() =>
|
||||
tokenStatus.value?.is_valid
|
||||
? tokenStatus.value.expiring_soon
|
||||
? 'warning'
|
||||
: 'success'
|
||||
: 'danger',
|
||||
)
|
||||
const tokenLabel = computed(() => {
|
||||
if (!tokenStatus.value) return '未知'
|
||||
if (!tokenStatus.value.is_valid) return '无效'
|
||||
return tokenStatus.value.expiring_soon ? '即将过期' : '有效'
|
||||
})
|
||||
const tokenDetail = computed(() => {
|
||||
if (!tokenStatus.value) return '未获取到业务 Token 状态。'
|
||||
if (!tokenStatus.value.is_valid) return '打卡凭证已过期,无法自动打卡。请使用扫码登录刷新授权。'
|
||||
if (tokenStatus.value.expiring_soon) return 'Token 即将过期,建议准备刷新授权。'
|
||||
return `业务 Token 正常,${tokenStatus.value.days_until_expiry ?? '未知'} 天后过期。`
|
||||
})
|
||||
const needsEmail = computed(() => !auth.state.user?.email)
|
||||
const needsPassword = computed(() => auth.state.user?.has_password === false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -44,6 +92,9 @@ async function load() {
|
||||
tasks.value = taskList
|
||||
tokenStatus.value = token
|
||||
records.value = recordPage.records
|
||||
if (!selectedTaskId.value || !taskList.some((task) => task.id === selectedTaskId.value)) {
|
||||
selectedTaskId.value = taskList.find((task) => task.is_active)?.id ?? taskList[0]?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = extractErrorMessage(err)
|
||||
} finally {
|
||||
@@ -51,13 +102,47 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function manualCheckIn() {
|
||||
if (!selectedTaskId.value) return
|
||||
checkInLoading.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
latestStatus.value = null
|
||||
try {
|
||||
const result = await checkInApi.manual(selectedTaskId.value)
|
||||
const recordId = result.record_id ?? result.id
|
||||
message.value = result.message || '已启动打卡任务'
|
||||
if (recordId) startRecordPolling(recordId)
|
||||
} catch (err) {
|
||||
error.value = extractErrorMessage(err)
|
||||
} finally {
|
||||
checkInLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startRecordPolling(recordId: number) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const status = await checkInApi.status(recordId)
|
||||
latestStatus.value = status
|
||||
if (!['pending', 'running'].includes(status.status)) {
|
||||
window.clearInterval(pollTimer)
|
||||
await load()
|
||||
}
|
||||
} catch {
|
||||
window.clearInterval(pollTimer)
|
||||
}
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateBlock v-if="loading" title="正在加载仪表盘" type="loading" />
|
||||
<StateBlock
|
||||
v-else-if="error"
|
||||
v-else-if="error && tasks.length === 0"
|
||||
title="仪表盘加载失败"
|
||||
:description="error"
|
||||
type="error"
|
||||
@@ -65,117 +150,300 @@ onMounted(load)
|
||||
@action="load"
|
||||
/>
|
||||
<div v-else class="grid gap-5">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-zinc-500">任务总数</span>
|
||||
<CheckCircle2 class="size-4 text-emerald-600" />
|
||||
</div>
|
||||
<div class="mt-3 text-3xl font-semibold">{{ tasks.length }}</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">{{ activeTasks }} 个启用</p>
|
||||
<div class="grid gap-3">
|
||||
<div v-if="needsEmail" :class="alertClass.info">
|
||||
您还未设置邮箱地址,设置后可以接收打卡任务通知。
|
||||
<button
|
||||
class="ml-2 font-semibold hover:text-sky-950"
|
||||
type="button"
|
||||
@click="router.navigate('/settings')"
|
||||
>
|
||||
立即前往设置
|
||||
</button>
|
||||
</div>
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-zinc-500">打卡 Token</span>
|
||||
<KeyRound class="size-4 text-sky-600" />
|
||||
</div>
|
||||
<div class="mt-3 text-lg font-semibold">
|
||||
{{ tokenStatus?.is_valid ? '可用' : '需要更新' }}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{{
|
||||
tokenStatus?.days_until_expiry == null
|
||||
? '未获取到过期信息'
|
||||
: `${tokenStatus.days_until_expiry} 天后过期`
|
||||
}}
|
||||
</p>
|
||||
<div v-if="needsPassword" :class="alertClass.info">
|
||||
您还未设置登录密码,设置后可以使用用户名和密码快速登录。
|
||||
<button
|
||||
class="ml-2 font-semibold hover:text-sky-950"
|
||||
type="button"
|
||||
@click="router.navigate('/settings')"
|
||||
>
|
||||
立即前往设置
|
||||
</button>
|
||||
</div>
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-zinc-500">最近成功</span>
|
||||
<Activity class="size-4 text-zinc-700" />
|
||||
<div
|
||||
v-if="tokenStatus && !tokenStatus.is_valid"
|
||||
:class="[alertClass.warning, 'flex items-start gap-2']"
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
打卡凭证已过期,无法自动打卡。请回到登录页使用扫码登录刷新 Token。
|
||||
<button
|
||||
class="ml-2 font-semibold hover:text-amber-950"
|
||||
type="button"
|
||||
@click="router.navigate('/login')"
|
||||
>
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 text-3xl font-semibold">{{ successToday }}</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">最近记录中的成功数</p>
|
||||
</div>
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-zinc-500">下次定时</span>
|
||||
<Clock class="size-4 text-amber-600" />
|
||||
</div>
|
||||
<div class="mt-3 text-lg font-semibold">
|
||||
{{ cronLabel(tasks.find((task) => task.is_active)?.cron_expression) }}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">来自首个启用任务</p>
|
||||
<div v-if="tasks.length === 0" :class="alertClass.info">
|
||||
您还没有打卡任务。
|
||||
<button
|
||||
class="ml-2 font-semibold hover:text-sky-950"
|
||||
type="button"
|
||||
@click="router.navigate('/tasks')"
|
||||
>
|
||||
立即创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
|
||||
<h2 class="font-semibold">任务概览</h2>
|
||||
<button
|
||||
class="text-sm font-medium text-zinc-700 hover:text-zinc-950"
|
||||
type="button"
|
||||
@click="router.navigate('/tasks')"
|
||||
>
|
||||
管理任务
|
||||
</button>
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyRound class="size-4 text-emerald-700" />
|
||||
<h2 class="font-semibold">Token 状态</h2>
|
||||
</div>
|
||||
<StateBlock
|
||||
v-if="tasks.length === 0"
|
||||
title="暂无任务"
|
||||
description="从模板创建任务后,这里会显示任务状态。"
|
||||
action-label="去创建"
|
||||
@action="router.navigate('/tasks')"
|
||||
/>
|
||||
<div v-else class="divide-y divide-zinc-200">
|
||||
<span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
|
||||
</div>
|
||||
<div class="grid gap-4 p-5 md:grid-cols-2">
|
||||
<div class="grid gap-3 text-sm">
|
||||
<div
|
||||
v-for="task in tasks.slice(0, 6)"
|
||||
:key="task.id"
|
||||
class="grid gap-2 px-4 py-3 sm:grid-cols-[1fr_auto] sm:items-center"
|
||||
class="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium">{{ task.name || `任务 #${task.id}` }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">
|
||||
ThreadId: {{ task.thread_id || '未解析' }} · {{ cronLabel(task.cron_expression) }}
|
||||
</div>
|
||||
</div>
|
||||
<span :class="toneClass(task.is_active ? 'success' : 'neutral')">{{
|
||||
task.is_active ? '启用' : '停用'
|
||||
<span class="text-zinc-500">Token 状态</span>
|
||||
<span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2"
|
||||
>
|
||||
<span class="text-zinc-500">剩余时间</span>
|
||||
<span>{{
|
||||
tokenStatus?.days_until_expiry == null
|
||||
? '未知'
|
||||
: `${tokenStatus.days_until_expiry} 天`
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
|
||||
<h2 class="font-semibold">最近记录</h2>
|
||||
<button
|
||||
class="text-sm font-medium text-zinc-700 hover:text-zinc-950"
|
||||
type="button"
|
||||
@click="router.navigate('/records')"
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<StateBlock
|
||||
v-if="records.length === 0"
|
||||
title="暂无记录"
|
||||
description="手动或定时打卡后会生成记录。"
|
||||
/>
|
||||
<div v-else class="divide-y divide-zinc-200">
|
||||
<div v-for="record in records" :key="record.id" class="px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-medium">{{ record.task_name || `任务 #${record.task_id}` }}</span>
|
||||
<span :class="toneClass(statusTone(record.status))">{{
|
||||
statusLabel(record.status)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">{{ formatDateTime(record.check_in_time) }}</div>
|
||||
<span class="text-zinc-500">即将过期</span>
|
||||
<span>{{ tokenStatus?.expiring_soon ? '是' : '否' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-600">
|
||||
<p>{{ tokenDetail }}</p>
|
||||
<button
|
||||
:class="[
|
||||
buttonBase,
|
||||
tokenStatus?.is_valid ? buttonTone.secondary : buttonTone.primary,
|
||||
'mt-4',
|
||||
]"
|
||||
type="button"
|
||||
@click="router.navigate('/login')"
|
||||
>
|
||||
<QrCode class="size-4" />
|
||||
扫码刷新 Token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div class="flex items-center gap-2">
|
||||
<CalendarDays class="size-4 text-emerald-700" />
|
||||
<h2 class="font-semibold">手动打卡</h2>
|
||||
</div>
|
||||
<span class="text-sm text-zinc-500">{{ activeTasks }} 个启用任务</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-sm text-zinc-500">选择任务并点击下方按钮立即执行打卡操作。</p>
|
||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<select v-model.number="selectedTaskId" :class="[inputClass, 'sm:max-w-md']">
|
||||
<option v-for="task in tasks" :key="task.id" :value="task.id">
|
||||
{{ task.name || `任务 #${task.id}` }} · {{ task.is_active ? '启用' : '停用' }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.primary]"
|
||||
:disabled="!selectedTaskId || checkInLoading"
|
||||
type="button"
|
||||
@click="manualCheckIn"
|
||||
>
|
||||
<CalendarDays class="size-4" />
|
||||
{{ checkInLoading ? '打卡中' : '立即打卡' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTask"
|
||||
class="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm"
|
||||
>
|
||||
<div class="font-medium text-zinc-900">
|
||||
{{ selectedTask.name || `任务 #${selectedTask.id}` }}
|
||||
</div>
|
||||
<div class="mt-1 text-zinc-500">
|
||||
ThreadId: {{ selectedTask.thread_id || '未解析' }} ·
|
||||
{{ cronLabel(selectedTask.cron_expression) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message" :class="[alertClass.success, 'mt-4']">{{ message }}</div>
|
||||
<div v-if="error" :class="[alertClass.danger, 'mt-4']">{{ error }}</div>
|
||||
<div
|
||||
v-if="latestStatus"
|
||||
class="mt-4 rounded-lg border border-zinc-200 bg-white p-4 text-sm"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-semibold text-zinc-900">本次打卡</span>
|
||||
<span :class="toneClass(statusTone(latestStatus.status))">{{
|
||||
statusLabel(latestStatus.status)
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-zinc-500">
|
||||
{{
|
||||
latestStatus.response_text || latestStatus.error_message || '正在等待后端返回结果。'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="lastRecord"
|
||||
class="mt-4 rounded-lg border border-zinc-200 bg-white p-4 text-sm"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-semibold text-zinc-900">上次打卡</span>
|
||||
<span :class="toneClass(statusTone(lastRecord.status))">{{
|
||||
statusLabel(lastRecord.status)
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-zinc-500">
|
||||
{{ formatDateTime(lastRecord.check_in_time) }} ·
|
||||
{{ lastRecord.response_text || lastRecord.error_message || '无响应内容' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserRound class="size-4 text-emerald-700" />
|
||||
<h2 class="font-semibold">个人信息</h2>
|
||||
</div>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@click="router.navigate('/settings')"
|
||||
>
|
||||
个人设置
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-3 p-5 text-sm md:grid-cols-2">
|
||||
<div class="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||||
<div class="text-zinc-500">用户名</div>
|
||||
<div class="mt-1 font-medium text-zinc-900">{{ auth.state.user?.alias || '未登录' }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||||
<div class="text-zinc-500">角色</div>
|
||||
<div class="mt-1">
|
||||
<span :class="toneClass(auth.state.user?.role === 'admin' ? 'danger' : 'info')">
|
||||
{{ auth.state.user?.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||||
<div class="text-zinc-500">邮箱</div>
|
||||
<div class="mt-1 font-medium text-zinc-900">{{ auth.state.user?.email || '未设置' }}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||||
<div class="text-zinc-500">注册时间</div>
|
||||
<div class="mt-1 font-medium text-zinc-900">
|
||||
{{ formatDateTime(auth.state.user?.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">任务概览</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{{ activeTasks }} 个启用,{{ inactiveTasks }} 个停用,最近记录成功
|
||||
{{ successToday }} 条。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@click="router.navigate('/tasks')"
|
||||
>
|
||||
管理任务
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-4 p-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-zinc-500">任务总数</span>
|
||||
<CheckCircle2 class="size-4 text-emerald-600" />
|
||||
</div>
|
||||
<div class="mt-3 text-3xl font-semibold">{{ tasks.length }}</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">{{ activeTasks }} 个启用</p>
|
||||
</div>
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-zinc-500">最近成功</span>
|
||||
<Activity class="size-4 text-zinc-700" />
|
||||
</div>
|
||||
<div class="mt-3 text-3xl font-semibold">{{ successToday }}</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">最近记录中的成功数</p>
|
||||
</div>
|
||||
<div :class="[cardClass, 'p-4 md:col-span-2']">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-zinc-500">下次定时</span>
|
||||
<Clock class="size-4 text-amber-600" />
|
||||
</div>
|
||||
<div class="mt-3 text-lg font-semibold">
|
||||
{{ cronLabel(tasks.find((task) => task.is_active)?.cron_expression) }}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">来自首个启用任务</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">最近记录</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">最近的打卡结果和状态变化会先出现在这里。</p>
|
||||
</div>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@click="router.navigate('/records')"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<StateBlock
|
||||
v-if="records.length === 0"
|
||||
title="暂无记录"
|
||||
description="手动或定时打卡后会生成记录。"
|
||||
/>
|
||||
<div v-else class="divide-y divide-zinc-200">
|
||||
<div v-for="record in records" :key="record.id" class="px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-medium">{{ record.task_name || `任务 #${record.task_id}` }}</span>
|
||||
<span :class="toneClass(statusTone(record.status))">{{
|
||||
statusLabel(record.status)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">
|
||||
{{ formatDateTime(record.check_in_time) }} ·
|
||||
{{ record.trigger_type ? statusLabel(record.trigger_type) : '未注明触发' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound, QrCode, RotateCw } from 'lucide-vue-next'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { Info, KeyRound, QrCode, RotateCw, UserRound } from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { authApi } from '@/api'
|
||||
import { useAuth } from '@/app/auth'
|
||||
import { useRouter } from '@/app/router'
|
||||
import { buttonBase, buttonTone, inputClass } from '@/components/ui'
|
||||
import { alertClass, buttonBase, buttonTone, cardClass, inputClass } from '@/components/ui'
|
||||
import { extractErrorMessage } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -17,14 +17,31 @@ const error = ref('')
|
||||
const info = ref('')
|
||||
const qrImage = ref('')
|
||||
const qrSessionId = ref('')
|
||||
const loginMode = ref<'qrcode' | 'password'>('qrcode')
|
||||
let pollTimer: number | undefined
|
||||
|
||||
const currentSubtitle = computed(() =>
|
||||
loginMode.value === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录',
|
||||
)
|
||||
const canSubmitPassword = computed(
|
||||
() => Boolean(alias.value.trim()) && Boolean(password.value) && !loading.value,
|
||||
)
|
||||
const canRequestQr = computed(() => Boolean(alias.value.trim()) && !loading.value)
|
||||
|
||||
function switchMode(mode: 'qrcode' | 'password') {
|
||||
loginMode.value = mode
|
||||
error.value = ''
|
||||
info.value = ''
|
||||
if (mode === 'password' && qrSessionId.value) void cancelQr()
|
||||
}
|
||||
|
||||
function loginRedirect() {
|
||||
const redirect = router.query.value.get('redirect') || '/dashboard'
|
||||
void auth.refreshCurrentUser().finally(() => router.replace(redirect))
|
||||
}
|
||||
|
||||
async function loginWithPassword() {
|
||||
if (!canSubmitPassword.value) return
|
||||
error.value = ''
|
||||
info.value = ''
|
||||
loading.value = true
|
||||
@@ -40,6 +57,7 @@ async function loginWithPassword() {
|
||||
}
|
||||
|
||||
async function requestQrCode() {
|
||||
if (!canRequestQr.value) return
|
||||
error.value = ''
|
||||
info.value = '正在创建扫码会话'
|
||||
loading.value = true
|
||||
@@ -64,7 +82,7 @@ function startPolling() {
|
||||
if (!qrSessionId.value) return
|
||||
try {
|
||||
const status = await authApi.getQRCodeStatus(qrSessionId.value)
|
||||
if (status.qrcode_image) qrImage.value = status.qrcode_image
|
||||
qrImage.value = status.qrcode_image ?? qrImage.value
|
||||
if (status.status === 'success') {
|
||||
window.clearInterval(pollTimer)
|
||||
auth.applyLogin(status)
|
||||
@@ -96,107 +114,180 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="grid min-h-[100dvh] bg-zinc-50 px-4 py-8 text-zinc-950 lg:grid-cols-[minmax(0,1fr)_440px] lg:p-0"
|
||||
>
|
||||
<section
|
||||
class="hidden border-r border-zinc-200 bg-white p-10 lg:flex lg:flex-col lg:justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-zinc-500">CheckIn App</div>
|
||||
<h1 class="mt-6 max-w-xl text-4xl font-semibold leading-tight tracking-normal">
|
||||
接龙自动打卡系统的新前端工作台
|
||||
</h1>
|
||||
<p class="mt-4 max-w-lg text-base text-zinc-600">
|
||||
使用账号密码或 QQ 扫码登录,管理任务、模板、记录和系统状态。
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3 text-sm">
|
||||
<div class="rounded-lg border border-zinc-200 p-4">
|
||||
<div class="text-2xl font-semibold">1</div>
|
||||
<div class="mt-1 text-zinc-500">用户审批</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 p-4">
|
||||
<div class="text-2xl font-semibold">N</div>
|
||||
<div class="mt-1 text-zinc-500">多任务</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 p-4">
|
||||
<div class="text-2xl font-semibold">24h</div>
|
||||
<div class="mt-1 text-zinc-500">自动调度</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto flex w-full max-w-md flex-col justify-center lg:px-8">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-xl font-semibold">登录</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">输入别名登录;没有或需要更新授权时使用 QQ 扫码。</p>
|
||||
|
||||
<form class="mt-6 grid gap-4" @submit.prevent="loginWithPassword">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">别名</span>
|
||||
<input v-model="alias" :class="inputClass" required placeholder="例如 zhangsan" />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">密码</span>
|
||||
<input
|
||||
v-model="password"
|
||||
:class="inputClass"
|
||||
type="password"
|
||||
placeholder="已设置密码时可用"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<main class="flex min-h-[100dvh] items-center justify-center bg-zinc-50 px-4 py-8 text-zinc-950">
|
||||
<section class="w-full max-w-md">
|
||||
<div :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="border-b border-zinc-200 px-6 py-5 text-center">
|
||||
<div
|
||||
v-if="error"
|
||||
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||
class="mx-auto mb-3 flex size-11 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-if="info"
|
||||
class="rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-sm text-sky-700"
|
||||
>
|
||||
{{ info }}
|
||||
<QrCode class="size-5" />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold tracking-normal text-zinc-950">接龙自动打卡系统</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">{{ currentSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.primary]"
|
||||
:disabled="loading || !alias || !password"
|
||||
type="submit"
|
||||
>
|
||||
<KeyRound class="size-4" />
|
||||
{{ loading ? '处理中' : '密码登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-5 border-t border-zinc-200 pt-5">
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary, 'w-full']"
|
||||
:disabled="loading || !alias"
|
||||
type="button"
|
||||
@click="requestQrCode"
|
||||
>
|
||||
<QrCode class="size-4" />
|
||||
请求 QQ 扫码
|
||||
</button>
|
||||
<div
|
||||
v-if="qrImage"
|
||||
class="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-center"
|
||||
>
|
||||
<img
|
||||
:src="qrImage.startsWith('data:') ? qrImage : `data:image/png;base64,${qrImage}`"
|
||||
alt="QQ 登录二维码"
|
||||
class="mx-auto size-48 rounded-md bg-white object-contain"
|
||||
/>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 rounded-md border border-zinc-200 bg-zinc-50 p-1 text-sm">
|
||||
<button
|
||||
class="mt-3 inline-flex items-center gap-2 text-sm text-zinc-600 hover:text-zinc-900"
|
||||
type="button"
|
||||
class="rounded px-3 py-2 text-center font-medium transition"
|
||||
:class="
|
||||
loginMode === 'qrcode'
|
||||
? 'bg-white text-zinc-900 shadow-sm'
|
||||
: 'text-zinc-500 hover:text-zinc-900'
|
||||
"
|
||||
@click="switchMode('qrcode')"
|
||||
>
|
||||
扫码登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-2 text-center font-medium transition"
|
||||
:class="
|
||||
loginMode === 'password'
|
||||
? 'bg-white text-zinc-900 shadow-sm'
|
||||
: 'text-zinc-500 hover:text-zinc-900'
|
||||
"
|
||||
@click="switchMode('password')"
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="loginMode === 'password'"
|
||||
class="mt-6 grid gap-4"
|
||||
@submit.prevent="loginWithPassword"
|
||||
>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">用户名</span>
|
||||
<div class="relative">
|
||||
<UserRound
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400"
|
||||
/>
|
||||
<input
|
||||
v-model="alias"
|
||||
:class="[inputClass, 'pl-9']"
|
||||
autocomplete="username"
|
||||
required
|
||||
placeholder="请输入您的用户名"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">密码</span>
|
||||
<div class="relative">
|
||||
<KeyRound
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400"
|
||||
/>
|
||||
<input
|
||||
v-model="password"
|
||||
:class="[inputClass, 'pl-9']"
|
||||
autocomplete="current-password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="error" :class="alertClass.danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="info" :class="alertClass.info">
|
||||
{{ info }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.primary, 'w-full']"
|
||||
:disabled="!canSubmitPassword"
|
||||
type="submit"
|
||||
>
|
||||
<KeyRound class="size-4" />
|
||||
{{ loading ? '登录中' : '登录' }}
|
||||
</button>
|
||||
<button
|
||||
class="text-center text-sm font-medium text-zinc-600 transition hover:text-zinc-950"
|
||||
type="button"
|
||||
@click="switchMode('qrcode')"
|
||||
>
|
||||
没有密码?使用扫码登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else class="mt-6 grid gap-4">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">用户名</span>
|
||||
<div class="relative">
|
||||
<UserRound
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400"
|
||||
/>
|
||||
<input
|
||||
v-model="alias"
|
||||
:class="[inputClass, 'pl-9']"
|
||||
autocomplete="username"
|
||||
required
|
||||
placeholder="请输入您的用户名"
|
||||
@keyup.enter="requestQrCode"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="error" :class="alertClass.danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="info" :class="alertClass.info">
|
||||
{{ info }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.primary, 'w-full']"
|
||||
:disabled="!canRequestQr"
|
||||
type="button"
|
||||
@click="requestQrCode"
|
||||
>
|
||||
<RotateCw class="size-4" />
|
||||
刷新会话
|
||||
<QrCode class="size-4" />
|
||||
{{ loading ? '正在登录' : '扫码登录/注册' }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="qrImage"
|
||||
class="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-center"
|
||||
>
|
||||
<img
|
||||
:src="qrImage.startsWith('data:') ? qrImage : `data:image/png;base64,${qrImage}`"
|
||||
alt="QQ 登录二维码"
|
||||
class="mx-auto size-48 rounded-md bg-white object-contain"
|
||||
/>
|
||||
<button
|
||||
class="mt-3 inline-flex items-center gap-2 text-sm font-medium text-zinc-600 transition hover:text-zinc-900"
|
||||
type="button"
|
||||
@click="requestQrCode"
|
||||
>
|
||||
<RotateCw class="size-4" />
|
||||
刷新会话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="[alertClass.info, 'mt-5 flex items-start gap-2']">
|
||||
<Info class="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
{{ loginMode === 'qrcode' ? '扫码登录提示' : '密码登录提示' }}
|
||||
</div>
|
||||
<div v-if="loginMode === 'qrcode'" class="mt-1 space-y-1 text-sm">
|
||||
<p>1. 输入您的用户名用于标识身份</p>
|
||||
<p>2. 点击扫码登录/注册按钮</p>
|
||||
<p>3. 使用手机 QQ 扫描二维码</p>
|
||||
<p>4. 新用户首次扫码会自动注册账户</p>
|
||||
</div>
|
||||
<div v-else class="mt-1 space-y-1 text-sm">
|
||||
<p>1. 输入您的用户名和密码</p>
|
||||
<p>2. 点击登录按钮直接进入系统</p>
|
||||
<p>3. 首次使用请先扫码登录/注册,然后在设置中设置密码</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ref } from 'vue'
|
||||
import { userApi } from '@/api'
|
||||
import { useAuth } from '@/app/auth'
|
||||
import { useRouter } from '@/app/router'
|
||||
import { buttonBase, buttonTone } from '@/components/ui'
|
||||
import { alertClass, buttonBase, buttonTone, cardClass, toneClass } from '@/components/ui'
|
||||
import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
|
||||
|
||||
const auth = useAuth()
|
||||
@@ -30,38 +30,40 @@ async function refresh() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto max-w-2xl rounded-lg border border-zinc-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-xl font-semibold">账号等待审批</h2>
|
||||
<p class="mt-2 text-sm text-zinc-500">
|
||||
当前账号
|
||||
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录,但还需要管理员审批后才能访问工作台。
|
||||
</p>
|
||||
<dl class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-md border border-zinc-200 p-3">
|
||||
<dt class="text-xs text-zinc-500">创建时间</dt>
|
||||
<dd class="mt-1 text-sm font-medium">
|
||||
{{ formatFullDateTime(auth.state.user?.created_at) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="rounded-md border border-zinc-200 p-3">
|
||||
<dt class="text-xs text-zinc-500">审批状态</dt>
|
||||
<dd class="mt-1 text-sm font-medium">待审批</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div
|
||||
v-if="error"
|
||||
class="mt-4 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||
>
|
||||
{{ error }}
|
||||
<section :class="[cardClass, 'mx-auto max-w-2xl overflow-hidden']">
|
||||
<div class="border-b border-amber-200 bg-amber-50/80 p-6">
|
||||
<span :class="toneClass('warning')">待审批</span>
|
||||
<h2 class="mt-3 text-xl font-semibold">账号等待审批</h2>
|
||||
<p class="mt-2 text-sm text-zinc-600">
|
||||
当前账号
|
||||
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录,但还需要管理员审批后才能访问工作台。
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<dl class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-md border border-zinc-200 p-3">
|
||||
<dt class="text-xs text-zinc-500">创建时间</dt>
|
||||
<dd class="mt-1 text-sm font-medium">
|
||||
{{ formatFullDateTime(auth.state.user?.created_at) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="rounded-md border border-zinc-200 p-3">
|
||||
<dt class="text-xs text-zinc-500">审批状态</dt>
|
||||
<dd class="mt-1 text-sm font-medium">待审批</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div v-if="error" :class="[alertClass.danger, 'mt-4']">
|
||||
{{ error }}
|
||||
</div>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.primary, 'mt-5']"
|
||||
:disabled="loading"
|
||||
type="button"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw class="size-4" :class="{ 'animate-spin': loading }" />
|
||||
刷新审批状态
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.primary, 'mt-5']"
|
||||
:disabled="loading"
|
||||
type="button"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw class="size-4" :class="{ 'animate-spin': loading }" />
|
||||
刷新审批状态
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,14 @@ import { Search } from 'lucide-vue-next'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { checkInApi, type CheckInRecord } from '@/api'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||
import {
|
||||
buttonBase,
|
||||
buttonTone,
|
||||
cardClass,
|
||||
inputClass,
|
||||
sectionHeaderClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -38,7 +45,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="grid gap-3 border-b border-zinc-200 p-4 md:grid-cols-[1fr_180px_180px_auto]">
|
||||
<div :class="[sectionHeaderClass, 'md:grid-cols-[1fr_180px_180px_auto]']">
|
||||
<div>
|
||||
<h2 class="font-semibold">个人打卡记录</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">按状态和触发方式查看最近的打卡结果。</p>
|
||||
@@ -80,7 +87,7 @@ onMounted(load)
|
||||
<article
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="grid gap-3 p-4 lg:grid-cols-[180px_1fr_auto]"
|
||||
class="grid gap-3 p-4 lg:grid-cols-[180px_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm font-semibold">
|
||||
@@ -98,13 +105,15 @@ onMounted(load)
|
||||
触发方式:{{ statusLabel(record.trigger_type) }}
|
||||
</p>
|
||||
</div>
|
||||
<span :class="toneClass(statusTone(record.status))">{{
|
||||
statusLabel(record.status)
|
||||
}}</span>
|
||||
<div class="lg:text-right">
|
||||
<span :class="toneClass(statusTone(record.status))">{{
|
||||
statusLabel(record.status)
|
||||
}}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-zinc-200 px-4 py-3 text-sm text-zinc-500"
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 bg-zinc-50/70 px-4 py-3 text-sm text-zinc-500"
|
||||
>
|
||||
<span
|
||||
>共 {{ total }} 条,当前 {{ filters.skip + 1 }} -
|
||||
|
||||
@@ -4,7 +4,15 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { userApi, type TokenStatus } from '@/api'
|
||||
import { useAuth } from '@/app/auth'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||
import {
|
||||
alertClass,
|
||||
buttonBase,
|
||||
buttonTone,
|
||||
cardClass,
|
||||
inputClass,
|
||||
sectionHeaderClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
import { extractErrorMessage } from '@/utils/format'
|
||||
|
||||
const auth = useAuth()
|
||||
@@ -75,74 +83,79 @@ onMounted(load)
|
||||
@action="load"
|
||||
/>
|
||||
<div v-else class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<form :class="[cardClass, 'grid gap-4 p-5']" @submit.prevent="save">
|
||||
<div>
|
||||
<form :class="[cardClass, 'overflow-hidden']" @submit.prevent="save">
|
||||
<div :class="sectionHeaderClass">
|
||||
<h2 class="font-semibold">个人资料</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">更新别名、邮箱和登录密码。</p>
|
||||
</div>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">别名</span>
|
||||
<input v-model="form.alias" :class="inputClass" required />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">邮箱</span>
|
||||
<input v-model="form.email" :class="inputClass" type="email" placeholder="用于打卡通知" />
|
||||
</label>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-4 p-5">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">当前密码</span>
|
||||
<input
|
||||
v-model="form.current_password"
|
||||
:class="inputClass"
|
||||
type="password"
|
||||
placeholder="修改密码时填写"
|
||||
/>
|
||||
<span class="text-xs font-semibold text-zinc-500">别名</span>
|
||||
<input v-model="form.alias" :class="inputClass" required />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">新密码</span>
|
||||
<input
|
||||
v-model="form.new_password"
|
||||
:class="inputClass"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
<span class="text-xs font-semibold text-zinc-500">邮箱</span>
|
||||
<input v-model="form.email" :class="inputClass" type="email" placeholder="用于打卡通知" />
|
||||
</label>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">当前密码</span>
|
||||
<input
|
||||
v-model="form.current_password"
|
||||
:class="inputClass"
|
||||
type="password"
|
||||
placeholder="修改密码时填写"
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">新密码</span>
|
||||
<input
|
||||
v-model="form.new_password"
|
||||
:class="inputClass"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="error" :class="alertClass.danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="message" :class="alertClass.success">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button :class="[buttonBase, buttonTone.primary, 'w-fit']" :disabled="saving" type="submit">
|
||||
<Save class="size-4" />
|
||||
{{ saving ? '保存中' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="error"
|
||||
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-if="message"
|
||||
class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
<button :class="[buttonBase, buttonTone.primary, 'w-fit']" :disabled="saving" type="submit">
|
||||
<Save class="size-4" />
|
||||
{{ saving ? '保存中' : '保存设置' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<aside :class="[cardClass, 'h-fit p-5']">
|
||||
<h2 class="font-semibold">授权状态</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">这里检查的是打卡业务 token,不是网站登录状态。</p>
|
||||
<div class="mt-4 grid gap-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500">状态</span>
|
||||
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
|
||||
token?.is_valid ? '可用' : '不可用'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500">即将过期</span>
|
||||
<span>{{ token?.expiring_soon ? '是' : '否' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500">剩余天数</span>
|
||||
<span>{{ token?.days_until_expiry ?? '未知' }}</span>
|
||||
<aside :class="[cardClass, 'h-fit overflow-hidden']">
|
||||
<div
|
||||
class="border-b px-5 py-4"
|
||||
:class="
|
||||
token?.is_valid ? 'border-emerald-200 bg-emerald-50/70' : 'border-rose-200 bg-rose-50/70'
|
||||
"
|
||||
>
|
||||
<h2 class="font-semibold">授权状态</h2>
|
||||
<p class="mt-1 text-sm text-zinc-600">这里检查的是打卡业务 token,不是网站登录状态。</p>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="mt-4 grid gap-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500">状态</span>
|
||||
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
|
||||
token?.is_valid ? '可用' : '不可用'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500">即将过期</span>
|
||||
<span>{{ token?.expiring_soon ? '是' : '否' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500">剩余天数</span>
|
||||
<span>{{ token?.days_until_expiry ?? '未知' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -4,7 +4,14 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { checkInApi, taskApi, type CheckInRecord, type Task } from '@/api'
|
||||
import { useRouter } from '@/app/router'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||
import {
|
||||
buttonBase,
|
||||
buttonTone,
|
||||
cardClass,
|
||||
inputClass,
|
||||
sectionHeaderClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -39,7 +46,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="grid gap-3 border-b border-zinc-200 p-4 lg:grid-cols-[1fr_180px_180px_auto]">
|
||||
<div :class="[sectionHeaderClass, 'lg:grid-cols-[1fr_180px_180px_auto]']">
|
||||
<div>
|
||||
<button
|
||||
class="mb-2 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900"
|
||||
@@ -85,7 +92,7 @@ onMounted(load)
|
||||
<article
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="grid gap-3 p-4 md:grid-cols-[180px_1fr_auto]"
|
||||
class="grid gap-3 p-4 md:grid-cols-[180px_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<div class="text-sm text-zinc-500">{{ formatFullDateTime(record.check_in_time) }}</div>
|
||||
<div>
|
||||
@@ -94,7 +101,11 @@ onMounted(load)
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-zinc-500">触发:{{ statusLabel(record.trigger_type) }}</div>
|
||||
</div>
|
||||
<span :class="toneClass(statusTone(record.status))">{{ statusLabel(record.status) }}</span>
|
||||
<div class="md:text-right">
|
||||
<span :class="toneClass(statusTone(record.status))">{{
|
||||
statusLabel(record.status)
|
||||
}}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div class="border-t border-zinc-200 px-4 py-3 text-sm text-zinc-500">
|
||||
共 {{ total }} 条记录
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
import { useRouter } from '@/app/router'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import {
|
||||
alertClass,
|
||||
buttonBase,
|
||||
buttonTone,
|
||||
cardClass,
|
||||
inputClass,
|
||||
sectionHeaderClass,
|
||||
textareaClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
@@ -207,8 +209,8 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div class="grid gap-5">
|
||||
<section :class="[cardClass, 'p-5']">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">从模板创建任务</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">选择启用模板,填写接龙 ID 和字段值后创建任务。</p>
|
||||
@@ -219,7 +221,7 @@ onMounted(load)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="mt-4 grid gap-4" @submit.prevent="createTask">
|
||||
<form class="grid gap-4 p-5" @submit.prevent="createTask">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">模板</span>
|
||||
@@ -282,16 +284,10 @@ onMounted(load)
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="error"
|
||||
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||
>
|
||||
<div v-if="error" :class="alertClass.danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-if="message"
|
||||
class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700"
|
||||
>
|
||||
<div v-if="message" :class="alertClass.success">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button
|
||||
@@ -320,8 +316,16 @@ onMounted(load)
|
||||
description="先从模板创建一个任务。"
|
||||
/>
|
||||
<section v-else :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="border-b border-zinc-200 px-4 py-3">
|
||||
<h2 class="font-semibold">任务列表</h2>
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">任务列表</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">查看启停、最近状态,并执行手动打卡或维护操作。</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-600"
|
||||
>
|
||||
{{ tasks.length }} 个任务
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-zinc-200">
|
||||
<article v-for="task in tasks" :key="task.id" class="p-4">
|
||||
@@ -338,12 +342,21 @@ onMounted(load)
|
||||
>
|
||||
{{ statusLabel(task.last_check_in_status) }}
|
||||
</span>
|
||||
<span
|
||||
v-for="status in Object.values(polling).filter(
|
||||
(item) => item.task_id === task.id,
|
||||
)"
|
||||
:key="status.record_id"
|
||||
:class="toneClass(statusTone(status.status))"
|
||||
>
|
||||
{{ statusLabel(status.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
ThreadId: {{ task.thread_id || '未解析' }} · {{ cronLabel(task.cron_expression) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap gap-2 lg:justify-end">
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@@ -391,9 +404,13 @@ onMounted(load)
|
||||
|
||||
<form
|
||||
v-if="editingTaskId === task.id"
|
||||
class="mt-4 grid gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4"
|
||||
class="mt-4 grid gap-3 rounded-lg border border-emerald-200 bg-emerald-50/60 p-4"
|
||||
@submit.prevent="saveEdit(task.id)"
|
||||
>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-zinc-900">编辑任务</h4>
|
||||
<p class="mt-1 text-xs text-zinc-500">保存前会校验 Payload JSON。</p>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">任务名称</span>
|
||||
@@ -408,7 +425,7 @@ onMounted(load)
|
||||
<span class="text-xs font-semibold text-zinc-500">Payload JSON</span>
|
||||
<textarea v-model="editForm.payload_config" :class="textareaClass" />
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button :class="[buttonBase, buttonTone.primary]" type="submit">保存</button>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import { buttonBase, buttonTone, cardClass, inputClass } from '@/components/ui'
|
||||
import { buttonBase, buttonTone, cardClass, inputClass, sectionHeaderClass } from '@/components/ui'
|
||||
import { extractErrorMessage } from '@/utils/format'
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -28,18 +28,24 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="flex flex-wrap items-center gap-3 border-b border-zinc-200 p-4">
|
||||
<input
|
||||
v-model.number="lines"
|
||||
:class="inputClass"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2000"
|
||||
class="max-w-40"
|
||||
/>
|
||||
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||
刷新日志
|
||||
</button>
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">系统日志</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">查看最近运行日志,适合排查打卡和后台任务状态。</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
v-model.number="lines"
|
||||
:class="inputClass"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2000"
|
||||
class="max-w-40"
|
||||
/>
|
||||
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||
刷新日志
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<StateBlock v-if="loading" title="正在加载日志" type="loading" />
|
||||
<StateBlock
|
||||
@@ -52,7 +58,7 @@ onMounted(load)
|
||||
/>
|
||||
<pre
|
||||
v-else
|
||||
class="max-h-[70vh] overflow-auto bg-zinc-950 p-4 text-xs leading-5 text-zinc-100"
|
||||
class="max-h-[70vh] overflow-auto bg-zinc-950 p-4 font-mono text-xs leading-5 text-zinc-100"
|
||||
>{{ logs || '无日志' }}</pre
|
||||
>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,14 @@ import { Search } from 'lucide-vue-next'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { checkInApi, type CheckInRecord } from '@/api'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||
import {
|
||||
buttonBase,
|
||||
buttonTone,
|
||||
cardClass,
|
||||
inputClass,
|
||||
sectionHeaderClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -33,8 +40,11 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="grid gap-3 border-b border-zinc-200 p-4 md:grid-cols-[1fr_160px_160px_auto]">
|
||||
<h2 class="font-semibold">全量记录</h2>
|
||||
<div :class="[sectionHeaderClass, 'md:grid-cols-[1fr_160px_160px_auto]']">
|
||||
<div>
|
||||
<h2 class="font-semibold">全量记录</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">按任务和状态快速定位系统记录。</p>
|
||||
</div>
|
||||
<input v-model="filters.task_id" :class="inputClass" placeholder="任务 ID" />
|
||||
<select v-model="filters.status" :class="inputClass">
|
||||
<option value="">全部状态</option>
|
||||
@@ -60,7 +70,7 @@ onMounted(load)
|
||||
<article
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="grid gap-2 p-4 md:grid-cols-[1fr_auto] md:items-center"
|
||||
class="grid gap-3 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
@@ -70,7 +80,7 @@ onMounted(load)
|
||||
{{ formatFullDateTime(record.check_in_time) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2 md:justify-end">
|
||||
<span :class="toneClass(statusTone(record.status))">{{
|
||||
statusLabel(record.status)
|
||||
}}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi, type AdminStats } from '@/api'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import { cardClass } from '@/components/ui'
|
||||
import { cardClass, sectionHeaderClass } from '@/components/ui'
|
||||
import { extractErrorMessage } from '@/utils/format'
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -34,26 +34,34 @@ onMounted(load)
|
||||
action-label="重试"
|
||||
@action="load"
|
||||
/>
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="text-sm text-zinc-500">用户</div>
|
||||
<div class="mt-2 text-3xl font-semibold">{{ stats?.users.total }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">已审批 {{ stats?.users.active }}</div>
|
||||
<section v-else :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">系统统计</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">总览用户、任务、记录和 Token 预警。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="text-sm text-zinc-500">任务</div>
|
||||
<div class="mt-2 text-3xl font-semibold">{{ stats?.tasks.total }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">启用 {{ stats?.tasks.active }}</div>
|
||||
<div class="grid gap-4 p-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4">
|
||||
<div class="text-sm text-zinc-500">用户</div>
|
||||
<div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.users.total }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">已审批 {{ stats?.users.active }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4">
|
||||
<div class="text-sm text-zinc-500">任务</div>
|
||||
<div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.tasks.total }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">启用 {{ stats?.tasks.active }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4">
|
||||
<div class="text-sm text-zinc-500">记录</div>
|
||||
<div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.check_in_records.total }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">今日 {{ stats?.check_in_records.today }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50/70 p-4">
|
||||
<div class="text-sm text-zinc-500">Token 预警</div>
|
||||
<div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.tokens.expiring_soon }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">7 天内过期</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="text-sm text-zinc-500">记录</div>
|
||||
<div class="mt-2 text-3xl font-semibold">{{ stats?.check_in_records.total }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">今日 {{ stats?.check_in_records.today }}</div>
|
||||
</div>
|
||||
<div :class="[cardClass, 'p-4']">
|
||||
<div class="text-sm text-zinc-500">Token 预警</div>
|
||||
<div class="mt-2 text-3xl font-semibold">{{ stats?.tokens.expiring_soon }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">7 天内过期</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { templateApi, type Template, type TemplatePreview } from '@/api'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import {
|
||||
alertClass,
|
||||
buttonBase,
|
||||
buttonTone,
|
||||
cardClass,
|
||||
inputClass,
|
||||
sectionHeaderClass,
|
||||
textareaClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
@@ -112,8 +114,11 @@ onMounted(load)
|
||||
<template>
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
|
||||
<h2 class="font-semibold">模板管理</h2>
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">模板管理</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">维护创建任务时可选择的字段配置和模板状态。</p>
|
||||
</div>
|
||||
<button :class="[buttonBase, buttonTone.primary]" type="button" @click="startCreate">
|
||||
<Plus class="size-4" />
|
||||
新建模板
|
||||
@@ -145,7 +150,7 @@ onMounted(load)
|
||||
{{ template.description || '无描述' }} · {{ formatDateTime(template.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@@ -175,48 +180,51 @@ onMounted(load)
|
||||
</section>
|
||||
|
||||
<aside class="grid gap-5">
|
||||
<form v-if="editingId" :class="[cardClass, 'grid gap-4 p-5']" @submit.prevent="save">
|
||||
<h2 class="font-semibold">{{ editingId === 'new' ? '新建模板' : '编辑模板' }}</h2>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">名称</span>
|
||||
<input v-model="form.name" :class="inputClass" required />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">描述</span>
|
||||
<input v-model="form.description" :class="inputClass" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="form.is_active" type="checkbox" />
|
||||
启用模板
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">字段配置 JSON</span>
|
||||
<textarea v-model="form.field_config" :class="textareaClass" class="min-h-64" />
|
||||
</label>
|
||||
<div
|
||||
v-if="error"
|
||||
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||
>
|
||||
{{ error }}
|
||||
<form
|
||||
v-if="editingId"
|
||||
:class="[cardClass, 'grid gap-4 overflow-hidden']"
|
||||
@submit.prevent="save"
|
||||
>
|
||||
<div class="border-b border-zinc-200 bg-zinc-50/70 px-5 py-4">
|
||||
<h2 class="font-semibold">{{ editingId === 'new' ? '新建模板' : '编辑模板' }}</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">字段配置必须是合法 JSON。</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="message"
|
||||
class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button :class="[buttonBase, buttonTone.primary]" type="submit">
|
||||
<Save class="size-4" />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@click="editingId = null"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<div class="grid gap-4 p-5">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">名称</span>
|
||||
<input v-model="form.name" :class="inputClass" required />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">描述</span>
|
||||
<input v-model="form.description" :class="inputClass" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="form.is_active" type="checkbox" />
|
||||
启用模板
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">字段配置 JSON</span>
|
||||
<textarea v-model="form.field_config" :class="textareaClass" class="min-h-64" />
|
||||
</label>
|
||||
<div v-if="error" :class="alertClass.danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="message" :class="alertClass.success">
|
||||
{{ message }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button :class="[buttonBase, buttonTone.primary]" type="submit">
|
||||
<Save class="size-4" />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@click="editingId = null"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -3,7 +3,15 @@ import { Check, Save, Search, Trash2, UserPlus } from 'lucide-vue-next'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { adminApi, userApi, type User } from '@/api'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||
import {
|
||||
alertClass,
|
||||
buttonBase,
|
||||
buttonTone,
|
||||
cardClass,
|
||||
inputClass,
|
||||
sectionHeaderClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
import { extractErrorMessage, formatDateTime } from '@/utils/format'
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -88,7 +96,16 @@ onMounted(load)
|
||||
<template>
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="flex flex-wrap items-center gap-3 border-b border-zinc-200 p-4">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">用户审批与管理</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">优先处理待审批用户,再维护角色、邮箱和密码。</p>
|
||||
</div>
|
||||
<span :class="toneClass(users.some((user) => !user.is_approved) ? 'warning' : 'success')">
|
||||
{{ users.filter((user) => !user.is_approved).length }} 个待审批
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-zinc-50/70 p-4">
|
||||
<input v-model="search" :class="inputClass" class="max-w-sm" placeholder="搜索别名" />
|
||||
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||
<Search class="size-4" />
|
||||
@@ -128,7 +145,7 @@ onMounted(load)
|
||||
{{ user.email || '未设置邮箱' }} · {{ formatDateTime(user.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="!user.is_approved"
|
||||
:class="[buttonBase, buttonTone.primary]"
|
||||
@@ -155,50 +172,60 @@ onMounted(load)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form v-if="editingId" :class="[cardClass, 'grid h-fit gap-4 p-5']" @submit.prevent="save">
|
||||
<h2 class="font-semibold">{{ editingId === 'new' ? '创建用户' : '编辑用户' }}</h2>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">别名</span>
|
||||
<input v-model="form.alias" :class="inputClass" required />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">邮箱</span>
|
||||
<input v-model="form.email" :class="inputClass" type="email" />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">角色</span>
|
||||
<select v-model="form.role" :class="inputClass">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">密码</span>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:class="inputClass"
|
||||
type="password"
|
||||
placeholder="留空不修改"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="form.is_approved" type="checkbox" />
|
||||
已审批
|
||||
</label>
|
||||
<div
|
||||
v-if="error"
|
||||
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||
>
|
||||
{{ error }}
|
||||
<form
|
||||
v-if="editingId"
|
||||
:class="[cardClass, 'grid h-fit gap-4 overflow-hidden']"
|
||||
@submit.prevent="save"
|
||||
>
|
||||
<div class="border-b border-zinc-200 bg-zinc-50/70 px-5 py-4">
|
||||
<h2 class="font-semibold">{{ editingId === 'new' ? '创建用户' : '编辑用户' }}</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">保存后会刷新用户列表。</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button :class="[buttonBase, buttonTone.primary]" type="submit">
|
||||
<Save class="size-4" />
|
||||
保存
|
||||
</button>
|
||||
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="editingId = null">
|
||||
取消
|
||||
</button>
|
||||
<div class="grid gap-4 p-5">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">别名</span>
|
||||
<input v-model="form.alias" :class="inputClass" required />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">邮箱</span>
|
||||
<input v-model="form.email" :class="inputClass" type="email" />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">角色</span>
|
||||
<select v-model="form.role" :class="inputClass">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500">密码</span>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:class="inputClass"
|
||||
type="password"
|
||||
placeholder="留空不修改"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="form.is_approved" type="checkbox" />
|
||||
已审批
|
||||
</label>
|
||||
<div v-if="error" :class="alertClass.danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button :class="[buttonBase, buttonTone.primary]" type="submit">
|
||||
<Save class="size-4" />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@click="editingId = null"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -11,4 +11,12 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user