style(new-frontend): consolidate visual tokens

This commit is contained in:
2026-05-04 19:40:56 +08:00
parent 72329baff4
commit 741d328430
19 changed files with 366 additions and 500 deletions
+35 -39
View File
@@ -58,9 +58,9 @@ const themeModes = [
function themeModeButtonClass(mode: ThemeMode) { function themeModeButtonClass(mode: ThemeMode) {
if (theme.state.mode === mode) { if (theme.state.mode === mode) {
return 'bg-zinc-900 text-white shadow-sm hover:bg-zinc-900 hover:text-white dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-zinc-100 dark:hover:text-zinc-950' return 'bg-foreground text-background shadow-sm hover:bg-foreground hover:text-background'
} }
return 'text-zinc-500 hover:bg-zinc-100 hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50' return 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
} }
function go(path: string) { function go(path: string) {
@@ -75,10 +75,8 @@ function signOut() {
</script> </script>
<template> <template>
<div class="min-h-[100dvh] bg-zinc-50 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50"> <div class="min-h-[100dvh] bg-background text-foreground">
<header <header class="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur">
class="sticky top-0 z-20 border-b border-zinc-200 bg-white/95 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/90"
>
<div class="flex h-14 w-full items-center justify-between px-4 sm:px-6 lg:px-8"> <div class="flex h-14 w-full items-center justify-between px-4 sm:px-6 lg:px-8">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Button <Button
@@ -93,27 +91,27 @@ function signOut() {
</Button> </Button>
<button class="flex items-center gap-3 text-left" type="button" @click="go('/dashboard')"> <button class="flex items-center gap-3 text-left" type="button" @click="go('/dashboard')">
<span <span
class="hidden size-9 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm sm:inline-flex" class="hidden size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm sm:inline-flex"
> >
<CheckCircle2 class="size-5" /> <CheckCircle2 class="size-5" />
</span> </span>
<span> <span>
<div class="text-sm font-semibold leading-4">接龙自动打卡</div> <div class="text-sm font-semibold leading-4 text-foreground">接龙自动打卡</div>
<div class="text-xs text-zinc-500 dark:text-zinc-400">CheckIn workspace</div> <div class="text-xs text-muted-foreground">CheckIn workspace</div>
</span> </span>
</button> </button>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="hidden text-right sm:block"> <div class="hidden text-right sm:block">
<div class="text-sm font-medium">{{ authState.user?.alias ?? '未登录' }}</div> <div class="text-sm font-medium text-foreground">
<div class="text-xs text-zinc-500 dark:text-zinc-400"> {{ authState.user?.alias ?? '未登录' }}
{{ roleLabel }} · {{ approvalLabel }}
</div> </div>
<div class="text-xs text-muted-foreground">{{ roleLabel }} · {{ approvalLabel }}</div>
</div> </div>
<TooltipProvider> <TooltipProvider>
<div <div
class="inline-flex items-center rounded-lg border border-zinc-200 bg-white p-0.5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" class="inline-flex items-center rounded-lg border border-border bg-background p-0.5 shadow-sm"
:aria-label="`主题模式,当前${themeLabel}`" :aria-label="`主题模式,当前${themeLabel}`"
> >
<Tooltip v-for="item in themeModes" :key="item.mode"> <Tooltip v-for="item in themeModes" :key="item.mode">
@@ -148,19 +146,19 @@ function signOut() {
class="grid min-h-[calc(100dvh-3.5rem)] w-full grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)]" class="grid min-h-[calc(100dvh-3.5rem)] w-full grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)]"
> >
<aside <aside
class="border-b border-zinc-200 bg-white px-3 py-3 shadow-sm lg:min-h-[calc(100dvh-3.5rem)] lg:border-b-0 lg:border-r lg:shadow-none dark:border-zinc-800 dark:bg-zinc-950" class="border-b border-border bg-card px-3 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'" :class="mobileOpen ? 'block' : 'hidden lg:block'"
> >
<div <div class="mb-4 rounded-lg border border-border bg-muted p-3 lg:hidden">
class="mb-4 rounded-lg border border-zinc-200 bg-zinc-50 p-3 lg:hidden dark:border-zinc-700 dark:bg-zinc-900" <div class="text-sm font-semibold text-foreground">
> {{ authState.user?.alias ?? '未登录' }}
<div class="text-sm font-semibold">{{ authState.user?.alias ?? '未登录' }}</div> </div>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400"> <div class="mt-1 text-xs text-muted-foreground">
{{ roleLabel }} · {{ approvalLabel }} {{ roleLabel }} · {{ approvalLabel }}
</div> </div>
</div> </div>
<div <div
class="mb-2 px-3 text-xs font-semibold uppercase tracking-normal text-zinc-500 dark:text-zinc-400" class="mb-2 px-3 text-xs font-semibold uppercase tracking-normal text-muted-foreground"
> >
工作台 工作台
</div> </div>
@@ -169,11 +167,11 @@ function signOut() {
v-for="link in userLinks" v-for="link in userLinks"
:key="link.path" :key="link.path"
type="button" type="button"
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 dark:hover:bg-zinc-900 dark:hover:text-emerald-300" class="flex min-h-9 items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
:class=" :class="
router.state.path === link.path router.state.path === link.path
? 'bg-emerald-700 text-white shadow-sm hover:bg-emerald-700 hover:text-white dark:bg-emerald-600 dark:text-white' ? 'bg-primary text-primary-foreground shadow-sm hover:bg-primary hover:text-primary-foreground'
: 'text-zinc-700 dark:text-zinc-300' : 'text-muted-foreground'
" "
@click="go(link.path)" @click="go(link.path)"
> >
@@ -182,9 +180,9 @@ function signOut() {
</button> </button>
</nav> </nav>
<div v-if="isAdmin" class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-800"> <div v-if="isAdmin" class="mt-5 border-t border-border pt-4">
<div <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 dark:border-sky-900/70 dark:bg-sky-950/50 dark:text-sky-200" class="mb-2 flex items-center justify-between rounded-md border border-[var(--tone-info-border)] bg-[var(--tone-info-bg)] px-3 py-2 text-xs font-semibold text-[var(--tone-info-fg)]"
> >
<span>管理员工作区</span> <span>管理员工作区</span>
<Shield class="size-3.5" /> <Shield class="size-3.5" />
@@ -194,11 +192,11 @@ function signOut() {
v-for="link in adminLinks" v-for="link in adminLinks"
:key="link.path" :key="link.path"
type="button" type="button"
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 dark:hover:bg-zinc-900 dark:hover:text-sky-300" class="flex min-h-9 items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
:class=" :class="
router.state.path === link.path router.state.path === link.path
? 'bg-sky-700 text-white shadow-sm hover:bg-sky-700 hover:text-white dark:bg-sky-600 dark:text-white' ? 'bg-[var(--tone-info-strong)] text-white shadow-sm hover:bg-[var(--tone-info-strong-hover)] hover:text-white'
: 'text-zinc-700 dark:text-zinc-300' : 'text-muted-foreground'
" "
@click="go(link.path)" @click="go(link.path)"
> >
@@ -209,34 +207,32 @@ function signOut() {
</div> </div>
</aside> </aside>
<main class="min-w-0 px-4 py-4 sm:px-6 lg:px-8"> <main class="min-w-0 bg-background px-4 py-4 sm:px-6 lg:px-8">
<div <div
class="mb-5 grid gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none" class="mb-5 grid gap-3 rounded-xl border border-border bg-card px-4 py-3 shadow-sm dark:shadow-none"
:class="{ :class="{
'border-sky-200 bg-sky-50/70 dark:border-sky-900/70 dark:bg-sky-950/30': isAdminRoute, 'border-[var(--tone-info-border)] bg-[var(--tone-info-bg)]': isAdminRoute,
}" }"
> >
<div class="min-w-0"> <div class="min-w-0">
<div <div
v-if="isAdminRoute" 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 dark:border-sky-900/70 dark:bg-sky-950/70 dark:text-sky-200" class="mb-1 inline-flex items-center gap-1 rounded-full border border-[var(--tone-info-border)] bg-background px-2 py-0.5 text-xs font-medium text-[var(--tone-info-fg)]"
> >
<Shield class="size-3" /> <Shield class="size-3" />
管理员 管理员
</div> </div>
<h1 <h1 class="truncate text-2xl font-semibold tracking-normal text-foreground">
class="truncate text-2xl font-semibold tracking-normal text-zinc-950 dark:text-zinc-50"
>
{{ title }} {{ title }}
</h1> </h1>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<div <div
class="inline-flex items-center gap-2 rounded-full border bg-white px-3 py-1 text-xs font-medium dark:bg-zinc-950" class="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1 text-xs font-medium"
:class=" :class="
authState.user?.is_approved authState.user?.is_approved
? 'border-emerald-200 text-emerald-700 dark:border-emerald-900/70 dark:text-emerald-300' ? 'border-[var(--tone-success-border)] text-[var(--tone-success-fg)]'
: 'border-amber-200 text-amber-700 dark:border-amber-900/70 dark:text-amber-300' : 'border-[var(--tone-warning-border)] text-[var(--tone-warning-fg)]'
" "
> >
<UserRound class="size-3.5" /> <UserRound class="size-3.5" />
@@ -244,7 +240,7 @@ function signOut() {
</div> </div>
<div <div
v-if="isAdminRoute" v-if="isAdminRoute"
class="inline-flex items-center gap-2 rounded-full border border-sky-200 bg-white px-3 py-1 text-xs font-medium text-sky-700 dark:border-sky-900/70 dark:bg-zinc-950 dark:text-sky-300" class="inline-flex items-center gap-2 rounded-full border border-[var(--tone-info-border)] bg-background px-3 py-1 text-xs font-medium text-[var(--tone-info-fg)]"
> >
<Shield class="size-3.5" /> <Shield class="size-3.5" />
管理员工作区 管理员工作区
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { AlertCircle, Loader2, Search } from 'lucide-vue-next' import { AlertCircle, Loader2, Search } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineProps<{ defineProps<{
title: string title: string
@@ -15,14 +16,14 @@ defineEmits<{
<template> <template>
<div <div
class="grid gap-4 rounded-xl border border-dashed border-zinc-200 bg-white p-4 text-left shadow-sm sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center dark:border-zinc-800 dark:bg-zinc-900" class="grid gap-4 rounded-xl border border-dashed border-border bg-card p-4 text-left text-card-foreground shadow-sm sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center dark:shadow-none"
> >
<div <div
class="flex size-10 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-300" class="flex size-10 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground"
:class="{ :class="{
'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-300': 'border-[var(--tone-danger-border)] bg-[var(--tone-danger-bg)] text-[var(--tone-danger-fg)]':
type === 'error', type === 'error',
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300': 'border-[var(--tone-info-border)] bg-[var(--tone-info-bg)] text-[var(--tone-info-fg)]':
type === 'loading', type === 'loading',
}" }"
> >
@@ -31,18 +32,19 @@ defineEmits<{
<Search v-else class="size-5" /> <Search v-else class="size-5" />
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</div> <div class="text-sm font-semibold text-foreground">{{ title }}</div>
<p v-if="description" class="mt-1 text-sm leading-5 text-zinc-500 dark:text-zinc-400"> <p v-if="description" class="mt-1 text-sm leading-5 text-muted-foreground">
{{ description }} {{ description }}
</p> </p>
</div> </div>
<button <Button
v-if="actionLabel" v-if="actionLabel"
type="button" type="button"
class="inline-flex min-h-9 items-center justify-center rounded-lg 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 sm:justify-self-end dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800" variant="outline"
class="sm:justify-self-end"
@click="$emit('action')" @click="$emit('action')"
> >
{{ actionLabel }} {{ actionLabel }}
</button> </Button>
</div> </div>
</template> </template>
@@ -214,19 +214,19 @@ function removeOption(index: number) {
<button <button
v-if="canAddChildren || fieldNode" v-if="canAddChildren || fieldNode"
type="button" type="button"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-md border border-zinc-200 text-zinc-500 transition hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800" class="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-zinc-200 text-zinc-500 transition hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
:title="collapsed ? '展开' : '收起'" :title="collapsed ? '展开' : '收起'"
@click="collapsed = !collapsed" @click="collapsed = !collapsed"
> >
<ChevronRight v-if="collapsed" class="size-4" /> <ChevronRight v-if="collapsed" class="size-5" />
<ChevronDown v-else class="size-4" /> <ChevronDown v-else class="size-5" />
</button> </button>
<div <div
class="inline-flex size-7 shrink-0 items-center justify-center rounded-md border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300" class="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300"
> >
<ListTree v-if="kind === 'array'" class="size-4" /> <ListTree v-if="kind === 'array'" class="size-5" />
<Braces v-else-if="kind === 'object'" class="size-4" /> <Braces v-else-if="kind === 'object'" class="size-5" />
<span v-else class="text-xs font-semibold">Aa</span> <span v-else class="text-sm font-semibold">Aa</span>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -255,27 +255,27 @@ function removeOption(index: number) {
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<button <button
type="button" type="button"
:class="[buttonBase, buttonTone.ghost, 'size-8 min-h-8 px-0 py-0']" :class="[buttonBase, buttonTone.ghost, 'size-10 min-h-10 px-0 py-0']"
title="上移" title="上移"
@click="move('up')" @click="move('up')"
> >
<ArrowUp class="size-4" /> <ArrowUp class="size-6" />
</button> </button>
<button <button
type="button" type="button"
:class="[buttonBase, buttonTone.ghost, 'size-8 min-h-8 px-0 py-0']" :class="[buttonBase, buttonTone.ghost, 'size-10 min-h-10 px-0 py-0']"
title="下移" title="下移"
@click="move('down')" @click="move('down')"
> >
<ArrowDown class="size-4" /> <ArrowDown class="size-6" />
</button> </button>
<button <button
type="button" type="button"
:class="[buttonBase, buttonTone.danger, 'size-8 min-h-8 px-0 py-0']" :class="[buttonBase, buttonTone.danger, 'size-10 min-h-10 px-0 py-0']"
title="删除" title="删除"
@click="deleteNode" @click="deleteNode"
> >
<Trash2 class="size-4" /> <Trash2 class="size-6" />
</button> </button>
</div> </div>
</div> </div>
@@ -388,7 +388,7 @@ function removeOption(index: number) {
type="button" type="button"
@click="addOption" @click="addOption"
> >
<Plus class="size-3.5" /> <Plus class="size-4" />
添加选项 添加选项
</button> </button>
</div> </div>
+21 -24
View File
@@ -1,61 +1,58 @@
export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
export const buttonBase = export const buttonBase =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-lg 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 dark:focus-visible:ring-emerald-400/30' '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-ring/50 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50'
export const buttonTone = { export const buttonTone = {
primary: primary: 'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
'border-emerald-700 bg-emerald-700 text-white hover:bg-emerald-800 dark:border-emerald-500 dark:bg-emerald-600 dark:hover:bg-emerald-500',
secondary: secondary:
'border-zinc-200 bg-white text-zinc-900 hover:border-zinc-300 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:border-zinc-600 dark:hover:bg-zinc-800', 'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground',
ghost: ghost:
'border-transparent bg-transparent text-zinc-700 shadow-none hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800', 'border-transparent bg-transparent text-muted-foreground shadow-none hover:bg-accent hover:text-accent-foreground',
danger: danger:
'border-rose-200 bg-rose-50 text-rose-700 hover:border-rose-300 hover:bg-rose-100 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-300 dark:hover:border-rose-800 dark:hover:bg-rose-900/40', 'border-destructive/30 bg-destructive/10 text-destructive hover:border-destructive/40 hover:bg-destructive/15',
admin: admin:
'border-sky-700 bg-sky-700 text-white hover:bg-sky-800 dark:border-sky-500 dark:bg-sky-600 dark:hover:bg-sky-500', 'border-[var(--tone-info-strong)] bg-[var(--tone-info-strong)] text-white hover:bg-[var(--tone-info-strong-hover)]',
} }
export const inputClass = export const inputClass =
'w-full min-h-9 rounded-lg 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 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-emerald-500 dark:focus:ring-emerald-400/10 dark:disabled:bg-zinc-900 dark:disabled:text-zinc-500' 'w-full min-h-9 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted-foreground focus:border-ring focus:ring-2 focus:ring-ring/15 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground'
export const textareaClass = `${inputClass} min-h-24 resize-y font-mono text-xs leading-5` export const textareaClass = `${inputClass} min-h-24 resize-y font-mono text-xs leading-5`
export const cardClass = export const cardClass =
'rounded-xl border border-zinc-200/80 bg-white shadow-[0_12px_28px_-24px_rgba(24,24,27,0.42)] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none' 'rounded-xl border border-border bg-card text-card-foreground shadow-[0_12px_28px_-24px_rgba(24,24,27,0.42)] dark:shadow-none'
export const sectionHeaderClass = export const sectionHeaderClass =
'grid gap-2 border-b border-zinc-200 bg-zinc-50/65 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center dark:border-zinc-800 dark:bg-zinc-950/50' 'grid gap-2 border-b border-border bg-muted/55 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'
export const actionBarClass = export const actionBarClass =
'grid gap-2 border-b border-zinc-200 bg-zinc-50/65 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center dark:border-zinc-800 dark:bg-zinc-950/50' 'grid gap-2 border-b border-border bg-muted/55 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'
export const labelClass = export const labelClass = 'text-xs font-semibold uppercase tracking-normal text-muted-foreground'
'text-xs font-semibold uppercase tracking-normal text-zinc-500 dark:text-zinc-400'
export const mutedText = 'text-sm text-zinc-500 dark:text-zinc-400' export const mutedText = 'text-sm text-muted-foreground'
export const alertClass = { export const alertClass = {
info: 'rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-sm leading-5 text-sky-800 dark:border-sky-900/70 dark:bg-sky-950/50 dark:text-sky-200', info: 'rounded-lg border border-[var(--tone-info-border)] bg-[var(--tone-info-bg)] px-3 py-2 text-sm leading-5 text-[var(--tone-info-fg)]',
success: success:
'rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm leading-5 text-emerald-800 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-200', 'rounded-lg border border-[var(--tone-success-border)] bg-[var(--tone-success-bg)] px-3 py-2 text-sm leading-5 text-[var(--tone-success-fg)]',
warning: warning:
'rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-200', 'rounded-lg border border-[var(--tone-warning-border)] bg-[var(--tone-warning-bg)] px-3 py-2 text-sm leading-5 text-[var(--tone-warning-fg)]',
danger: danger:
'rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm leading-5 text-rose-800 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-200', 'rounded-lg border border-[var(--tone-danger-border)] bg-[var(--tone-danger-bg)] px-3 py-2 text-sm leading-5 text-[var(--tone-danger-fg)]',
} }
export function toneClass(tone: Tone) { export function toneClass(tone: Tone) {
const tones: Record<Tone, string> = { const tones: Record<Tone, string> = {
neutral: neutral: 'border-border bg-muted text-muted-foreground',
'border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
success: success:
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300', 'border-[var(--tone-success-border)] bg-[var(--tone-success-bg)] text-[var(--tone-success-fg)]',
warning: warning:
'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-300', 'border-[var(--tone-warning-border)] bg-[var(--tone-warning-bg)] text-[var(--tone-warning-fg)]',
danger: danger:
'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-300', 'border-[var(--tone-danger-border)] bg-[var(--tone-danger-bg)] text-[var(--tone-danger-fg)]',
info: 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/70 dark:bg-sky-950/50 dark:text-sky-300', info: 'border-[var(--tone-info-border)] bg-[var(--tone-info-bg)] text-[var(--tone-info-fg)]',
} }
return `inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${tones[tone]}` return `inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${tones[tone]}`
} }
@@ -11,6 +11,10 @@ export const buttonVariants = cva(
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
danger:
'border border-destructive/30 bg-destructive/10 text-destructive hover:border-destructive/40 hover:bg-destructive/15 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
admin:
'bg-[var(--tone-info-strong)] text-white hover:bg-[var(--tone-info-strong-hover)] focus-visible:ring-[var(--tone-info-border)]',
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+33 -37
View File
@@ -65,6 +65,20 @@
--accent: oklch(0.94 0.045 150); --accent: oklch(0.94 0.045 150);
--accent-foreground: oklch(0.23 0.07 150); --accent-foreground: oklch(0.23 0.07 150);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--tone-success-bg: oklch(0.965 0.045 150);
--tone-success-fg: oklch(0.36 0.11 150);
--tone-success-border: oklch(0.84 0.09 150);
--tone-warning-bg: oklch(0.97 0.05 88);
--tone-warning-fg: oklch(0.42 0.1 70);
--tone-warning-border: oklch(0.85 0.11 82);
--tone-danger-bg: oklch(0.96 0.035 18);
--tone-danger-fg: oklch(0.48 0.16 24);
--tone-danger-border: oklch(0.84 0.09 20);
--tone-info-bg: oklch(0.96 0.035 225);
--tone-info-fg: oklch(0.39 0.1 230);
--tone-info-border: oklch(0.84 0.08 225);
--tone-info-strong: oklch(0.48 0.13 230);
--tone-info-strong-hover: oklch(0.42 0.13 230);
--border: oklch(0.92 0.004 286.32); --border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32);
--ring: oklch(0.58 0.12 150); --ring: oklch(0.58 0.12 150);
@@ -99,6 +113,20 @@
--accent: oklch(0.274 0.006 286.033); --accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--tone-success-bg: oklch(0.28 0.055 150 / 58%);
--tone-success-fg: oklch(0.82 0.12 150);
--tone-success-border: oklch(0.43 0.09 150 / 75%);
--tone-warning-bg: oklch(0.3 0.06 70 / 58%);
--tone-warning-fg: oklch(0.84 0.13 82);
--tone-warning-border: oklch(0.48 0.1 74 / 75%);
--tone-danger-bg: oklch(0.31 0.075 24 / 58%);
--tone-danger-fg: oklch(0.82 0.12 20);
--tone-danger-border: oklch(0.48 0.11 22 / 75%);
--tone-info-bg: oklch(0.31 0.06 230 / 58%);
--tone-info-fg: oklch(0.82 0.11 225);
--tone-info-border: oklch(0.48 0.1 230 / 75%);
--tone-info-strong: oklch(0.62 0.14 230);
--tone-info-strong-hover: oklch(0.68 0.14 230);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938); --ring: oklch(0.552 0.016 285.938);
@@ -138,54 +166,23 @@
} }
} }
.dark .bg-white, .dark .bg-white {
.dark .bg-white\/95 {
background-color: rgb(24 24 27); background-color: rgb(24 24 27);
} }
.dark .bg-zinc-50, .dark .bg-zinc-50 {
.dark .bg-zinc-50\/70 {
background-color: rgb(9 9 11 / 0.62); background-color: rgb(9 9 11 / 0.62);
} }
.dark .bg-emerald-50, .dark .bg-emerald-50 {
.dark .bg-emerald-50\/60,
.dark .bg-emerald-50\/70 {
background-color: rgb(2 44 34 / 0.42); background-color: rgb(2 44 34 / 0.42);
} }
.dark .bg-sky-50, .dark .border-zinc-200 {
.dark .bg-sky-50\/70 {
background-color: rgb(8 47 73 / 0.46);
}
.dark .bg-amber-50,
.dark .bg-amber-50\/70,
.dark .bg-amber-50\/80 {
background-color: rgb(69 26 3 / 0.42);
}
.dark .bg-rose-50,
.dark .bg-rose-50\/70 {
background-color: rgb(76 5 25 / 0.42);
}
.dark .border-zinc-200,
.dark .border-zinc-200\/80 {
border-color: rgb(39 39 42); border-color: rgb(39 39 42);
} }
.dark .divide-zinc-200 > :not(:last-child) {
border-color: rgb(39 39 42);
}
.dark .text-zinc-950,
.dark .text-zinc-900 {
color: rgb(244 244 245);
}
.dark .text-zinc-800, .dark .text-zinc-800,
.dark .text-zinc-700,
.dark .text-zinc-600 { .dark .text-zinc-600 {
color: rgb(212 212 216); color: rgb(212 212 216);
} }
@@ -195,7 +192,6 @@
color: rgb(161 161 170); color: rgb(161 161 170);
} }
.dark .hover\:text-zinc-950:hover, .dark .hover\:text-zinc-950:hover {
.dark .hover\:text-zinc-900:hover {
color: rgb(250 250 250); color: rgb(250 250 250);
} }
+72 -100
View File
@@ -22,15 +22,8 @@ import {
import { useAuth } from '@/app/auth' import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router' import { useRouter } from '@/app/router'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { import { alertClass, cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui'
alertClass, import { Button } from '@/components/ui/button'
buttonBase,
buttonTone,
cardClass,
inputClass,
sectionHeaderClass,
toneClass,
} from '@/components/ui'
import { import {
cronLabel, cronLabel,
extractErrorMessage, extractErrorMessage,
@@ -162,26 +155,28 @@ onMounted(load)
:class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']" :class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']"
> >
<span>未设置邮箱</span> <span>未设置邮箱</span>
<button <Button
class="font-semibold hover:text-sky-950 dark:hover:text-sky-100" variant="ghost"
class="font-semibold"
type="button" type="button"
@click="router.navigate('/settings')" @click="router.navigate('/settings')"
> >
设置 设置
</button> </Button>
</div> </div>
<div <div
v-if="needsPassword" v-if="needsPassword"
:class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']" :class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']"
> >
<span>未设置登录密码</span> <span>未设置登录密码</span>
<button <Button
class="font-semibold hover:text-sky-950 dark:hover:text-sky-100" variant="ghost"
class="font-semibold"
type="button" type="button"
@click="router.navigate('/settings')" @click="router.navigate('/settings')"
> >
设置 设置
</button> </Button>
</div> </div>
<div <div
v-if="tokenStatus && !tokenStatus.is_valid" v-if="tokenStatus && !tokenStatus.is_valid"
@@ -191,26 +186,28 @@ onMounted(load)
<AlertTriangle class="size-4 shrink-0" /> <AlertTriangle class="size-4 shrink-0" />
打卡凭证已过期 打卡凭证已过期
</span> </span>
<button <Button
class="font-semibold hover:text-amber-950 dark:hover:text-amber-100" variant="ghost"
class="font-semibold"
type="button" type="button"
@click="router.navigate('/login')" @click="router.navigate('/login')"
> >
刷新 刷新
</button> </Button>
</div> </div>
<div <div
v-if="tasks.length === 0" v-if="tasks.length === 0"
:class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']" :class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']"
> >
<span>暂无打卡任务</span> <span>暂无打卡任务</span>
<button <Button
class="font-semibold hover:text-sky-950 dark:hover:text-sky-100" variant="ghost"
class="font-semibold"
type="button" type="button"
@click="router.navigate('/tasks')" @click="router.navigate('/tasks')"
> >
创建 创建
</button> </Button>
</div> </div>
</div> </div>
@@ -218,10 +215,10 @@ onMounted(load)
<div :class="[cardClass, 'overflow-hidden']"> <div :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<CalendarDays class="size-4 text-emerald-700" /> <CalendarDays class="size-4 text-[var(--tone-success-fg)]" />
<h2 class="font-semibold">手动打卡</h2> <h2 class="font-semibold">手动打卡</h2>
</div> </div>
<span class="text-sm text-zinc-500">{{ activeTasks }} 个启用</span> <span class="text-sm text-muted-foreground">{{ activeTasks }} 个启用</span>
</div> </div>
<div class="p-4"> <div class="p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -230,24 +227,23 @@ onMounted(load)
{{ task.name || `任务 #${task.id}` }} · {{ task.is_active ? '启用' : '停用' }} {{ task.name || `任务 #${task.id}` }} · {{ task.is_active ? '启用' : '停用' }}
</option> </option>
</select> </select>
<button <Button
:class="[buttonBase, buttonTone.primary]"
:disabled="!selectedTaskId || checkInLoading" :disabled="!selectedTaskId || checkInLoading"
type="button" type="button"
@click="manualCheckIn" @click="manualCheckIn"
> >
<CalendarDays class="size-4" /> <CalendarDays class="size-4" />
{{ checkInLoading ? '打卡中' : '立即打卡' }} {{ checkInLoading ? '打卡中' : '立即打卡' }}
</button> </Button>
</div> </div>
<div <div
v-if="selectedTask" v-if="selectedTask"
class="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm dark:border-zinc-800 dark:bg-zinc-950" class="mt-4 rounded-lg border border-border bg-muted p-4 text-sm"
> >
<div class="font-medium text-zinc-900 dark:text-zinc-100"> <div class="font-medium text-foreground">
{{ selectedTask.name || `任务 #${selectedTask.id}` }} {{ selectedTask.name || `任务 #${selectedTask.id}` }}
</div> </div>
<div class="mt-1 text-zinc-500 dark:text-zinc-400"> <div class="mt-1 text-muted-foreground">
ThreadId: {{ selectedTask.thread_id || '未解析' }} · ThreadId: {{ selectedTask.thread_id || '未解析' }} ·
{{ cronLabel(selectedTask.cron_expression) }} {{ cronLabel(selectedTask.cron_expression) }}
</div> </div>
@@ -256,15 +252,15 @@ onMounted(load)
<div v-if="error" :class="[alertClass.danger, 'mt-4']">{{ error }}</div> <div v-if="error" :class="[alertClass.danger, 'mt-4']">{{ error }}</div>
<div <div
v-if="latestStatus" v-if="latestStatus"
class="mt-4 rounded-lg border border-zinc-200 bg-white p-4 text-sm dark:border-zinc-800 dark:bg-zinc-950" class="mt-4 rounded-lg border border-border bg-background p-4 text-sm"
> >
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-zinc-900 dark:text-zinc-100">本次打卡</span> <span class="font-semibold text-foreground">本次打卡</span>
<span :class="toneClass(statusTone(latestStatus.status))">{{ <span :class="toneClass(statusTone(latestStatus.status))">{{
statusLabel(latestStatus.status) statusLabel(latestStatus.status)
}}</span> }}</span>
</div> </div>
<p class="mt-2 text-zinc-500 dark:text-zinc-400"> <p class="mt-2 text-muted-foreground">
{{ {{
latestStatus.response_text || latestStatus.error_message || '正在等待后端返回结果。' latestStatus.response_text || latestStatus.error_message || '正在等待后端返回结果。'
}} }}
@@ -272,15 +268,15 @@ onMounted(load)
</div> </div>
<div <div
v-else-if="lastRecord" v-else-if="lastRecord"
class="mt-4 rounded-lg border border-zinc-200 bg-white p-4 text-sm dark:border-zinc-800 dark:bg-zinc-950" class="mt-4 rounded-lg border border-border bg-background p-4 text-sm"
> >
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-zinc-900 dark:text-zinc-100">上次打卡</span> <span class="font-semibold text-foreground">上次打卡</span>
<span :class="toneClass(statusTone(lastRecord.status))">{{ <span :class="toneClass(statusTone(lastRecord.status))">{{
statusLabel(lastRecord.status) statusLabel(lastRecord.status)
}}</span> }}</span>
</div> </div>
<p class="mt-2 text-zinc-500 dark:text-zinc-400"> <p class="mt-2 text-muted-foreground">
{{ formatDateTime(lastRecord.check_in_time) }} · {{ formatDateTime(lastRecord.check_in_time) }} ·
{{ lastRecord.response_text || lastRecord.error_message || '无响应内容' }} {{ lastRecord.response_text || lastRecord.error_message || '无响应内容' }}
</p> </p>
@@ -291,14 +287,14 @@ onMounted(load)
<div :class="[cardClass, 'overflow-hidden']"> <div :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<KeyRound class="size-4 text-emerald-700" /> <KeyRound class="size-4 text-[var(--tone-success-fg)]" />
<h2 class="font-semibold">授权</h2> <h2 class="font-semibold">授权</h2>
</div> </div>
<span :class="toneClass(tokenTone)">{{ tokenLabel }}</span> <span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
</div> </div>
<div class="grid gap-3 p-4 text-sm"> <div class="grid gap-3 p-4 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-zinc-500">剩余</span> <span class="text-muted-foreground">剩余</span>
<span class="font-medium"> <span class="font-medium">
{{ {{
tokenStatus?.days_until_expiry == null tokenStatus?.days_until_expiry == null
@@ -308,18 +304,18 @@ onMounted(load)
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-zinc-500">预警</span> <span class="text-muted-foreground">预警</span>
<span>{{ tokenStatus?.expiring_soon ? '是' : '否' }}</span> <span>{{ tokenStatus?.expiring_soon ? '是' : '否' }}</span>
</div> </div>
<div class="text-sm text-zinc-500 dark:text-zinc-400">{{ tokenDetail }}</div> <div class="text-sm text-muted-foreground">{{ tokenDetail }}</div>
<button <Button
:class="[buttonBase, tokenStatus?.is_valid ? buttonTone.secondary : buttonTone.primary]" :variant="tokenStatus?.is_valid ? 'outline' : 'default'"
type="button" type="button"
@click="router.navigate('/login')" @click="router.navigate('/login')"
> >
<QrCode class="size-4" /> <QrCode class="size-4" />
扫码刷新 扫码刷新
</button> </Button>
</div> </div>
</div> </div>
</section> </section>
@@ -327,49 +323,37 @@ onMounted(load)
<section :class="[cardClass, 'overflow-hidden']"> <section :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UserRound class="size-4 text-emerald-700" /> <UserRound class="size-4 text-[var(--tone-success-fg)]" />
<h2 class="font-semibold">个人信息</h2> <h2 class="font-semibold">个人信息</h2>
</div> </div>
<button <Button variant="outline" type="button" @click="router.navigate('/settings')">
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="router.navigate('/settings')"
>
个人设置 个人设置
</button> </Button>
</div> </div>
<div class="grid gap-3 p-4 text-sm md:grid-cols-4"> <div class="grid gap-3 p-4 text-sm md:grid-cols-4">
<div <div class="rounded-lg border border-border bg-muted px-3 py-2">
class="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950" <div class="text-muted-foreground">用户名</div>
> <div class="mt-1 font-medium text-foreground">
<div class="text-zinc-500">用户名</div>
<div class="mt-1 font-medium text-zinc-900 dark:text-zinc-100">
{{ auth.state.user?.alias || '未登录' }} {{ auth.state.user?.alias || '未登录' }}
</div> </div>
</div> </div>
<div <div class="rounded-lg border border-border bg-muted px-3 py-2">
class="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950" <div class="text-muted-foreground">角色</div>
>
<div class="text-zinc-500">角色</div>
<div class="mt-1"> <div class="mt-1">
<span :class="toneClass(auth.state.user?.role === 'admin' ? 'danger' : 'info')"> <span :class="toneClass(auth.state.user?.role === 'admin' ? 'danger' : 'info')">
{{ auth.state.user?.role === 'admin' ? '管理员' : '普通用户' }} {{ auth.state.user?.role === 'admin' ? '管理员' : '普通用户' }}
</span> </span>
</div> </div>
</div> </div>
<div <div class="rounded-lg border border-border bg-muted px-3 py-2">
class="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950" <div class="text-muted-foreground">邮箱</div>
> <div class="mt-1 font-medium text-foreground">
<div class="text-zinc-500">邮箱</div>
<div class="mt-1 font-medium text-zinc-900 dark:text-zinc-100">
{{ auth.state.user?.email || '未设置' }} {{ auth.state.user?.email || '未设置' }}
</div> </div>
</div> </div>
<div <div class="rounded-lg border border-border bg-muted px-3 py-2">
class="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950" <div class="text-muted-foreground">注册时间</div>
> <div class="mt-1 font-medium text-foreground">
<div class="text-zinc-500">注册时间</div>
<div class="mt-1 font-medium text-zinc-900 dark:text-zinc-100">
{{ formatDateTime(auth.state.user?.created_at) }} {{ formatDateTime(auth.state.user?.created_at) }}
</div> </div>
</div> </div>
@@ -381,48 +365,40 @@ onMounted(load)
<div> <div>
<h2 class="font-semibold">任务概览</h2> <h2 class="font-semibold">任务概览</h2>
</div> </div>
<button <Button variant="outline" type="button" @click="router.navigate('/tasks')">
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="router.navigate('/tasks')"
>
管理任务 管理任务
</button> </Button>
</div> </div>
<div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-4"> <div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-4">
<div <div class="rounded-lg border border-border bg-muted p-3">
class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-zinc-500">任务总数</span> <span class="text-sm text-muted-foreground">任务总数</span>
<CheckCircle2 class="size-4 text-emerald-600" /> <CheckCircle2 class="size-4 text-[var(--tone-success-fg)]" />
</div> </div>
<div class="mt-3 text-3xl font-semibold">{{ tasks.length }}</div> <div class="mt-3 text-3xl font-semibold">{{ tasks.length }}</div>
<p class="mt-1 text-sm text-zinc-500"> <p class="mt-1 text-sm text-muted-foreground">
{{ activeTasks }} 启用 · {{ inactiveTasks }} 停用 {{ activeTasks }} 启用 · {{ inactiveTasks }} 停用
</p> </p>
</div> </div>
<div <div class="rounded-lg border border-border bg-muted p-3">
class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-zinc-500">最近成功</span> <span class="text-sm text-muted-foreground">最近成功</span>
<Activity class="size-4 text-zinc-700" /> <Activity class="size-4 text-foreground" />
</div> </div>
<div class="mt-3 text-3xl font-semibold">{{ successToday }}</div> <div class="mt-3 text-3xl font-semibold">{{ successToday }}</div>
<p class="mt-1 text-sm text-zinc-500">最近记录</p> <p class="mt-1 text-sm text-muted-foreground">最近记录</p>
</div> </div>
<div <div class="rounded-lg border border-border bg-muted p-3 md:col-span-2">
class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 md:col-span-2 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-zinc-500">下次定时</span> <span class="text-sm text-muted-foreground">下次定时</span>
<Clock class="size-4 text-amber-600" /> <Clock class="size-4 text-[var(--tone-warning-fg)]" />
</div> </div>
<div class="mt-3 text-lg font-semibold"> <div class="mt-3 text-lg font-semibold">
{{ cronLabel(nextActiveTask?.cron_expression) }} {{ cronLabel(nextActiveTask?.cron_expression) }}
</div> </div>
<p class="mt-1 text-sm text-zinc-500">{{ nextActiveTask?.name || '无启用任务' }}</p> <p class="mt-1 text-sm text-muted-foreground">
{{ nextActiveTask?.name || '无启用任务' }}
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -432,16 +408,12 @@ onMounted(load)
<div> <div>
<h2 class="font-semibold">最近记录</h2> <h2 class="font-semibold">最近记录</h2>
</div> </div>
<button <Button variant="outline" type="button" @click="router.navigate('/records')">
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="router.navigate('/records')"
>
查看全部 查看全部
</button> </Button>
</div> </div>
<StateBlock v-if="records.length === 0" title="暂无记录" /> <StateBlock v-if="records.length === 0" title="暂无记录" />
<div v-else class="divide-y divide-zinc-200 dark:divide-zinc-800"> <div v-else class="divide-y divide-border">
<div v-for="record in records" :key="record.id" class="px-4 py-3"> <div v-for="record in records" :key="record.id" class="px-4 py-3">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<span class="font-medium">{{ record.task_name || `任务 #${record.task_id}` }}</span> <span class="font-medium">{{ record.task_name || `任务 #${record.task_id}` }}</span>
@@ -449,7 +421,7 @@ onMounted(load)
statusLabel(record.status) statusLabel(record.status)
}}</span> }}</span>
</div> </div>
<div class="mt-1 text-sm text-zinc-500"> <div class="mt-1 text-sm text-muted-foreground">
{{ formatDateTime(record.check_in_time) }} · {{ formatDateTime(record.check_in_time) }} ·
{{ record.trigger_type ? statusLabel(record.trigger_type) : '未注明触发' }} {{ record.trigger_type ? statusLabel(record.trigger_type) : '未注明触发' }}
</div> </div>
+24 -39
View File
@@ -4,7 +4,8 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import { authApi } from '@/api' import { authApi } from '@/api'
import { useAuth } from '@/app/auth' import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router' import { useRouter } from '@/app/router'
import { alertClass, buttonBase, buttonTone, cardClass, inputClass } from '@/components/ui' import { alertClass, cardClass, inputClass, labelClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage } from '@/utils/format' import { extractErrorMessage } from '@/utils/format'
const router = useRouter() const router = useRouter()
@@ -112,32 +113,28 @@ onBeforeUnmount(() => {
<template> <template>
<main <main
class="flex min-h-[100dvh] items-center justify-center bg-zinc-50 px-4 py-8 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50" class="flex min-h-[100dvh] items-center justify-center bg-background px-4 py-8 text-foreground"
> >
<section class="w-full max-w-md"> <section class="w-full max-w-md">
<div :class="[cardClass, 'overflow-hidden']"> <div :class="[cardClass, 'overflow-hidden']">
<div class="border-b border-zinc-200 px-4 py-3 text-center dark:border-zinc-800"> <div class="border-b border-border px-4 py-3 text-center">
<div <div
class="mx-auto mb-3 flex size-10 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm" class="mx-auto mb-3 flex size-10 items-center justify-center rounded-lg bg-[var(--tone-info-strong)] text-background shadow-sm"
> >
<QrCode class="size-5" /> <QrCode class="size-5" />
</div> </div>
<h1 class="text-xl font-semibold tracking-normal text-zinc-950 dark:text-zinc-50"> <h1 class="text-xl font-semibold tracking-normal text-foreground">接龙自动打卡系统</h1>
接龙自动打卡系统
</h1>
</div> </div>
<div class="p-4"> <div class="p-4">
<div <div class="grid grid-cols-2 rounded-lg border border-border bg-muted p-1 text-sm">
class="grid grid-cols-2 rounded-lg border border-zinc-200 bg-zinc-50 p-1 text-sm dark:border-zinc-700 dark:bg-zinc-950"
>
<button <button
type="button" type="button"
class="rounded-md px-3 py-2 text-center font-medium transition" class="rounded-md px-3 py-2 text-center font-medium transition"
:class=" :class="
loginMode === 'qrcode' loginMode === 'qrcode'
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50' ? 'bg-background text-foreground shadow-sm'
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100' : 'text-muted-foreground hover:text-foreground'
" "
@click="switchMode('qrcode')" @click="switchMode('qrcode')"
> >
@@ -148,8 +145,8 @@ onBeforeUnmount(() => {
class="rounded-md px-3 py-2 text-center font-medium transition" class="rounded-md px-3 py-2 text-center font-medium transition"
:class=" :class="
loginMode === 'password' loginMode === 'password'
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50' ? 'bg-background text-foreground shadow-sm'
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100' : 'text-muted-foreground hover:text-foreground'
" "
@click="switchMode('password')" @click="switchMode('password')"
> >
@@ -163,10 +160,10 @@ onBeforeUnmount(() => {
@submit.prevent="loginWithPassword" @submit.prevent="loginWithPassword"
> >
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">用户名</span> <span :class="labelClass">用户名</span>
<div class="relative"> <div class="relative">
<UserRound <UserRound
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400 dark:text-zinc-500" class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/> />
<input <input
v-model="alias" v-model="alias"
@@ -178,10 +175,10 @@ onBeforeUnmount(() => {
</div> </div>
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">密码</span> <span :class="labelClass">密码</span>
<div class="relative"> <div class="relative">
<KeyRound <KeyRound
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400 dark:text-zinc-500" class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/> />
<input <input
v-model="password" v-model="password"
@@ -200,22 +197,18 @@ onBeforeUnmount(() => {
{{ info }} {{ info }}
</div> </div>
<button <Button class="w-full" type="submit" :disabled="!canSubmitPassword">
:class="[buttonBase, buttonTone.primary, 'w-full']"
:disabled="!canSubmitPassword"
type="submit"
>
<KeyRound class="size-4" /> <KeyRound class="size-4" />
{{ loading ? '登录中' : '登录' }} {{ loading ? '登录中' : '登录' }}
</button> </Button>
</form> </form>
<div v-else class="mt-5 grid gap-4"> <div v-else class="mt-5 grid gap-4">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">用户名</span> <span :class="labelClass">用户名</span>
<div class="relative"> <div class="relative">
<UserRound <UserRound
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400 dark:text-zinc-500" class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/> />
<input <input
v-model="alias" v-model="alias"
@@ -235,27 +228,19 @@ onBeforeUnmount(() => {
{{ info }} {{ info }}
</div> </div>
<button <Button class="w-full" type="button" :disabled="!canRequestQr" @click="requestQrCode">
:class="[buttonBase, buttonTone.primary, 'w-full']"
:disabled="!canRequestQr"
type="button"
@click="requestQrCode"
>
<QrCode class="size-4" /> <QrCode class="size-4" />
{{ loading ? '正在登录' : '扫码登录/注册' }} {{ loading ? '正在登录' : '扫码登录/注册' }}
</button> </Button>
<div <div v-if="qrImage" class="rounded-lg border border-border bg-muted p-4 text-center">
v-if="qrImage"
class="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-center dark:border-zinc-700 dark:bg-zinc-950"
>
<img <img
:src="qrImage.startsWith('data:') ? qrImage : `data:image/png;base64,${qrImage}`" :src="qrImage.startsWith('data:') ? qrImage : `data:image/png;base64,${qrImage}`"
alt="QQ 登录二维码" alt="QQ 登录二维码"
class="mx-auto size-48 rounded-md bg-white object-contain dark:bg-zinc-100" class="mx-auto size-48 rounded-md bg-background object-contain"
/> />
<button <button
class="mt-3 inline-flex items-center gap-2 text-sm font-medium text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100" class="mt-3 inline-flex items-center gap-2 text-sm font-medium text-muted-foreground transition hover:text-foreground"
type="button" type="button"
@click="requestQrCode" @click="requestQrCode"
> >
+6 -10
View File
@@ -1,20 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from '@/app/router' import { useRouter } from '@/app/router'
import { buttonBase, buttonTone } from '@/components/ui' import { Button } from '@/components/ui/button'
const router = useRouter() const router = useRouter()
</script> </script>
<template> <template>
<section class="rounded-lg border border-zinc-200 bg-white p-8 text-center shadow-sm"> <section
<h2 class="text-xl font-semibold">页面不存在</h2> class="rounded-lg border border-border bg-card p-8 text-center text-card-foreground shadow-sm dark:shadow-none"
<p class="mt-2 text-sm text-zinc-500">当前地址没有对应的新前端页面</p>
<button
:class="[buttonBase, buttonTone.primary, 'mt-5']"
type="button"
@click="router.navigate('/dashboard')"
> >
返回仪表盘 <h2 class="text-xl font-semibold">页面不存在</h2>
</button> <p class="mt-2 text-sm text-muted-foreground">当前地址没有对应的新前端页面</p>
<Button type="button" class="mt-5" @click="router.navigate('/dashboard')"> 返回仪表盘 </Button>
</section> </section>
</template> </template>
@@ -4,7 +4,8 @@ import { ref } from 'vue'
import { userApi } from '@/api' import { userApi } from '@/api'
import { useAuth } from '@/app/auth' import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router' import { useRouter } from '@/app/router'
import { alertClass, buttonBase, buttonTone, cardClass, toneClass } from '@/components/ui' import { alertClass, cardClass, toneClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage, formatFullDateTime } from '@/utils/format' import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
const auth = useAuth() const auth = useAuth()
@@ -31,41 +32,34 @@ async function refresh() {
<template> <template>
<section :class="[cardClass, 'mx-auto max-w-2xl overflow-hidden']"> <section :class="[cardClass, 'mx-auto max-w-2xl overflow-hidden']">
<div <div class="border-b border-[var(--tone-warning-border)] bg-[var(--tone-warning-bg)] p-6">
class="border-b border-amber-200 bg-amber-50/80 p-6 dark:border-amber-900/70 dark:bg-amber-950/30"
>
<span :class="toneClass('warning')">待审批</span> <span :class="toneClass('warning')">待审批</span>
<h2 class="mt-3 text-xl font-semibold">账号等待审批</h2> <h2 class="mt-3 text-xl font-semibold">账号等待审批</h2>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300"> <p class="mt-2 text-sm text-muted-foreground">
当前账号 当前账号
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录但还需要管理员审批后才能访问工作台 {{ auth.state.user?.alias ?? '未知用户' }} 已完成登录但还需要管理员审批后才能访问工作台
</p> </p>
</div> </div>
<div class="p-6"> <div class="p-6">
<dl class="mt-5 grid gap-3 sm:grid-cols-2"> <dl class="mt-5 grid gap-3 sm:grid-cols-2">
<div class="rounded-md border border-zinc-200 p-3 dark:border-zinc-800 dark:bg-zinc-950"> <div class="rounded-md border border-border bg-background p-3">
<dt class="text-xs text-zinc-500 dark:text-zinc-400">创建时间</dt> <dt class="text-xs text-muted-foreground">创建时间</dt>
<dd class="mt-1 text-sm font-medium"> <dd class="mt-1 text-sm font-medium">
{{ formatFullDateTime(auth.state.user?.created_at) }} {{ formatFullDateTime(auth.state.user?.created_at) }}
</dd> </dd>
</div> </div>
<div class="rounded-md border border-zinc-200 p-3 dark:border-zinc-800 dark:bg-zinc-950"> <div class="rounded-md border border-border bg-background p-3">
<dt class="text-xs text-zinc-500 dark:text-zinc-400">审批状态</dt> <dt class="text-xs text-muted-foreground">审批状态</dt>
<dd class="mt-1 text-sm font-medium">待审批</dd> <dd class="mt-1 text-sm font-medium">待审批</dd>
</div> </div>
</dl> </dl>
<div v-if="error" :class="[alertClass.danger, 'mt-4']"> <div v-if="error" :class="[alertClass.danger, 'mt-4']">
{{ error }} {{ error }}
</div> </div>
<button <Button class="mt-5" :disabled="loading" type="button" @click="refresh">
:class="[buttonBase, buttonTone.primary, 'mt-5']"
:disabled="loading"
type="button"
@click="refresh"
>
<RefreshCw class="size-4" :class="{ 'animate-spin': loading }" /> <RefreshCw class="size-4" :class="{ 'animate-spin': loading }" />
刷新审批状态 刷新审批状态
</button> </Button>
</div> </div>
</section> </section>
</template> </template>
+14 -25
View File
@@ -3,14 +3,8 @@ import { Search } from 'lucide-vue-next'
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { checkInApi, type CheckInRecord } from '@/api' import { checkInApi, type CheckInRecord } from '@/api'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { import { cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui'
buttonBase, import { Button } from '@/components/ui/button'
buttonTone,
cardClass,
inputClass,
sectionHeaderClass,
toneClass,
} from '@/components/ui'
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format' import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
const loading = ref(true) const loading = ref(true)
@@ -61,10 +55,10 @@ onMounted(load)
<option value="scheduler">定时</option> <option value="scheduler">定时</option>
<option value="admin">管理员</option> <option value="admin">管理员</option>
</select> </select>
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load"> <Button variant="outline" type="button" @click="load">
<Search class="size-4" /> <Search class="size-4" />
筛选 筛选
</button> </Button>
</div> </div>
<StateBlock v-if="loading" title="正在加载记录" type="loading" /> <StateBlock v-if="loading" title="正在加载记录" type="loading" />
@@ -78,7 +72,7 @@ onMounted(load)
/> />
<StateBlock v-else-if="records.length === 0" title="暂无记录" /> <StateBlock v-else-if="records.length === 0" title="暂无记录" />
<div v-else> <div v-else>
<div class="divide-y divide-zinc-200 dark:divide-zinc-800"> <div class="divide-y divide-border">
<article <article
v-for="record in records" v-for="record in records"
:key="record.id" :key="record.id"
@@ -88,15 +82,15 @@ onMounted(load)
<div class="truncate text-sm font-semibold"> <div class="truncate text-sm font-semibold">
{{ record.task_name || `任务 #${record.task_id}` }} {{ record.task_name || `任务 #${record.task_id}` }}
</div> </div>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400"> <div class="mt-1 text-xs text-muted-foreground">
{{ formatFullDateTime(record.check_in_time) }} {{ formatFullDateTime(record.check_in_time) }}
</div> </div>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm text-zinc-700 dark:text-zinc-200"> <p class="truncate text-sm text-foreground">
{{ record.response_text || record.error_message || '无响应内容' }} {{ record.response_text || record.error_message || '无响应内容' }}
</p> </p>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400"> <p class="mt-1 text-xs text-muted-foreground">
触发方式{{ statusLabel(record.trigger_type) }} 触发方式{{ statusLabel(record.trigger_type) }}
</p> </p>
</div> </div>
@@ -108,29 +102,24 @@ onMounted(load)
</article> </article>
</div> </div>
<div <div
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 dark:border-zinc-800 dark:bg-zinc-950/50 dark:text-zinc-400" class="flex flex-wrap items-center justify-between gap-3 border-t border-border bg-muted/55 px-4 py-3 text-sm text-muted-foreground"
> >
<span <span
> {{ total }} 当前 {{ filters.skip + 1 }} - > {{ total }} 当前 {{ filters.skip + 1 }} -
{{ Math.min(filters.skip + filters.limit, total) }}</span {{ Math.min(filters.skip + filters.limit, total) }}</span
> >
<div class="flex gap-2"> <div class="flex gap-2">
<button <Button variant="outline" :disabled="filters.skip === 0" type="button" @click="page(-1)">
:class="[buttonBase, buttonTone.secondary]"
:disabled="filters.skip === 0"
type="button"
@click="page(-1)"
>
上一页 上一页
</button> </Button>
<button <Button
:class="[buttonBase, buttonTone.secondary]" variant="outline"
:disabled="filters.skip + filters.limit >= total" :disabled="filters.skip + filters.limit >= total"
type="button" type="button"
@click="page(1)" @click="page(1)"
> >
下一页 下一页
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
+13 -13
View File
@@ -6,13 +6,13 @@ import { useAuth } from '@/app/auth'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { import {
alertClass, alertClass,
buttonBase,
buttonTone,
cardClass, cardClass,
inputClass, inputClass,
labelClass,
sectionHeaderClass, sectionHeaderClass,
toneClass, toneClass,
} from '@/components/ui' } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage } from '@/utils/format' import { extractErrorMessage } from '@/utils/format'
const auth = useAuth() const auth = useAuth()
@@ -89,16 +89,16 @@ onMounted(load)
</div> </div>
<div class="grid gap-4 p-4"> <div class="grid gap-4 p-4">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">别名</span> <span :class="labelClass">别名</span>
<input v-model="form.alias" :class="inputClass" required /> <input v-model="form.alias" :class="inputClass" required />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">邮箱</span> <span :class="labelClass">邮箱</span>
<input v-model="form.email" :class="inputClass" type="email" placeholder="用于打卡通知" /> <input v-model="form.email" :class="inputClass" type="email" placeholder="用于打卡通知" />
</label> </label>
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">当前密码</span> <span :class="labelClass">当前密码</span>
<input <input
v-model="form.current_password" v-model="form.current_password"
:class="inputClass" :class="inputClass"
@@ -107,7 +107,7 @@ onMounted(load)
/> />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">新密码</span> <span :class="labelClass">新密码</span>
<input <input
v-model="form.new_password" v-model="form.new_password"
:class="inputClass" :class="inputClass"
@@ -122,10 +122,10 @@ onMounted(load)
<div v-if="message" :class="alertClass.success"> <div v-if="message" :class="alertClass.success">
{{ message }} {{ message }}
</div> </div>
<button :class="[buttonBase, buttonTone.primary, 'w-fit']" :disabled="saving" type="submit"> <Button class="w-fit" :disabled="saving" type="submit">
<Save class="size-4" /> <Save class="size-4" />
{{ saving ? '保存中' : '保存设置' }} {{ saving ? '保存中' : '保存设置' }}
</button> </Button>
</div> </div>
</form> </form>
@@ -134,8 +134,8 @@ onMounted(load)
class="grid gap-2 border-b px-4 py-3" class="grid gap-2 border-b px-4 py-3"
:class=" :class="
token?.is_valid token?.is_valid
? 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/70 dark:bg-emerald-950/30' ? 'border-[var(--tone-success-border)] bg-[var(--tone-success-bg)]'
: 'border-rose-200 bg-rose-50/70 dark:border-rose-900/70 dark:bg-rose-950/30' : 'border-[var(--tone-danger-border)] bg-[var(--tone-danger-bg)]'
" "
> >
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
@@ -148,17 +148,17 @@ onMounted(load)
<div class="p-4"> <div class="p-4">
<div class="grid gap-3 text-sm"> <div class="grid gap-3 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-zinc-500">状态</span> <span class="text-muted-foreground">状态</span>
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{ <span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
token?.is_valid ? '可用' : '不可用' token?.is_valid ? '可用' : '不可用'
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-zinc-500">即将过期</span> <span class="text-muted-foreground">即将过期</span>
<span>{{ token?.expiring_soon ? '是' : '否' }}</span> <span>{{ token?.expiring_soon ? '是' : '否' }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-zinc-500">剩余天数</span> <span class="text-muted-foreground">剩余天数</span>
<span>{{ token?.days_until_expiry ?? '未知' }}</span> <span>{{ token?.days_until_expiry ?? '未知' }}</span>
</div> </div>
</div> </div>
+10 -18
View File
@@ -4,14 +4,8 @@ import { onMounted, reactive, ref } from 'vue'
import { checkInApi, taskApi, type CheckInRecord, type Task } from '@/api' import { checkInApi, taskApi, type CheckInRecord, type Task } from '@/api'
import { useRouter } from '@/app/router' import { useRouter } from '@/app/router'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { import { cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui'
buttonBase, import { Button } from '@/components/ui/button'
buttonTone,
cardClass,
inputClass,
sectionHeaderClass,
toneClass,
} from '@/components/ui'
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format' import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
const router = useRouter() const router = useRouter()
@@ -49,7 +43,7 @@ onMounted(load)
<div :class="[sectionHeaderClass, 'lg:grid-cols-[1fr_180px_180px_auto]']"> <div :class="[sectionHeaderClass, 'lg:grid-cols-[1fr_180px_180px_auto]']">
<div> <div>
<button <button
class="mb-2 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900" class="mb-2 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
type="button" type="button"
@click="router.navigate('/tasks')" @click="router.navigate('/tasks')"
> >
@@ -68,10 +62,10 @@ onMounted(load)
<option value="manual">手动</option> <option value="manual">手动</option>
<option value="scheduler">定时</option> <option value="scheduler">定时</option>
</select> </select>
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load"> <Button variant="outline" type="button" @click="load">
<Search class="size-4" /> <Search class="size-4" />
筛选 筛选
</button> </Button>
</div> </div>
<StateBlock v-if="loading" title="正在加载任务记录" type="loading" /> <StateBlock v-if="loading" title="正在加载任务记录" type="loading" />
@@ -84,20 +78,20 @@ onMounted(load)
@action="load" @action="load"
/> />
<StateBlock v-else-if="records.length === 0" title="暂无记录" /> <StateBlock v-else-if="records.length === 0" title="暂无记录" />
<div v-else class="divide-y divide-zinc-200"> <div v-else class="divide-y divide-border">
<article <article
v-for="record in records" v-for="record in records"
:key="record.id" :key="record.id"
class="grid gap-3 p-3 md:grid-cols-[180px_minmax(0,1fr)_auto] md:items-center" class="grid gap-3 p-3 md:grid-cols-[180px_minmax(0,1fr)_auto] md:items-center"
> >
<div class="text-sm text-zinc-500 dark:text-zinc-400"> <div class="text-sm text-muted-foreground">
{{ formatFullDateTime(record.check_in_time) }} {{ formatFullDateTime(record.check_in_time) }}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-sm text-zinc-700 dark:text-zinc-200"> <div class="truncate text-sm text-foreground">
{{ record.response_text || record.error_message || '无响应内容' }} {{ record.response_text || record.error_message || '无响应内容' }}
</div> </div>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400"> <div class="mt-1 text-xs text-muted-foreground">
触发{{ statusLabel(record.trigger_type) }} 触发{{ statusLabel(record.trigger_type) }}
</div> </div>
</div> </div>
@@ -107,9 +101,7 @@ onMounted(load)
}}</span> }}</span>
</div> </div>
</article> </article>
<div <div class="border-t border-border bg-muted/55 px-4 py-3 text-sm text-muted-foreground">
class="border-t border-zinc-200 bg-zinc-50/70 px-4 py-3 text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950/50 dark:text-zinc-400"
>
{{ total }} 条记录 {{ total }} 条记录
</div> </div>
</div> </div>
+36 -54
View File
@@ -15,14 +15,14 @@ import { useRouter } from '@/app/router'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { import {
alertClass, alertClass,
buttonBase,
buttonTone,
cardClass, cardClass,
inputClass, inputClass,
labelClass,
sectionHeaderClass, sectionHeaderClass,
textareaClass, textareaClass,
toneClass, toneClass,
} from '@/components/ui' } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { import {
cronLabel, cronLabel,
extractErrorMessage, extractErrorMessage,
@@ -214,16 +214,16 @@ onMounted(load)
<div> <div>
<h2 class="font-semibold">从模板创建任务</h2> <h2 class="font-semibold">从模板创建任务</h2>
</div> </div>
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load"> <Button variant="outline" type="button" @click="load">
<RefreshCw class="size-4" /> <RefreshCw class="size-4" />
刷新 刷新
</button> </Button>
</div> </div>
<form class="grid gap-4 p-4" @submit.prevent="createTask"> <form class="grid gap-4 p-4" @submit.prevent="createTask">
<div class="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)_220px]"> <div class="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)_220px]">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">模板</span> <span :class="labelClass">模板</span>
<select v-model.number="selectedTemplateId" :class="inputClass"> <select v-model.number="selectedTemplateId" :class="inputClass">
<option v-for="template in templates" :key="template.id" :value="template.id"> <option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.name }} {{ template.name }}
@@ -231,39 +231,35 @@ onMounted(load)
</select> </select>
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">任务名称</span> <span :class="labelClass">任务名称</span>
<input v-model="createForm.task_name" :class="inputClass" placeholder="可选" /> <input v-model="createForm.task_name" :class="inputClass" placeholder="可选" />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400" <span :class="labelClass">接龙 ThreadId</span>
>接龙 ThreadId</span
>
<input v-model="createForm.thread_id" :class="inputClass" required /> <input v-model="createForm.thread_id" :class="inputClass" required />
</label> </label>
</div> </div>
<div class="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)] md:items-end"> <div class="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)] md:items-end">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">Cron 表达式</span> <span :class="labelClass">Cron 表达式</span>
<input <input
v-model="createForm.cron_expression" v-model="createForm.cron_expression"
:class="inputClass" :class="inputClass"
placeholder="0 20 * * *" placeholder="0 20 * * *"
/> />
</label> </label>
<button <Button
:class="[buttonBase, buttonTone.primary, 'w-full md:w-fit']" class="w-full md:w-fit"
:disabled="creating || !selectedTemplateId || !createForm.thread_id" :disabled="creating || !selectedTemplateId || !createForm.thread_id"
type="submit" type="submit"
> >
<Plus class="size-4" /> <Plus class="size-4" />
{{ creating ? '创建中' : '创建任务' }} {{ creating ? '创建中' : '创建任务' }}
</button> </Button>
</div> </div>
<div v-if="fieldEntries.length" class="grid gap-3 md:grid-cols-2"> <div v-if="fieldEntries.length" class="grid gap-3 md:grid-cols-2">
<label v-for="[key, field] in fieldEntries" :key="key" class="grid gap-2"> <label v-for="[key, field] in fieldEntries" :key="key" class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">{{ <span :class="labelClass">{{ field?.display_name ?? key }}</span>
field?.display_name ?? key
}}</span>
<select <select
v-if="field?.field_type === 'select'" v-if="field?.field_type === 'select'"
v-model="createForm.field_values[key]" v-model="createForm.field_values[key]"
@@ -320,12 +316,12 @@ onMounted(load)
<h2 class="font-semibold">任务列表</h2> <h2 class="font-semibold">任务列表</h2>
</div> </div>
<span <span
class="rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-600 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-300" class="rounded-full border border-border bg-background px-3 py-1 text-xs font-medium text-muted-foreground"
> >
{{ tasks.length }} 个任务 {{ tasks.length }} 个任务
</span> </span>
</div> </div>
<div class="divide-y divide-zinc-200 dark:divide-zinc-800"> <div class="divide-y divide-border">
<article v-for="task in tasks" :key="task.id" class="p-3 sm:p-4"> <article v-for="task in tasks" :key="task.id" class="p-3 sm:p-4">
<div class="grid gap-3 lg:grid-cols-[1fr_auto]"> <div class="grid gap-3 lg:grid-cols-[1fr_auto]">
<div class="min-w-0"> <div class="min-w-0">
@@ -350,92 +346,78 @@ onMounted(load)
{{ statusLabel(status.status) }} {{ statusLabel(status.status) }}
</span> </span>
</div> </div>
<div <div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground">
class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"
>
<span>ThreadId: {{ task.thread_id || '未解析' }}</span> <span>ThreadId: {{ task.thread_id || '未解析' }}</span>
<span>{{ cronLabel(task.cron_expression) }}</span> <span>{{ cronLabel(task.cron_expression) }}</span>
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-2 lg:justify-end"> <div class="flex flex-wrap gap-2 lg:justify-end">
<button <Button
:class="[buttonBase, buttonTone.secondary]" variant="outline"
type="button" type="button"
@click="router.navigate(`/tasks/${task.id}/records`)" @click="router.navigate(`/tasks/${task.id}/records`)"
> >
记录 记录
</button> </Button>
<button <Button
:class="[buttonBase, buttonTone.secondary]" variant="outline"
:disabled="actionId === task.id" :disabled="actionId === task.id"
type="button" type="button"
@click="manualCheckIn(task)" @click="manualCheckIn(task)"
> >
<Play class="size-4" /> <Play class="size-4" />
打卡 打卡
</button> </Button>
<button <Button
:class="[buttonBase, buttonTone.secondary]" variant="outline"
:disabled="actionId === task.id" :disabled="actionId === task.id"
type="button" type="button"
@click="toggleTask(task)" @click="toggleTask(task)"
> >
<Check class="size-4" /> <Check class="size-4" />
{{ task.is_active ? '停用' : '启用' }} {{ task.is_active ? '停用' : '启用' }}
</button> </Button>
<button <Button variant="ghost" type="button" @click="startEdit(task)">
:class="[buttonBase, buttonTone.ghost]"
type="button"
@click="startEdit(task)"
>
<Edit3 class="size-4" /> <Edit3 class="size-4" />
编辑 编辑
</button> </Button>
<button <Button
:class="[buttonBase, buttonTone.danger]" variant="danger"
:disabled="actionId === task.id" :disabled="actionId === task.id"
type="button" type="button"
@click="deleteTask(task)" @click="deleteTask(task)"
> >
<Trash2 class="size-4" /> <Trash2 class="size-4" />
删除 删除
</button> </Button>
</div> </div>
</div> </div>
<form <form
v-if="editingTaskId === task.id" v-if="editingTaskId === task.id"
class="mt-4 grid gap-3 rounded-lg border border-emerald-200 bg-emerald-50/60 p-3 dark:border-emerald-900/70 dark:bg-emerald-950/30" class="mt-4 grid gap-3 rounded-lg border border-[var(--tone-success-border)] bg-[var(--tone-success-bg)] p-3"
@submit.prevent="saveEdit(task.id)" @submit.prevent="saveEdit(task.id)"
> >
<div> <div>
<h4 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">编辑任务</h4> <h4 class="text-sm font-semibold text-foreground">编辑任务</h4>
</div> </div>
<div class="grid gap-3 md:grid-cols-2"> <div class="grid gap-3 md:grid-cols-2">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">任务名称</span> <span :class="labelClass">任务名称</span>
<input v-model="editForm.name" :class="inputClass" /> <input v-model="editForm.name" :class="inputClass" />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">Cron</span> <span :class="labelClass">Cron</span>
<input v-model="editForm.cron_expression" :class="inputClass" /> <input v-model="editForm.cron_expression" :class="inputClass" />
</label> </label>
</div> </div>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400" <span :class="labelClass">Payload JSON</span>
>Payload JSON</span
>
<textarea v-model="editForm.payload_config" :class="textareaClass" /> <textarea v-model="editForm.payload_config" :class="textareaClass" />
</label> </label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button :class="[buttonBase, buttonTone.primary]" type="submit">保存</button> <Button type="submit">保存</Button>
<button <Button variant="outline" type="button" @click="editingTaskId = null"> 取消 </Button>
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="editingTaskId = null"
>
取消
</button>
</div> </div>
</form> </form>
</article> </article>
@@ -2,7 +2,8 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { adminApi } from '@/api' import { adminApi } from '@/api'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { buttonBase, buttonTone, cardClass, inputClass, sectionHeaderClass } from '@/components/ui' import { cardClass, inputClass, sectionHeaderClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage } from '@/utils/format' import { extractErrorMessage } from '@/utils/format'
const loading = ref(true) const loading = ref(true)
@@ -41,9 +42,7 @@ onMounted(load)
max="2000" max="2000"
class="max-w-40" class="max-w-40"
/> />
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load"> <Button variant="outline" type="button" @click="load"> 刷新日志 </Button>
刷新日志
</button>
</div> </div>
</div> </div>
<StateBlock v-if="loading" title="正在加载日志" type="loading" /> <StateBlock v-if="loading" title="正在加载日志" type="loading" />
@@ -3,14 +3,8 @@ import { Search } from 'lucide-vue-next'
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { checkInApi, type CheckInRecord } from '@/api' import { checkInApi, type CheckInRecord } from '@/api'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { import { cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui'
buttonBase, import { Button } from '@/components/ui/button'
buttonTone,
cardClass,
inputClass,
sectionHeaderClass,
toneClass,
} from '@/components/ui'
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format' import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
const loading = ref(true) const loading = ref(true)
@@ -52,10 +46,10 @@ onMounted(load)
<option value="out_of_time">超出时间</option> <option value="out_of_time">超出时间</option>
</select> </select>
<input v-model.number="filters.limit" :class="inputClass" type="number" min="1" max="200" /> <input v-model.number="filters.limit" :class="inputClass" type="number" min="1" max="200" />
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load"> <Button variant="outline" type="button" @click="load">
<Search class="size-4" /> <Search class="size-4" />
筛选 筛选
</button> </Button>
</div> </div>
<StateBlock v-if="loading" title="正在加载记录" type="loading" /> <StateBlock v-if="loading" title="正在加载记录" type="loading" />
<StateBlock <StateBlock
@@ -67,7 +61,7 @@ onMounted(load)
@action="load" @action="load"
/> />
<StateBlock v-else-if="records.length === 0" title="暂无记录" /> <StateBlock v-else-if="records.length === 0" title="暂无记录" />
<div v-else class="divide-y divide-zinc-200 dark:divide-zinc-800"> <div v-else class="divide-y divide-border">
<article <article
v-for="record in records" v-for="record in records"
:key="record.id" :key="record.id"
@@ -77,7 +71,7 @@ onMounted(load)
<div class="truncate font-medium"> <div class="truncate font-medium">
{{ record.user_alias || record.user_email || `任务 #${record.task_id}` }} {{ record.user_alias || record.user_email || `任务 #${record.task_id}` }}
</div> </div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"> <div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground">
<span>{{ formatFullDateTime(record.check_in_time) }}</span> <span>{{ formatFullDateTime(record.check_in_time) }}</span>
<span>{{ record.response_text || record.error_message || '无响应内容' }}</span> <span>{{ record.response_text || record.error_message || '无响应内容' }}</span>
</div> </div>
@@ -86,7 +80,7 @@ onMounted(load)
<span :class="toneClass(statusTone(record.status))">{{ <span :class="toneClass(statusTone(record.status))">{{
statusLabel(record.status) statusLabel(record.status)
}}</span> }}</span>
<span class="text-sm text-zinc-500">{{ <span class="text-sm text-muted-foreground">{{
record.task_name || record.thread_id || '无任务名' record.task_name || record.thread_id || '无任务名'
}}</span> }}</span>
</div> </div>
@@ -2,7 +2,8 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { adminApi, type AdminStats } from '@/api' import { adminApi, type AdminStats } from '@/api'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { buttonBase, buttonTone, cardClass, sectionHeaderClass } from '@/components/ui' import { cardClass, sectionHeaderClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage } from '@/utils/format' import { extractErrorMessage } from '@/utils/format'
const loading = ref(true) const loading = ref(true)
@@ -39,36 +40,32 @@ onMounted(load)
<div> <div>
<h2 class="font-semibold">系统统计</h2> <h2 class="font-semibold">系统统计</h2>
</div> </div>
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">刷新</button> <Button variant="outline" type="button" @click="load">刷新</Button>
</div> </div>
<div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-4"> <div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-4">
<div <div class="rounded-lg border border-border bg-background p-3">
class="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-950" <div class="text-sm text-muted-foreground">用户</div>
>
<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-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 class="mt-1 text-sm text-muted-foreground">已审批 {{ stats?.users.active }}</div>
</div> </div>
<div <div class="rounded-lg border border-border bg-background p-3">
class="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-950" <div class="text-sm text-muted-foreground">任务</div>
>
<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-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 class="mt-1 text-sm text-muted-foreground">启用 {{ stats?.tasks.active }}</div>
</div> </div>
<div <div class="rounded-lg border border-border bg-background p-3">
class="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-950" <div class="text-sm text-muted-foreground">记录</div>
>
<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-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 class="mt-1 text-sm text-muted-foreground">
今日 {{ stats?.check_in_records.today }}
</div>
</div> </div>
<div <div
class="rounded-lg border border-amber-200 bg-amber-50/70 p-3 dark:border-amber-900/70 dark:bg-amber-950/30" class="rounded-lg border border-[var(--tone-warning-border)] bg-[var(--tone-warning-bg)] p-3"
> >
<div class="text-sm text-zinc-500">Token 预警</div> <div class="text-sm text-muted-foreground">Token 预警</div>
<div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.tokens.expiring_soon }}</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 class="mt-1 text-sm text-muted-foreground">7 天内过期</div>
</div> </div>
</div> </div>
</section> </section>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Eye, Plus, Save, Trash2 } from 'lucide-vue-next' import { Edit3, Eye, Plus, Save, Trash2 } from 'lucide-vue-next'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { templateApi, type Template, type TemplatePreview } from '@/api' import { templateApi, type Template, type TemplatePreview } from '@/api'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
@@ -167,7 +167,7 @@ onMounted(load)
action-label="新建模板" action-label="新建模板"
@action="startCreate" @action="startCreate"
/> />
<div v-else class="divide-y divide-zinc-200 dark:divide-zinc-800"> <div v-else class="divide-y divide-border">
<article <article
v-for="template in templates" v-for="template in templates"
:key="template.id" :key="template.id"
@@ -180,9 +180,7 @@ onMounted(load)
template.is_active ? '启用' : '停用' template.is_active ? '启用' : '停用'
}}</span> }}</span>
</div> </div>
<div <div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground">
class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"
>
<span>{{ template.description || '无描述' }}</span> <span>{{ template.description || '无描述' }}</span>
<span>{{ formatDateTime(template.created_at) }}</span> <span>{{ formatDateTime(template.created_at) }}</span>
</div> </div>
@@ -192,13 +190,11 @@ onMounted(load)
<Eye class="size-4" /> <Eye class="size-4" />
预览 预览
</Button> </Button>
<Button type="button" variant="outline" @click="startEdit(template)"> 编辑 </Button> <Button type="button" variant="outline" @click="startEdit(template)">
<Button <Edit3 class="size-4" />
type="button" 编辑
variant="outline" </Button>
class="border-rose-200 bg-rose-50 text-rose-700 hover:border-rose-300 hover:bg-rose-100 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-300 dark:hover:border-rose-800 dark:hover:bg-rose-900/40" <Button type="button" variant="danger" @click="remove(template)">
@click="remove(template)"
>
<Trash2 class="size-4" /> <Trash2 class="size-4" />
删除 删除
</Button> </Button>
@@ -223,9 +219,7 @@ onMounted(load)
<DialogContent <DialogContent
class="grid max-h-[calc(100dvh-2rem)] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100vw-2rem))] lg:max-w-[min(1120px,calc(100vw-3rem))]" class="grid max-h-[calc(100dvh-2rem)] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100vw-2rem))] lg:max-w-[min(1120px,calc(100vw-3rem))]"
> >
<DialogHeader <DialogHeader class="border-b border-border bg-muted/55 px-5 py-4">
class="border-b border-zinc-200 bg-zinc-50/70 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-950/50"
>
<DialogTitle>{{ editorTitle }}</DialogTitle> <DialogTitle>{{ editorTitle }}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -241,7 +235,7 @@ onMounted(load)
<input v-model="form.description" :class="inputClass" /> <input v-model="form.description" :class="inputClass" />
</label> </label>
<label <label
class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950" class="flex min-h-9 items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm"
> >
<input v-model="form.is_active" type="checkbox" /> <input v-model="form.is_active" type="checkbox" />
启用模板 启用模板
@@ -250,10 +244,7 @@ onMounted(load)
<TemplateConfigEditor v-model="form.field_config" @valid="editorValid = $event" /> <TemplateConfigEditor v-model="form.field_config" @valid="editorValid = $event" />
<div <div v-if="localPreviewPayload" class="rounded-lg border border-border p-3">
v-if="localPreviewPayload"
class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
>
<details> <details>
<summary class="cursor-pointer text-sm font-semibold">当前配置预览</summary> <summary class="cursor-pointer text-sm font-semibold">当前配置预览</summary>
<pre <pre
@@ -272,7 +263,7 @@ onMounted(load)
</div> </div>
<DialogFooter <DialogFooter
class="sticky bottom-0 border-t border-zinc-200 bg-white/95 px-5 py-4 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/95" class="sticky bottom-0 border-t border-border bg-background/95 px-5 py-4 backdrop-blur"
> >
<Button type="button" variant="outline" @click="editingId = null">取消</Button> <Button type="button" variant="outline" @click="editingId = null">取消</Button>
<Button :disabled="!editorValid" type="submit"> <Button :disabled="!editorValid" type="submit">
@@ -5,10 +5,9 @@ import { adminApi, userApi, type User } from '@/api'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { import {
alertClass, alertClass,
buttonBase,
buttonTone,
cardClass, cardClass,
inputClass, inputClass,
labelClass,
sectionHeaderClass, sectionHeaderClass,
toneClass, toneClass,
} from '@/components/ui' } from '@/components/ui'
@@ -106,17 +105,17 @@ onMounted(load)
</span> </span>
</div> </div>
<div <div
class="grid gap-3 border-b border-zinc-200 bg-zinc-50/70 p-4 sm:grid-cols-[minmax(0,1fr)_auto_auto] dark:border-zinc-800 dark:bg-zinc-950/50" class="grid gap-3 border-b border-border bg-muted/55 p-4 sm:grid-cols-[minmax(0,1fr)_auto_auto]"
> >
<input v-model="search" :class="inputClass" class="max-w-sm" placeholder="搜索别名" /> <input v-model="search" :class="inputClass" class="max-w-sm" placeholder="搜索别名" />
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load"> <Button variant="outline" type="button" @click="load">
<Search class="size-4" /> <Search class="size-4" />
搜索 搜索
</button> </Button>
<button :class="[buttonBase, buttonTone.primary]" type="button" @click="startCreate"> <Button type="button" @click="startCreate">
<UserPlus class="size-4" /> <UserPlus class="size-4" />
创建用户 创建用户
</button> </Button>
</div> </div>
<StateBlock v-if="loading" title="正在加载用户" type="loading" /> <StateBlock v-if="loading" title="正在加载用户" type="loading" />
<StateBlock <StateBlock
@@ -128,7 +127,7 @@ onMounted(load)
@action="load" @action="load"
/> />
<StateBlock v-else-if="users.length === 0" title="暂无用户" /> <StateBlock v-else-if="users.length === 0" title="暂无用户" />
<div v-else class="divide-y divide-zinc-200 dark:divide-zinc-800"> <div v-else class="divide-y divide-border">
<article <article
v-for="user in users" v-for="user in users"
:key="user.id" :key="user.id"
@@ -144,35 +143,24 @@ onMounted(load)
user.role user.role
}}</span> }}</span>
</div> </div>
<div <div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground">
class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"
>
<span>{{ user.email || '未设置邮箱' }}</span> <span>{{ user.email || '未设置邮箱' }}</span>
<span>{{ formatDateTime(user.created_at) }}</span> <span>{{ formatDateTime(user.created_at) }}</span>
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <Button v-if="!user.is_approved" type="button" @click="approve(user.id)">
v-if="!user.is_approved"
:class="[buttonBase, buttonTone.primary]"
type="button"
@click="approve(user.id)"
>
<Check class="size-4" /> <Check class="size-4" />
审批 审批
</button> </Button>
<button :class="[buttonBase, buttonTone.danger]" type="button" @click="reject(user.id)"> <Button variant="danger" type="button" @click="reject(user.id)">
<Trash2 class="size-4" /> <Trash2 class="size-4" />
删除 删除
</button> </Button>
<button <Button variant="outline" type="button" @click="startEdit(user)">
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="startEdit(user)"
>
<UserPlus class="size-4" /> <UserPlus class="size-4" />
编辑 编辑
</button> </Button>
</div> </div>
</article> </article>
</div> </div>
@@ -187,13 +175,13 @@ onMounted(load)
> >
<div class="grid justify-items-center gap-4"> <div class="grid justify-items-center gap-4">
<span <span
class="inline-flex size-12 items-center justify-center rounded-xl border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300" class="inline-flex size-12 items-center justify-center rounded-xl border border-[var(--tone-success-border)] bg-[var(--tone-success-bg)] text-[var(--tone-success-fg)]"
> >
<UserPlus class="size-5" /> <UserPlus class="size-5" />
</span> </span>
<div class="grid gap-1"> <div class="grid gap-1">
<h2 class="font-semibold">未选择用户</h2> <h2 class="font-semibold">未选择用户</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400">创建或从列表编辑</p> <p class="text-sm text-muted-foreground">创建或从列表编辑</p>
</div> </div>
<Button type="button" @click="startCreate"> <Button type="button" @click="startCreate">
<UserPlus class="size-4" /> <UserPlus class="size-4" />
@@ -210,29 +198,27 @@ onMounted(load)
]" ]"
@submit.prevent="save" @submit.prevent="save"
> >
<div <div class="border-b border-border bg-muted/55 px-4 py-3">
class="border-b border-zinc-200 bg-zinc-50/70 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-950/50"
>
<h2 class="font-semibold">{{ editingId === 'new' ? '创建用户' : '编辑用户' }}</h2> <h2 class="font-semibold">{{ editingId === 'new' ? '创建用户' : '编辑用户' }}</h2>
</div> </div>
<div class="grid gap-4 p-4"> <div class="grid gap-4 p-4">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">别名</span> <span :class="labelClass">别名</span>
<input v-model="form.alias" :class="inputClass" required /> <input v-model="form.alias" :class="inputClass" required />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">邮箱</span> <span :class="labelClass">邮箱</span>
<input v-model="form.email" :class="inputClass" type="email" /> <input v-model="form.email" :class="inputClass" type="email" />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">角色</span> <span :class="labelClass">角色</span>
<select v-model="form.role" :class="inputClass"> <select v-model="form.role" :class="inputClass">
<option value="user">user</option> <option value="user">user</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select> </select>
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">密码</span> <span :class="labelClass">密码</span>
<input <input
v-model="form.password" v-model="form.password"
:class="inputClass" :class="inputClass"
@@ -248,17 +234,11 @@ onMounted(load)
{{ error }} {{ error }}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button :class="[buttonBase, buttonTone.primary]" type="submit"> <Button type="submit">
<Save class="size-4" /> <Save class="size-4" />
保存 保存
</button> </Button>
<button <Button variant="outline" type="button" @click="editingId = null"> 取消 </Button>
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="editingId = null"
>
取消
</button>
</div> </div>
</div> </div>
</form> </form>