mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
feat(new-frontend): add theme and templates
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "node --test --experimental-strip-types src/app/theme.test.ts src/components/templates/template-config.test.ts",
|
||||||
"typecheck": "vue-tsc -b",
|
"typecheck": "vue-tsc -b",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
"lint:check": "eslint .",
|
"lint:check": "eslint .",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
THEME_STORAGE_KEY,
|
||||||
|
coerceThemeMode,
|
||||||
|
getResolvedTheme,
|
||||||
|
getStoredThemeMode,
|
||||||
|
nextThemeMode,
|
||||||
|
persistThemeMode,
|
||||||
|
type ThemeMode,
|
||||||
|
} from './theme.ts'
|
||||||
|
|
||||||
|
class MemoryStorage {
|
||||||
|
private readonly values = new Map<string, string>()
|
||||||
|
|
||||||
|
getItem(key: string) {
|
||||||
|
return this.values.get(key) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
this.values.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(key: string) {
|
||||||
|
this.values.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('coerces unknown theme values to system', () => {
|
||||||
|
assert.equal(coerceThemeMode('light'), 'light')
|
||||||
|
assert.equal(coerceThemeMode('dark'), 'dark')
|
||||||
|
assert.equal(coerceThemeMode('system'), 'system')
|
||||||
|
assert.equal(coerceThemeMode('sepia'), 'system')
|
||||||
|
assert.equal(coerceThemeMode(null), 'system')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves system mode from user preference', () => {
|
||||||
|
assert.equal(getResolvedTheme('system', true), 'dark')
|
||||||
|
assert.equal(getResolvedTheme('system', false), 'light')
|
||||||
|
assert.equal(getResolvedTheme('dark', false), 'dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('persists and restores theme mode', () => {
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
persistThemeMode('dark', storage)
|
||||||
|
|
||||||
|
assert.equal(storage.getItem(THEME_STORAGE_KEY), 'dark')
|
||||||
|
assert.equal(getStoredThemeMode(storage), 'dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cycles theme modes predictably', () => {
|
||||||
|
const sequence: ThemeMode[] = ['light', 'dark', 'system', 'light']
|
||||||
|
for (let index = 0; index < sequence.length - 1; index += 1) {
|
||||||
|
assert.equal(nextThemeMode(sequence[index]), sequence[index + 1])
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { computed, reactive } from 'vue'
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||||
|
export type ResolvedTheme = 'light' | 'dark'
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = 'checkin.theme.mode'
|
||||||
|
|
||||||
|
type ThemeStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
mode: ThemeMode
|
||||||
|
resolved: ResolvedTheme
|
||||||
|
initialized: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = reactive<ThemeState>({
|
||||||
|
mode: 'system',
|
||||||
|
resolved: 'light',
|
||||||
|
initialized: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
let mediaQuery: MediaQueryList | null = null
|
||||||
|
let mediaUnsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
function canUseDom() {
|
||||||
|
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceThemeMode(value: unknown): ThemeMode {
|
||||||
|
return value === 'light' || value === 'dark' || value === 'system' ? value : 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResolvedTheme(mode: ThemeMode, prefersDark: boolean): ResolvedTheme {
|
||||||
|
if (mode === 'dark') return 'dark'
|
||||||
|
if (mode === 'light') return 'light'
|
||||||
|
return prefersDark ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredThemeMode(storage?: ThemeStorage): ThemeMode {
|
||||||
|
if (!storage) return 'system'
|
||||||
|
return coerceThemeMode(storage.getItem(THEME_STORAGE_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistThemeMode(mode: ThemeMode, storage?: ThemeStorage) {
|
||||||
|
if (!storage) return
|
||||||
|
storage.setItem(THEME_STORAGE_KEY, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextThemeMode(mode: ThemeMode): ThemeMode {
|
||||||
|
if (mode === 'light') return 'dark'
|
||||||
|
if (mode === 'dark') return 'system'
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResolvedTheme(resolved: ResolvedTheme) {
|
||||||
|
if (!canUseDom()) return
|
||||||
|
document.documentElement.classList.toggle('dark', resolved === 'dark')
|
||||||
|
document.documentElement.style.colorScheme = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPrefersDark() {
|
||||||
|
if (!canUseDom()) return false
|
||||||
|
mediaQuery = mediaQuery ?? window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
return mediaQuery.matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncResolvedTheme() {
|
||||||
|
state.resolved = getResolvedTheme(state.mode, currentPrefersDark())
|
||||||
|
applyResolvedTheme(state.resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeSystemPreference() {
|
||||||
|
if (!canUseDom() || mediaUnsubscribe) return
|
||||||
|
mediaQuery = mediaQuery ?? window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const listener = () => {
|
||||||
|
if (state.mode === 'system') syncResolvedTheme()
|
||||||
|
}
|
||||||
|
mediaQuery.addEventListener('change', listener)
|
||||||
|
mediaUnsubscribe = () => mediaQuery?.removeEventListener('change', listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTheme() {
|
||||||
|
if (!canUseDom()) return
|
||||||
|
state.mode = getStoredThemeMode(window.localStorage)
|
||||||
|
syncResolvedTheme()
|
||||||
|
subscribeSystemPreference()
|
||||||
|
state.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThemeMode(mode: ThemeMode) {
|
||||||
|
state.mode = mode
|
||||||
|
if (canUseDom()) persistThemeMode(mode, window.localStorage)
|
||||||
|
syncResolvedTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cycleThemeMode() {
|
||||||
|
setThemeMode(nextThemeMode(state.mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeThemeListener() {
|
||||||
|
mediaUnsubscribe?.()
|
||||||
|
mediaUnsubscribe = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
modeLabel: computed(() => {
|
||||||
|
if (state.mode === 'light') return '亮色'
|
||||||
|
if (state.mode === 'dark') return '暗色'
|
||||||
|
return state.resolved === 'dark' ? '跟随系统:暗色' : '跟随系统:亮色'
|
||||||
|
}),
|
||||||
|
setThemeMode,
|
||||||
|
cycleThemeMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
|
Monitor,
|
||||||
|
MoonStar,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -18,9 +20,11 @@ import {
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useAuth } from '@/app/auth'
|
import { useAuth } from '@/app/auth'
|
||||||
import { useRouter } from '@/app/router'
|
import { useRouter } from '@/app/router'
|
||||||
|
import { useTheme } from '@/app/theme'
|
||||||
|
|
||||||
const { state: authState, isAdmin, logout } = useAuth()
|
const { state: authState, isAdmin, logout } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const theme = useTheme()
|
||||||
const mobileOpen = ref(false)
|
const mobileOpen = ref(false)
|
||||||
|
|
||||||
const userLinks = [
|
const userLinks = [
|
||||||
@@ -42,6 +46,7 @@ const title = computed(() => router.current.value.title)
|
|||||||
const isAdminRoute = computed(() => router.state.path.startsWith('/admin'))
|
const isAdminRoute = computed(() => router.state.path.startsWith('/admin'))
|
||||||
const approvalLabel = computed(() => (authState.user?.is_approved ? '已审批' : '待审批'))
|
const approvalLabel = computed(() => (authState.user?.is_approved ? '已审批' : '待审批'))
|
||||||
const roleLabel = computed(() => (isAdmin.value ? '管理员' : '普通用户'))
|
const roleLabel = computed(() => (isAdmin.value ? '管理员' : '普通用户'))
|
||||||
|
const themeLabel = computed(() => theme.modeLabel.value)
|
||||||
|
|
||||||
function go(path: string) {
|
function go(path: string) {
|
||||||
mobileOpen.value = false
|
mobileOpen.value = false
|
||||||
@@ -55,13 +60,15 @@ function signOut() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[100dvh] bg-zinc-50 text-zinc-950">
|
<div class="min-h-[100dvh] bg-zinc-50 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
|
||||||
<header class="sticky top-0 z-20 border-b border-zinc-200 bg-white/95 backdrop-blur">
|
<header
|
||||||
|
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="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex size-9 items-center justify-center rounded-md border border-zinc-200 bg-white text-zinc-700 shadow-sm lg:hidden"
|
class="inline-flex size-9 items-center justify-center rounded-md border border-zinc-200 bg-white text-zinc-700 shadow-sm lg:hidden dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
|
||||||
@click="mobileOpen = !mobileOpen"
|
@click="mobileOpen = !mobileOpen"
|
||||||
>
|
>
|
||||||
<X v-if="mobileOpen" class="size-4" />
|
<X v-if="mobileOpen" class="size-4" />
|
||||||
@@ -75,7 +82,7 @@ function signOut() {
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<div class="text-sm font-semibold leading-4">接龙自动打卡</div>
|
<div class="text-sm font-semibold leading-4">接龙自动打卡</div>
|
||||||
<div class="text-xs text-zinc-500">CheckIn workspace</div>
|
<div class="text-xs text-zinc-500 dark:text-zinc-400">CheckIn workspace</div>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,11 +90,23 @@ function signOut() {
|
|||||||
<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">{{ authState.user?.alias ?? '未登录' }}</div>
|
||||||
<div class="text-xs text-zinc-500">{{ roleLabel }} · {{ approvalLabel }}</div>
|
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ roleLabel }} · {{ approvalLabel }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex min-h-9 items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:translate-y-px"
|
class="inline-flex size-9 items-center justify-center rounded-md border border-zinc-200 bg-white text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:translate-y-px dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
:aria-label="`切换主题,当前${themeLabel}`"
|
||||||
|
:title="`切换主题,当前${themeLabel}`"
|
||||||
|
@click="theme.cycleThemeMode"
|
||||||
|
>
|
||||||
|
<MoonStar v-if="theme.state.resolved === 'dark'" class="size-4" />
|
||||||
|
<Monitor v-else class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex min-h-9 items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:translate-y-px dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
@click="signOut"
|
@click="signOut"
|
||||||
>
|
>
|
||||||
<LogOut class="size-4" />
|
<LogOut class="size-4" />
|
||||||
@@ -99,14 +118,20 @@ function signOut() {
|
|||||||
|
|
||||||
<div class="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)]">
|
<div class="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
<aside
|
<aside
|
||||||
class="border-b border-zinc-200 bg-white px-4 py-3 shadow-sm lg:min-h-[calc(100dvh-3.5rem)] lg:border-b-0 lg:border-r lg:shadow-none"
|
class="border-b border-zinc-200 bg-white px-4 py-3 shadow-sm lg:min-h-[calc(100dvh-3.5rem)] lg:border-b-0 lg:border-r lg:shadow-none dark:border-zinc-800 dark:bg-zinc-950"
|
||||||
:class="mobileOpen ? 'block' : 'hidden lg:block'"
|
:class="mobileOpen ? 'block' : 'hidden lg:block'"
|
||||||
>
|
>
|
||||||
<div class="mb-4 rounded-lg border border-zinc-200 bg-zinc-50 p-3 lg:hidden">
|
<div
|
||||||
|
class="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">{{ authState.user?.alias ?? '未登录' }}</div>
|
<div class="text-sm font-semibold">{{ authState.user?.alias ?? '未登录' }}</div>
|
||||||
<div class="mt-1 text-xs text-zinc-500">{{ roleLabel }} · {{ approvalLabel }}</div>
|
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ roleLabel }} · {{ approvalLabel }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 px-3 text-xs font-semibold uppercase tracking-normal text-zinc-500">
|
</div>
|
||||||
|
<div
|
||||||
|
class="mb-2 px-3 text-xs font-semibold uppercase tracking-normal text-zinc-500 dark:text-zinc-400"
|
||||||
|
>
|
||||||
工作台
|
工作台
|
||||||
</div>
|
</div>
|
||||||
<nav class="grid gap-1">
|
<nav class="grid gap-1">
|
||||||
@@ -114,11 +139,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"
|
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="
|
: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'
|
? 'bg-emerald-700 text-white shadow-sm hover:bg-emerald-700 hover:text-white dark:bg-emerald-600 dark:text-white'
|
||||||
: 'text-zinc-700'
|
: 'text-zinc-700 dark:text-zinc-300'
|
||||||
"
|
"
|
||||||
@click="go(link.path)"
|
@click="go(link.path)"
|
||||||
>
|
>
|
||||||
@@ -127,9 +152,9 @@ function signOut() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div v-if="isAdmin" class="mt-5 border-t border-zinc-200 pt-4">
|
<div v-if="isAdmin" class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-800">
|
||||||
<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"
|
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"
|
||||||
>
|
>
|
||||||
<span>管理员工作区</span>
|
<span>管理员工作区</span>
|
||||||
<Shield class="size-3.5" />
|
<Shield class="size-3.5" />
|
||||||
@@ -139,11 +164,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"
|
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="
|
: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'
|
? 'bg-sky-700 text-white shadow-sm hover:bg-sky-700 hover:text-white dark:bg-sky-600 dark:text-white'
|
||||||
: 'text-zinc-700'
|
: 'text-zinc-700 dark:text-zinc-300'
|
||||||
"
|
"
|
||||||
@click="go(link.path)"
|
@click="go(link.path)"
|
||||||
>
|
>
|
||||||
@@ -156,19 +181,23 @@ function signOut() {
|
|||||||
|
|
||||||
<main class="min-w-0 px-4 py-5 sm:px-6 lg:px-8">
|
<main class="min-w-0 px-4 py-5 sm:px-6 lg:px-8">
|
||||||
<div
|
<div
|
||||||
class="mb-5 flex flex-wrap items-end justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm"
|
class="mb-5 flex flex-wrap items-end justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none"
|
||||||
:class="{ 'border-sky-200 bg-sky-50/70': isAdminRoute }"
|
:class="{
|
||||||
|
'border-sky-200 bg-sky-50/70 dark:border-sky-900/70 dark:bg-sky-950/30': isAdminRoute,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<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"
|
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"
|
||||||
>
|
>
|
||||||
<Shield class="size-3" />
|
<Shield class="size-3" />
|
||||||
管理员
|
管理员
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-semibold tracking-normal text-zinc-950">{{ title }}</h1>
|
<h1 class="text-2xl font-semibold tracking-normal text-zinc-950 dark:text-zinc-50">
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
{{
|
{{
|
||||||
isAdminRoute
|
isAdminRoute
|
||||||
? '管理用户、模板、记录、日志和系统统计。'
|
? '管理用户、模板、记录、日志和系统统计。'
|
||||||
@@ -177,11 +206,11 @@ function signOut() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="inline-flex items-center gap-2 rounded-full border bg-white px-3 py-1 text-xs font-medium"
|
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="
|
:class="
|
||||||
authState.user?.is_approved
|
authState.user?.is_approved
|
||||||
? 'border-emerald-200 text-emerald-700'
|
? 'border-emerald-200 text-emerald-700 dark:border-emerald-900/70 dark:text-emerald-300'
|
||||||
: 'border-amber-200 text-amber-700'
|
: 'border-amber-200 text-amber-700 dark:border-amber-900/70 dark:text-amber-300'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<UserRound class="size-3.5" />
|
<UserRound class="size-3.5" />
|
||||||
|
|||||||
@@ -14,24 +14,30 @@ defineEmits<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rounded-lg border border-dashed border-zinc-200 bg-white p-6 text-center shadow-sm">
|
|
||||||
<div
|
<div
|
||||||
class="mx-auto mb-3 flex size-10 items-center justify-center rounded-md border border-zinc-200 bg-zinc-50 text-zinc-600"
|
class="rounded-lg border border-dashed border-zinc-200 bg-white p-6 text-center shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-3 flex size-10 items-center justify-center rounded-md border border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-300"
|
||||||
:class="{
|
:class="{
|
||||||
'border-rose-200 bg-rose-50 text-rose-700': type === 'error',
|
'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-300':
|
||||||
'border-emerald-200 bg-emerald-50 text-emerald-700': type === 'loading',
|
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':
|
||||||
|
type === 'loading',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="type === 'loading'" class="size-5 animate-spin" />
|
<Loader2 v-if="type === 'loading'" class="size-5 animate-spin" />
|
||||||
<AlertCircle v-else-if="type === 'error'" class="size-5" />
|
<AlertCircle v-else-if="type === 'error'" class="size-5" />
|
||||||
<Search v-else class="size-5" />
|
<Search v-else class="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-semibold text-zinc-900">{{ title }}</div>
|
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</div>
|
||||||
<p v-if="description" class="mx-auto mt-1 max-w-md text-sm text-zinc-500">{{ description }}</p>
|
<p v-if="description" class="mx-auto mt-1 max-w-md text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
v-if="actionLabel"
|
v-if="actionLabel"
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-4 inline-flex min-h-9 items-center rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px"
|
class="mt-4 inline-flex min-h-9 items-center rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
@click="$emit('action')"
|
@click="$emit('action')"
|
||||||
>
|
>
|
||||||
{{ actionLabel }}
|
{{ actionLabel }}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Braces, Eye, Plus, TreePine } from 'lucide-vue-next'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import TemplateFieldNode from './TemplateFieldNode.vue'
|
||||||
|
import {
|
||||||
|
addFieldAtPath,
|
||||||
|
buildTemplatePreviewPayload,
|
||||||
|
createDefaultFieldConfig,
|
||||||
|
createDefaultNode,
|
||||||
|
parseTemplateFieldConfig,
|
||||||
|
serializeTemplateFieldConfig,
|
||||||
|
validateFieldConfig,
|
||||||
|
type FieldNodeKind,
|
||||||
|
type TemplateFieldConfigRoot,
|
||||||
|
} from './template-config'
|
||||||
|
import {
|
||||||
|
alertClass,
|
||||||
|
buttonBase,
|
||||||
|
buttonTone,
|
||||||
|
inputClass,
|
||||||
|
labelClass,
|
||||||
|
textareaClass,
|
||||||
|
} from '@/components/ui'
|
||||||
|
import { stringifyJson } from '@/utils/format'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [string]
|
||||||
|
valid: [boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mode = ref<'structured' | 'json'>('structured')
|
||||||
|
const config = ref<TemplateFieldConfigRoot>({})
|
||||||
|
const jsonDraft = ref(props.modelValue)
|
||||||
|
const error = ref('')
|
||||||
|
const addKey = ref('')
|
||||||
|
const addKind = ref<FieldNodeKind>('field')
|
||||||
|
|
||||||
|
const parsed = computed(() => parseTemplateFieldConfig(props.modelValue))
|
||||||
|
const validation = computed(() => validateFieldConfig(config.value))
|
||||||
|
const previewPayload = computed(() => buildTemplatePreviewPayload(config.value))
|
||||||
|
const rootEntries = computed(() => Object.entries(config.value))
|
||||||
|
const isValid = computed(() => validation.value.ok && !error.value)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if (value === serializeTemplateFieldConfig(config.value)) return
|
||||||
|
jsonDraft.value = value
|
||||||
|
const result = parseTemplateFieldConfig(value)
|
||||||
|
if (result.ok) {
|
||||||
|
config.value = result.config
|
||||||
|
error.value = ''
|
||||||
|
} else if (mode.value === 'json') {
|
||||||
|
config.value = result.config
|
||||||
|
error.value = result.message ?? '字段配置无效'
|
||||||
|
} else {
|
||||||
|
config.value = result.config
|
||||||
|
mode.value = 'json'
|
||||||
|
error.value = result.message ?? '字段配置无效,请先在 JSON 模式修复'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(isValid, (value) => emit('valid', value), { immediate: true })
|
||||||
|
|
||||||
|
function commit(nextConfig: TemplateFieldConfigRoot) {
|
||||||
|
config.value = nextConfig
|
||||||
|
const nextJson = serializeTemplateFieldConfig(nextConfig)
|
||||||
|
jsonDraft.value = nextJson
|
||||||
|
error.value = ''
|
||||||
|
emit('update:modelValue', nextJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRootField() {
|
||||||
|
try {
|
||||||
|
const key = addKey.value.trim()
|
||||||
|
if (!key) throw new Error('字段名不能为空')
|
||||||
|
const node =
|
||||||
|
addKind.value === 'field' ? createDefaultFieldConfig(key) : createDefaultNode(addKind.value)
|
||||||
|
commit(addFieldAtPath(config.value, [], key, node))
|
||||||
|
addKey.value = ''
|
||||||
|
addKind.value = 'field'
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '添加字段失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMode(nextMode: 'structured' | 'json') {
|
||||||
|
if (nextMode === mode.value) return
|
||||||
|
if (nextMode === 'json') {
|
||||||
|
jsonDraft.value = serializeTemplateFieldConfig(config.value)
|
||||||
|
mode.value = 'json'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseTemplateFieldConfig(jsonDraft.value)
|
||||||
|
if (!result.ok) {
|
||||||
|
error.value = result.message ?? 'JSON 无法切换到结构化编辑'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commit(result.config)
|
||||||
|
mode.value = 'structured'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyJsonDraft() {
|
||||||
|
const result = parseTemplateFieldConfig(jsonDraft.value)
|
||||||
|
if (!result.ok) {
|
||||||
|
error.value = result.message ?? 'JSON 无效'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commit(result.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodeError(message: string) {
|
||||||
|
error.value = message
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="inline-grid grid-cols-2 rounded-md border border-zinc-200 bg-white p-1 text-sm dark:border-zinc-700 dark:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center gap-2 rounded px-3 py-1.5 font-medium transition"
|
||||||
|
:class="
|
||||||
|
mode === 'structured'
|
||||||
|
? 'bg-emerald-700 text-white shadow-sm dark:bg-emerald-600'
|
||||||
|
: 'text-zinc-600 hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-zinc-50'
|
||||||
|
"
|
||||||
|
@click="switchMode('structured')"
|
||||||
|
>
|
||||||
|
<TreePine class="size-4" />
|
||||||
|
结构化
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center gap-2 rounded px-3 py-1.5 font-medium transition"
|
||||||
|
:class="
|
||||||
|
mode === 'json'
|
||||||
|
? 'bg-zinc-900 text-white shadow-sm dark:bg-zinc-700'
|
||||||
|
: 'text-zinc-600 hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-zinc-50'
|
||||||
|
"
|
||||||
|
@click="switchMode('json')"
|
||||||
|
>
|
||||||
|
<Braces class="size-4" />
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<Eye class="size-3.5" />
|
||||||
|
预览使用当前有效配置
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" :class="alertClass.danger">{{ error }}</div>
|
||||||
|
<div v-else-if="!validation.ok" :class="alertClass.warning">{{ validation.message }}</div>
|
||||||
|
<div v-else-if="!parsed.ok && mode === 'structured'" :class="alertClass.warning">
|
||||||
|
已保留原始 JSON,请切换到 JSON 模式修复:{{ parsed.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mode === 'structured'" class="grid gap-3">
|
||||||
|
<div
|
||||||
|
class="grid gap-2 rounded-lg border border-zinc-200 bg-white p-3 sm:grid-cols-[1fr_130px_auto] dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<label class="grid gap-1.5">
|
||||||
|
<span :class="labelClass">新增根字段</span>
|
||||||
|
<input v-model="addKey" :class="inputClass" placeholder="例如 signature 或 values" />
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-1.5">
|
||||||
|
<span :class="labelClass">类型</span>
|
||||||
|
<select v-model="addKind" :class="inputClass">
|
||||||
|
<option value="field">字段</option>
|
||||||
|
<option value="object">对象</option>
|
||||||
|
<option value="array">数组</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary, 'self-end']"
|
||||||
|
type="button"
|
||||||
|
@click="addRootField"
|
||||||
|
>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rootEntries.length" class="grid gap-3">
|
||||||
|
<TemplateFieldNode
|
||||||
|
v-for="[key, node] in rootEntries"
|
||||||
|
:key="key"
|
||||||
|
:root="config"
|
||||||
|
:node="node"
|
||||||
|
:field-key="key"
|
||||||
|
:path="[key]"
|
||||||
|
@change="commit"
|
||||||
|
@error="handleNodeError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg border border-dashed border-zinc-200 bg-zinc-50 p-6 text-center text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400"
|
||||||
|
>
|
||||||
|
暂无字段配置,先添加根字段。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-3">
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span :class="labelClass">字段配置 JSON</span>
|
||||||
|
<textarea v-model="jsonDraft" :class="[textareaClass, 'min-h-80']" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button :class="[buttonBase, buttonTone.primary]" type="button" @click="applyJsonDraft">
|
||||||
|
应用 JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
type="button"
|
||||||
|
@click="switchMode('structured')"
|
||||||
|
>
|
||||||
|
返回结构化
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details
|
||||||
|
class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"
|
||||||
|
>
|
||||||
|
<summary class="cursor-pointer text-sm font-semibold">当前 Payload 预览</summary>
|
||||||
|
<pre
|
||||||
|
class="mt-3 max-h-64 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100"
|
||||||
|
>{{ stringifyJson(previewPayload) }}</pre
|
||||||
|
>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
Braces,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ListTree,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
addFieldAtPath,
|
||||||
|
createDefaultFieldConfig,
|
||||||
|
createDefaultNode,
|
||||||
|
deleteFieldAtPath,
|
||||||
|
isFieldConfig,
|
||||||
|
isPlainObject,
|
||||||
|
moveFieldAtPath,
|
||||||
|
nodeKind,
|
||||||
|
nodeKindLabel,
|
||||||
|
renameFieldAtPath,
|
||||||
|
updateFieldAtPath,
|
||||||
|
type FieldNodeKind,
|
||||||
|
type FieldPath,
|
||||||
|
type TemplateFieldConfigItem,
|
||||||
|
type TemplateFieldConfigNode,
|
||||||
|
type TemplateFieldConfigRoot,
|
||||||
|
} from './template-config'
|
||||||
|
import { buttonBase, buttonTone, inputClass, labelClass, textareaClass } from '@/components/ui'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
root: TemplateFieldConfigRoot
|
||||||
|
node: TemplateFieldConfigNode
|
||||||
|
fieldKey: string
|
||||||
|
path: FieldPath
|
||||||
|
depth?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
change: [TemplateFieldConfigRoot]
|
||||||
|
error: [string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const collapsed = ref(false)
|
||||||
|
const keyDraft = ref(props.fieldKey)
|
||||||
|
const addKey = ref('')
|
||||||
|
const addKind = ref<FieldNodeKind>('field')
|
||||||
|
const localError = ref('')
|
||||||
|
|
||||||
|
const depth = computed(() => props.depth ?? 0)
|
||||||
|
const kind = computed(() => nodeKind(props.node))
|
||||||
|
const isNamedField = computed(() => typeof props.path[props.path.length - 1] === 'string')
|
||||||
|
const fieldNode = computed(() =>
|
||||||
|
isFieldConfig(props.node) ? (props.node as TemplateFieldConfigItem) : null,
|
||||||
|
)
|
||||||
|
const kindBadge = computed(() => nodeKindLabel(kind.value))
|
||||||
|
const pathLabel = computed(() => props.path.map(String).join('.') || 'root')
|
||||||
|
const unknownKeys = computed(() => {
|
||||||
|
const field = fieldNode.value
|
||||||
|
if (!field) return []
|
||||||
|
const known = new Set([
|
||||||
|
'display_name',
|
||||||
|
'field_type',
|
||||||
|
'value_type',
|
||||||
|
'default_value',
|
||||||
|
'required',
|
||||||
|
'hidden',
|
||||||
|
'placeholder',
|
||||||
|
'options',
|
||||||
|
])
|
||||||
|
return Object.keys(field).filter((key) => !known.has(key))
|
||||||
|
})
|
||||||
|
|
||||||
|
const children = computed(() => {
|
||||||
|
if (Array.isArray(props.node)) {
|
||||||
|
return props.node.map((node, index) => ({
|
||||||
|
id: String(index),
|
||||||
|
label: `元素 ${index + 1}`,
|
||||||
|
path: [...props.path, index],
|
||||||
|
node,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(props.node) && !isFieldConfig(props.node)) {
|
||||||
|
return Object.entries(props.node).map(([key, node]) => ({
|
||||||
|
id: key,
|
||||||
|
label: key,
|
||||||
|
path: [...props.path, key],
|
||||||
|
node: node as TemplateFieldConfigNode,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const canAddChildren = computed(() => kind.value === 'array' || kind.value === 'object')
|
||||||
|
const addKeyRequired = computed(() => kind.value === 'object')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.fieldKey,
|
||||||
|
(value) => {
|
||||||
|
keyDraft.value = value
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function createNodeFor(kindValue: FieldNodeKind, name: string) {
|
||||||
|
if (kindValue === 'field') return createDefaultFieldConfig(name || '字段')
|
||||||
|
return createDefaultNode(kindValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportError(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : '字段配置更新失败'
|
||||||
|
localError.value = message
|
||||||
|
emit('error', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(nextRoot: TemplateFieldConfigRoot) {
|
||||||
|
localError.value = ''
|
||||||
|
emit('change', nextRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNode(nextNode: TemplateFieldConfigNode) {
|
||||||
|
try {
|
||||||
|
commit(updateFieldAtPath(props.root, props.path, nextNode))
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(property: keyof TemplateFieldConfigItem, value: unknown) {
|
||||||
|
const field = fieldNode.value
|
||||||
|
if (!field) return
|
||||||
|
const next: TemplateFieldConfigItem = { ...field, [property]: value }
|
||||||
|
if (property === 'field_type' && value === 'select' && !Array.isArray(next.options)) {
|
||||||
|
next.options = []
|
||||||
|
}
|
||||||
|
if (property === 'hidden' && value) {
|
||||||
|
next.required = false
|
||||||
|
}
|
||||||
|
updateNode(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRename() {
|
||||||
|
if (!isNamedField.value || keyDraft.value === props.fieldKey) return
|
||||||
|
try {
|
||||||
|
commit(renameFieldAtPath(props.root, props.path, keyDraft.value))
|
||||||
|
} catch (error) {
|
||||||
|
keyDraft.value = props.fieldKey
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChild() {
|
||||||
|
try {
|
||||||
|
const key = addKey.value.trim()
|
||||||
|
if (addKeyRequired.value && !key) throw new Error('字段名不能为空')
|
||||||
|
const node = createNodeFor(addKind.value, key)
|
||||||
|
commit(addFieldAtPath(props.root, props.path, key, node))
|
||||||
|
addKey.value = ''
|
||||||
|
addKind.value = 'field'
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNode() {
|
||||||
|
try {
|
||||||
|
commit(deleteFieldAtPath(props.root, props.path))
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(direction: 'up' | 'down') {
|
||||||
|
try {
|
||||||
|
commit(moveFieldAtPath(props.root, props.path, direction))
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOption() {
|
||||||
|
const field = fieldNode.value
|
||||||
|
if (!field) return
|
||||||
|
updateField('options', [...(field.options ?? []), { label: '', value: '' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOption(index: number, property: 'label' | 'value', value: string) {
|
||||||
|
const field = fieldNode.value
|
||||||
|
if (!field) return
|
||||||
|
const options = [...(field.options ?? [])]
|
||||||
|
options[index] = { ...(options[index] ?? { label: '', value: '' }), [property]: value }
|
||||||
|
updateField('options', options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOption(index: number) {
|
||||||
|
const field = fieldNode.value
|
||||||
|
if (!field) return
|
||||||
|
const options = [...(field.options ?? [])]
|
||||||
|
options.splice(index, 1)
|
||||||
|
updateField('options', options)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="rounded-lg border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
|
:style="{ marginLeft: `${Math.min(depth, 4) * 0.5}rem` }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canAddChildren || fieldNode"
|
||||||
|
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"
|
||||||
|
:title="collapsed ? '展开' : '收起'"
|
||||||
|
@click="collapsed = !collapsed"
|
||||||
|
>
|
||||||
|
<ChevronRight v-if="collapsed" class="size-4" />
|
||||||
|
<ChevronDown v-else class="size-4" />
|
||||||
|
</button>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<ListTree v-if="kind === 'array'" class="size-4" />
|
||||||
|
<Braces v-else-if="kind === 'object'" class="size-4" />
|
||||||
|
<span v-else class="text-xs font-semibold">Aa</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-if="isNamedField"
|
||||||
|
v-model="keyDraft"
|
||||||
|
:class="[inputClass, 'h-8 min-h-8 w-36 font-mono text-xs sm:w-44']"
|
||||||
|
title="字段名"
|
||||||
|
@blur="commitRename"
|
||||||
|
@keyup.enter="commitRename"
|
||||||
|
/>
|
||||||
|
<span v-else class="font-mono text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||||
|
{{ fieldKey }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full border border-zinc-200 bg-zinc-50 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{{ kindBadge }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ pathLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[buttonBase, buttonTone.ghost, 'size-8 min-h-8 px-0 py-0']"
|
||||||
|
title="上移"
|
||||||
|
@click="move('up')"
|
||||||
|
>
|
||||||
|
<ArrowUp class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[buttonBase, buttonTone.ghost, 'size-8 min-h-8 px-0 py-0']"
|
||||||
|
title="下移"
|
||||||
|
@click="move('down')"
|
||||||
|
>
|
||||||
|
<ArrowDown class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[buttonBase, buttonTone.danger, 'size-8 min-h-8 px-0 py-0']"
|
||||||
|
title="删除"
|
||||||
|
@click="deleteNode"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="localError" class="mt-2 text-xs font-medium text-rose-600 dark:text-rose-300">
|
||||||
|
{{ localError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="!collapsed" class="mt-3 grid gap-3">
|
||||||
|
<div v-if="fieldNode" class="grid gap-3">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label class="grid gap-1.5">
|
||||||
|
<span :class="labelClass">显示名称</span>
|
||||||
|
<input
|
||||||
|
:class="inputClass"
|
||||||
|
:value="fieldNode.display_name"
|
||||||
|
placeholder="例如:姓名"
|
||||||
|
@input="updateField('display_name', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-1.5">
|
||||||
|
<span :class="labelClass">字段类型</span>
|
||||||
|
<select
|
||||||
|
:class="inputClass"
|
||||||
|
:value="fieldNode.field_type"
|
||||||
|
@change="updateField('field_type', ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="text">单行文本</option>
|
||||||
|
<option value="textarea">多行文本</option>
|
||||||
|
<option value="number">数字输入</option>
|
||||||
|
<option value="select">下拉选择</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label class="grid gap-1.5">
|
||||||
|
<span :class="labelClass">值类型</span>
|
||||||
|
<select
|
||||||
|
:class="inputClass"
|
||||||
|
:value="fieldNode.value_type ?? 'string'"
|
||||||
|
@change="updateField('value_type', ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="string">string</option>
|
||||||
|
<option value="int">int</option>
|
||||||
|
<option value="double">double</option>
|
||||||
|
<option value="bool">bool</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-1.5">
|
||||||
|
<span :class="labelClass">默认值</span>
|
||||||
|
<textarea
|
||||||
|
v-if="fieldNode.value_type === 'json'"
|
||||||
|
:class="[textareaClass, 'min-h-20']"
|
||||||
|
:value="String(fieldNode.default_value ?? '')"
|
||||||
|
@input="updateField('default_value', ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
:class="inputClass"
|
||||||
|
:value="String(fieldNode.default_value ?? '')"
|
||||||
|
@input="updateField('default_value', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="grid gap-1.5">
|
||||||
|
<span :class="labelClass">占位提示</span>
|
||||||
|
<input
|
||||||
|
:class="inputClass"
|
||||||
|
:value="fieldNode.placeholder ?? ''"
|
||||||
|
placeholder="用户填写时看到的提示"
|
||||||
|
@input="updateField('placeholder', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid gap-2 rounded-md border border-zinc-200 bg-zinc-50 p-3 text-sm sm:grid-cols-2 dark:border-zinc-800 dark:bg-zinc-950"
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
:checked="Boolean(fieldNode.required)"
|
||||||
|
:disabled="Boolean(fieldNode.hidden)"
|
||||||
|
type="checkbox"
|
||||||
|
@change="updateField('required', ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
必填
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
:checked="Boolean(fieldNode.hidden)"
|
||||||
|
type="checkbox"
|
||||||
|
@change="updateField('hidden', ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
隐藏
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="fieldNode.field_type === 'select'" class="grid gap-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span :class="labelClass">选项</span>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary, 'min-h-8 px-2 py-1 text-xs']"
|
||||||
|
type="button"
|
||||||
|
@click="addOption"
|
||||||
|
>
|
||||||
|
<Plus class="size-3.5" />
|
||||||
|
添加选项
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(option, index) in fieldNode.options ?? []"
|
||||||
|
:key="index"
|
||||||
|
class="grid gap-2 rounded-md border border-zinc-200 bg-zinc-50 p-2 sm:grid-cols-[1fr_1fr_auto] dark:border-zinc-800 dark:bg-zinc-950"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:class="inputClass"
|
||||||
|
:value="option.label"
|
||||||
|
placeholder="显示文本"
|
||||||
|
@input="updateOption(index, 'label', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:class="inputClass"
|
||||||
|
:value="option.value"
|
||||||
|
placeholder="保存值"
|
||||||
|
@input="updateOption(index, 'value', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[buttonBase, buttonTone.danger, 'min-h-9 px-2 py-1']"
|
||||||
|
@click="removeOption(index)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="unknownKeys.length" class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
保留未识别属性:{{ unknownKeys.join(', ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="canAddChildren" class="grid gap-3">
|
||||||
|
<div v-if="children.length" class="grid gap-2">
|
||||||
|
<TemplateFieldNode
|
||||||
|
v-for="child in children"
|
||||||
|
:key="child.id"
|
||||||
|
:root="root"
|
||||||
|
:node="child.node"
|
||||||
|
:field-key="child.label"
|
||||||
|
:path="child.path"
|
||||||
|
:depth="depth + 1"
|
||||||
|
@change="$emit('change', $event)"
|
||||||
|
@error="$emit('error', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-md border border-dashed border-zinc-200 bg-zinc-50 p-4 text-center text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400"
|
||||||
|
>
|
||||||
|
当前{{ kindBadge }}为空
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid gap-2 rounded-md border border-zinc-200 bg-zinc-50 p-3 sm:grid-cols-[1fr_120px_auto] dark:border-zinc-800 dark:bg-zinc-950"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="addKey"
|
||||||
|
:class="inputClass"
|
||||||
|
:placeholder="addKeyRequired ? '子字段名' : '数组元素名(可选)'"
|
||||||
|
/>
|
||||||
|
<select v-model="addKind" :class="inputClass">
|
||||||
|
<option value="field">字段</option>
|
||||||
|
<option value="object">对象</option>
|
||||||
|
<option value="array">数组</option>
|
||||||
|
</select>
|
||||||
|
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="addChild">
|
||||||
|
<Plus class="size-4" />
|
||||||
|
添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
addFieldAtPath,
|
||||||
|
createDefaultFieldConfig,
|
||||||
|
buildTemplatePreviewPayload,
|
||||||
|
deleteFieldAtPath,
|
||||||
|
isFieldConfig,
|
||||||
|
moveFieldAtPath,
|
||||||
|
parseTemplateFieldConfig,
|
||||||
|
renameFieldAtPath,
|
||||||
|
serializeTemplateFieldConfig,
|
||||||
|
type TemplateFieldConfigRoot,
|
||||||
|
type TemplateFieldConfigItem,
|
||||||
|
updateFieldAtPath,
|
||||||
|
validateFieldConfig,
|
||||||
|
} from './template-config.ts'
|
||||||
|
|
||||||
|
test('parses and serializes valid template field config', () => {
|
||||||
|
const parsed = parseTemplateFieldConfig(
|
||||||
|
'{"signature":{"display_name":"姓名","field_type":"text","required":true}}',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true)
|
||||||
|
const signature = parsed.config.signature
|
||||||
|
assert.ok(isFieldConfig(signature))
|
||||||
|
assert.equal(signature.display_name, '姓名')
|
||||||
|
assert.match(serializeTemplateFieldConfig(parsed.config), /"signature"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses field-like configs so structured editor can repair metadata', () => {
|
||||||
|
const parsed = parseTemplateFieldConfig('{"signature":{"display_name":"姓名"}}')
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true)
|
||||||
|
assert.ok(isFieldConfig(parsed.config.signature))
|
||||||
|
|
||||||
|
const validation = validateFieldConfig(parsed.config)
|
||||||
|
assert.equal(validation.ok, false)
|
||||||
|
assert.match(validation.message ?? '', /字段类型/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('default field preserves expected editing metadata', () => {
|
||||||
|
const field = createDefaultFieldConfig('体温')
|
||||||
|
|
||||||
|
assert.equal(field.display_name, '体温')
|
||||||
|
assert.equal(field.field_type, 'text')
|
||||||
|
assert.equal(field.value_type, 'string')
|
||||||
|
assert.equal(field.required, false)
|
||||||
|
assert.equal(field.hidden, false)
|
||||||
|
assert.deepEqual(field.options, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds updates deletes and reorders root fields immutably', () => {
|
||||||
|
const config: TemplateFieldConfigRoot = {
|
||||||
|
signature: createDefaultFieldConfig('姓名'),
|
||||||
|
status: createDefaultFieldConfig('状态'),
|
||||||
|
}
|
||||||
|
const withLocation = addFieldAtPath(config, [], 'location', createDefaultFieldConfig('位置'))
|
||||||
|
const locationField = withLocation.location as TemplateFieldConfigItem
|
||||||
|
const updated = updateFieldAtPath(withLocation, ['location'], {
|
||||||
|
...locationField,
|
||||||
|
field_type: 'select',
|
||||||
|
options: [{ label: '教学楼', value: 'teaching' }],
|
||||||
|
legacy_hint: 'preserved',
|
||||||
|
})
|
||||||
|
const moved = moveFieldAtPath(updated, ['location'], 'up')
|
||||||
|
const deleted = deleteFieldAtPath(moved, ['status'])
|
||||||
|
|
||||||
|
assert.deepEqual(Object.keys(config), ['signature', 'status'])
|
||||||
|
assert.deepEqual(Object.keys(moved), ['signature', 'location', 'status'])
|
||||||
|
const updatedLocation = updated.location as TemplateFieldConfigItem
|
||||||
|
assert.ok(isFieldConfig(updatedLocation))
|
||||||
|
assert.equal(updatedLocation.legacy_hint, 'preserved')
|
||||||
|
assert.deepEqual(Object.keys(deleted), ['signature', 'location'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renames fields without disturbing sibling order', () => {
|
||||||
|
const config: TemplateFieldConfigRoot = {
|
||||||
|
signature: createDefaultFieldConfig('姓名'),
|
||||||
|
profile: {
|
||||||
|
city: createDefaultFieldConfig('城市'),
|
||||||
|
region: createDefaultFieldConfig('区域'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const renamed = renameFieldAtPath(config, ['profile', 'city'], 'location')
|
||||||
|
|
||||||
|
assert.deepEqual(Object.keys(renamed), ['signature', 'profile'])
|
||||||
|
const renamedProfile = renamed.profile as Record<string, TemplateFieldConfigItem>
|
||||||
|
assert.ok(!Array.isArray(renamedProfile) && !isFieldConfig(renamedProfile))
|
||||||
|
assert.deepEqual(Object.keys(renamedProfile), ['location', 'region'])
|
||||||
|
const renamedLocation = renamedProfile.location
|
||||||
|
assert.ok(isFieldConfig(renamedLocation))
|
||||||
|
assert.equal(renamedLocation.display_name, '城市')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('builds preview payload from nested field config', () => {
|
||||||
|
const config: TemplateFieldConfigRoot = {
|
||||||
|
signature: { ...createDefaultFieldConfig('姓名'), default_value: '张三' },
|
||||||
|
profile: {
|
||||||
|
city: { ...createDefaultFieldConfig('城市'), default_value: '上海' },
|
||||||
|
},
|
||||||
|
tags: [{ ...createDefaultFieldConfig('标签'), default_value: '新生' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(buildTemplatePreviewPayload(config), {
|
||||||
|
signature: '张三',
|
||||||
|
profile: { city: '上海' },
|
||||||
|
tags: ['新生'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validates duplicate or empty keys before mutation', () => {
|
||||||
|
const config = { signature: createDefaultFieldConfig('姓名') }
|
||||||
|
|
||||||
|
assert.throws(() => addFieldAtPath(config, [], '', createDefaultFieldConfig()), /字段名/)
|
||||||
|
assert.throws(() => addFieldAtPath(config, [], 'signature', createDefaultFieldConfig()), /已存在/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validates malformed config shapes', () => {
|
||||||
|
const result = validateFieldConfig({
|
||||||
|
signature: { display_name: '姓名', field_type: 'select', options: [{ label: '' }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.match(result.message ?? '', /选项/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import type { TemplateFieldOption } from '@/api'
|
||||||
|
|
||||||
|
export type FieldNodeKind = 'field' | 'array' | 'object'
|
||||||
|
export type FieldPath = Array<string | number>
|
||||||
|
|
||||||
|
export interface TemplateFieldConfigItem {
|
||||||
|
display_name: string
|
||||||
|
field_type: 'text' | 'textarea' | 'number' | 'select' | string
|
||||||
|
value_type?: 'string' | 'int' | 'double' | 'bool' | 'json' | string
|
||||||
|
default_value?: unknown
|
||||||
|
required?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
placeholder?: string | null
|
||||||
|
options?: TemplateFieldOption[] | null
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateFieldConfigArray extends Array<TemplateFieldConfigNode> {}
|
||||||
|
|
||||||
|
export interface TemplateFieldConfigObject {
|
||||||
|
[key: string]: TemplateFieldConfigNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemplateFieldConfigNode =
|
||||||
|
| TemplateFieldConfigItem
|
||||||
|
| TemplateFieldConfigArray
|
||||||
|
| TemplateFieldConfigObject
|
||||||
|
|
||||||
|
export interface TemplateFieldConfigRoot {
|
||||||
|
[key: string]: TemplateFieldConfigNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
ok: boolean
|
||||||
|
config: TemplateFieldConfigRoot
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
ok: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyValidationResult extends ValidationResult {
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneConfig<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultFieldConfig(displayName = ''): TemplateFieldConfigItem {
|
||||||
|
return {
|
||||||
|
display_name: displayName,
|
||||||
|
field_type: 'text',
|
||||||
|
default_value: '',
|
||||||
|
required: false,
|
||||||
|
hidden: false,
|
||||||
|
placeholder: '',
|
||||||
|
value_type: 'string',
|
||||||
|
options: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultNode(kind: FieldNodeKind): TemplateFieldConfigNode {
|
||||||
|
if (kind === 'array') return []
|
||||||
|
if (kind === 'object') return {}
|
||||||
|
return createDefaultFieldConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFieldConfig(value: unknown): value is TemplateFieldConfigItem {
|
||||||
|
return isPlainObject(value) && 'display_name' in value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeKind(value: unknown): FieldNodeKind {
|
||||||
|
if (Array.isArray(value)) return 'array'
|
||||||
|
if (isFieldConfig(value)) return 'field'
|
||||||
|
return 'object'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeKindLabel(kind: FieldNodeKind) {
|
||||||
|
if (kind === 'array') return '数组'
|
||||||
|
if (kind === 'object') return '对象'
|
||||||
|
return '字段'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTemplateFieldConfig(value: string): ParseResult {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown
|
||||||
|
if (!isPlainObject(parsed)) {
|
||||||
|
return { ok: false, config: {}, message: '字段配置必须是 JSON 对象' }
|
||||||
|
}
|
||||||
|
return { ok: true, config: parsed as TemplateFieldConfigRoot }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
config: {},
|
||||||
|
message: error instanceof Error ? error.message : '字段配置 JSON 解析失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeTemplateFieldConfig(config: TemplateFieldConfigRoot) {
|
||||||
|
return JSON.stringify(config, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNode(value: TemplateFieldConfigNode, path: FieldPath): ValidationResult {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const result = validateNode(value[index], [...path, index])
|
||||||
|
if (!result.ok) return result
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return { ok: false, message: `字段 ${path.join('.')} 不是有效配置` }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFieldConfig(value)) {
|
||||||
|
if (!String(value.display_name ?? '').trim()) {
|
||||||
|
return { ok: false, message: `字段 ${path.join('.')} 缺少显示名称` }
|
||||||
|
}
|
||||||
|
if (!String(value.field_type ?? '').trim()) {
|
||||||
|
return { ok: false, message: `字段 ${path.join('.')} 缺少字段类型` }
|
||||||
|
}
|
||||||
|
if (value.field_type === 'select') {
|
||||||
|
const options = Array.isArray(value.options) ? value.options : []
|
||||||
|
if (options.length === 0) return { ok: false, message: `字段 ${path.join('.')} 缺少选项` }
|
||||||
|
const invalid = options.some((option) => !option.label || option.value == null)
|
||||||
|
if (invalid) return { ok: false, message: `字段 ${path.join('.')} 存在无效选项` }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
if (!key.trim()) return { ok: false, message: '字段名不能为空' }
|
||||||
|
const result = validateNode(child as TemplateFieldConfigNode, [...path, key])
|
||||||
|
if (!result.ok) return result
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFieldConfig(config: unknown): ValidationResult {
|
||||||
|
if (!isPlainObject(config)) return { ok: false, message: '字段配置必须是对象' }
|
||||||
|
return validateNode(config as TemplateFieldConfigNode, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidKey(target: Record<string, TemplateFieldConfigNode>, key: string) {
|
||||||
|
if (!key.trim()) throw new Error('字段名不能为空')
|
||||||
|
if (Object.prototype.hasOwnProperty.call(target, key)) throw new Error('字段已存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSiblingKey(
|
||||||
|
root: TemplateFieldConfigRoot,
|
||||||
|
parentPath: FieldPath,
|
||||||
|
key: string,
|
||||||
|
currentKey?: string,
|
||||||
|
): KeyValidationResult {
|
||||||
|
const normalized = key.trim()
|
||||||
|
if (!normalized) return { ok: false, key: normalized, message: '字段名不能为空' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parent = getContainer(root, parentPath)
|
||||||
|
if (Array.isArray(parent)) return { ok: true, key: normalized }
|
||||||
|
if (normalized !== currentKey && Object.prototype.hasOwnProperty.call(parent, normalized)) {
|
||||||
|
return { ok: false, key: normalized, message: '字段已存在' }
|
||||||
|
}
|
||||||
|
return { ok: true, key: normalized }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
key: normalized,
|
||||||
|
message: error instanceof Error ? error.message : '字段路径无效',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContainer(
|
||||||
|
root: TemplateFieldConfigRoot,
|
||||||
|
path: FieldPath,
|
||||||
|
): Record<string, TemplateFieldConfigNode> | TemplateFieldConfigNode[] {
|
||||||
|
let current: TemplateFieldConfigNode = root
|
||||||
|
for (const segment of path) {
|
||||||
|
if (Array.isArray(current) && typeof segment === 'number') {
|
||||||
|
current = current[segment]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isPlainObject(current) && typeof segment === 'string') {
|
||||||
|
current = current[segment] as TemplateFieldConfigNode
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw new Error('字段路径无效')
|
||||||
|
}
|
||||||
|
if (!Array.isArray(current) && !isPlainObject(current)) throw new Error('字段路径无效')
|
||||||
|
return current as Record<string, TemplateFieldConfigNode> | TemplateFieldConfigNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addFieldAtPath(
|
||||||
|
root: TemplateFieldConfigRoot,
|
||||||
|
parentPath: FieldPath,
|
||||||
|
key: string,
|
||||||
|
value: TemplateFieldConfigNode,
|
||||||
|
): TemplateFieldConfigRoot {
|
||||||
|
const next = cloneConfig(root)
|
||||||
|
const parent = getContainer(next, parentPath)
|
||||||
|
if (Array.isArray(parent)) {
|
||||||
|
if (key.trim()) {
|
||||||
|
parent.push({ [key]: cloneConfig(value) })
|
||||||
|
} else {
|
||||||
|
parent.push(cloneConfig(value))
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
assertValidKey(parent, key)
|
||||||
|
parent[key] = cloneConfig(value)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFieldAtPath(
|
||||||
|
root: TemplateFieldConfigRoot,
|
||||||
|
path: FieldPath,
|
||||||
|
value: TemplateFieldConfigNode,
|
||||||
|
): TemplateFieldConfigRoot {
|
||||||
|
if (path.length === 0) {
|
||||||
|
if (!isPlainObject(value) || Array.isArray(value)) throw new Error('根配置必须是对象')
|
||||||
|
return cloneConfig(value as TemplateFieldConfigRoot)
|
||||||
|
}
|
||||||
|
const next = cloneConfig(root)
|
||||||
|
const parent = getContainer(next, path.slice(0, -1))
|
||||||
|
const segment = path[path.length - 1]
|
||||||
|
if (Array.isArray(parent) && typeof segment === 'number') parent[segment] = cloneConfig(value)
|
||||||
|
else if (!Array.isArray(parent) && typeof segment === 'string')
|
||||||
|
parent[segment] = cloneConfig(value)
|
||||||
|
else throw new Error('字段路径无效')
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameFieldAtPath(
|
||||||
|
root: TemplateFieldConfigRoot,
|
||||||
|
path: FieldPath,
|
||||||
|
nextKey: string,
|
||||||
|
): TemplateFieldConfigRoot {
|
||||||
|
if (path.length === 0) return root
|
||||||
|
const segment = path[path.length - 1]
|
||||||
|
if (typeof segment !== 'string') throw new Error('字段路径无效')
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1)
|
||||||
|
const validated = validateSiblingKey(root, parentPath, nextKey, segment)
|
||||||
|
if (!validated.ok) throw new Error(validated.message)
|
||||||
|
if (validated.key === segment) return root
|
||||||
|
|
||||||
|
const next = cloneConfig(root)
|
||||||
|
const parent = getContainer(next, parentPath)
|
||||||
|
if (Array.isArray(parent)) throw new Error('字段路径无效')
|
||||||
|
|
||||||
|
const entries = Object.entries(parent)
|
||||||
|
for (const key of Object.keys(parent)) delete parent[key]
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
parent[key === segment ? validated.key : key] = value
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFieldAtPath(root: TemplateFieldConfigRoot, path: FieldPath) {
|
||||||
|
if (path.length === 0) return {}
|
||||||
|
const next = cloneConfig(root)
|
||||||
|
const parent = getContainer(next, path.slice(0, -1))
|
||||||
|
const segment = path[path.length - 1]
|
||||||
|
if (Array.isArray(parent) && typeof segment === 'number') parent.splice(segment, 1)
|
||||||
|
else if (!Array.isArray(parent) && typeof segment === 'string') delete parent[segment]
|
||||||
|
else throw new Error('字段路径无效')
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveFieldAtPath(
|
||||||
|
root: TemplateFieldConfigRoot,
|
||||||
|
path: FieldPath,
|
||||||
|
direction: 'up' | 'down',
|
||||||
|
): TemplateFieldConfigRoot {
|
||||||
|
if (path.length === 0) return root
|
||||||
|
const next = cloneConfig(root)
|
||||||
|
const parent = getContainer(next, path.slice(0, -1))
|
||||||
|
const segment = path[path.length - 1]
|
||||||
|
const offset = direction === 'up' ? -1 : 1
|
||||||
|
|
||||||
|
if (Array.isArray(parent) && typeof segment === 'number') {
|
||||||
|
const target = segment + offset
|
||||||
|
if (target < 0 || target >= parent.length) return next
|
||||||
|
const [item] = parent.splice(segment, 1)
|
||||||
|
parent.splice(target, 0, item)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parent) && typeof segment === 'string') {
|
||||||
|
const entries = Object.entries(parent)
|
||||||
|
const index = entries.findIndex(([key]) => key === segment)
|
||||||
|
const target = index + offset
|
||||||
|
if (index < 0 || target < 0 || target >= entries.length) return next
|
||||||
|
const [entry] = entries.splice(index, 1)
|
||||||
|
entries.splice(target, 0, entry)
|
||||||
|
for (const key of Object.keys(parent)) delete parent[key]
|
||||||
|
for (const [key, value] of entries) parent[key] = value
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('字段路径无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewNode(node: TemplateFieldConfigNode): unknown {
|
||||||
|
if (Array.isArray(node)) return node.map((item) => buildPreviewNode(item))
|
||||||
|
if (isFieldConfig(node)) return node.default_value ?? ''
|
||||||
|
if (!isPlainObject(node)) return null
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of Object.entries(node)) {
|
||||||
|
payload[key] = buildPreviewNode(value as TemplateFieldConfigNode)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTemplatePreviewPayload(config: TemplateFieldConfigRoot) {
|
||||||
|
return buildPreviewNode(config) as Record<string, unknown>
|
||||||
|
}
|
||||||
@@ -1,48 +1,61 @@
|
|||||||
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-md border px-3 py-2 text-sm font-medium shadow-sm transition duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50'
|
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium shadow-sm transition duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-emerald-400/30'
|
||||||
|
|
||||||
export const buttonTone = {
|
export const buttonTone = {
|
||||||
primary: 'border-emerald-700 bg-emerald-700 text-white hover:bg-emerald-800',
|
primary:
|
||||||
secondary: 'border-zinc-200 bg-white text-zinc-900 hover:border-zinc-300 hover:bg-zinc-50',
|
'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',
|
||||||
ghost: 'border-transparent bg-transparent text-zinc-700 shadow-none hover:bg-zinc-100',
|
secondary:
|
||||||
danger: 'border-rose-200 bg-rose-50 text-rose-700 hover:border-rose-300 hover:bg-rose-100',
|
'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',
|
||||||
admin: 'border-sky-700 bg-sky-700 text-white hover:bg-sky-800',
|
ghost:
|
||||||
|
'border-transparent bg-transparent text-zinc-700 shadow-none hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inputClass =
|
export const inputClass =
|
||||||
'w-full min-h-9 rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-emerald-700 focus:ring-2 focus:ring-emerald-700/10 disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500'
|
'w-full min-h-9 rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-emerald-700 focus:ring-2 focus:ring-emerald-700/10 disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500 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'
|
||||||
|
|
||||||
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-lg border border-zinc-200/80 bg-white shadow-[0_14px_32px_-28px_rgba(24,24,27,0.45)]'
|
'rounded-lg border border-zinc-200/80 bg-white shadow-[0_14px_32px_-28px_rgba(24,24,27,0.45)] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none'
|
||||||
|
|
||||||
export const sectionHeaderClass =
|
export const sectionHeaderClass =
|
||||||
'flex flex-wrap items-start justify-between gap-3 border-b border-zinc-200 bg-zinc-50/70 px-4 py-3'
|
'flex flex-wrap items-start justify-between gap-3 border-b border-zinc-200 bg-zinc-50/70 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-950/50'
|
||||||
|
|
||||||
export const actionBarClass =
|
export const actionBarClass =
|
||||||
'flex flex-wrap items-center gap-2 border-b border-zinc-200 bg-zinc-50/70 p-4'
|
'flex flex-wrap items-center gap-2 border-b border-zinc-200 bg-zinc-50/70 p-4 dark:border-zinc-800 dark:bg-zinc-950/50'
|
||||||
|
|
||||||
export const labelClass = 'text-xs font-semibold uppercase tracking-normal text-zinc-500'
|
export const labelClass =
|
||||||
|
'text-xs font-semibold uppercase tracking-normal text-zinc-500 dark:text-zinc-400'
|
||||||
|
|
||||||
export const mutedText = 'text-sm text-zinc-500'
|
export const mutedText = 'text-sm text-zinc-500 dark:text-zinc-400'
|
||||||
|
|
||||||
export const alertClass = {
|
export const alertClass = {
|
||||||
info: 'rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-sm text-sky-800',
|
info: 'rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-sm text-sky-800 dark:border-sky-900/70 dark:bg-sky-950/50 dark:text-sky-200',
|
||||||
success: 'rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800',
|
success:
|
||||||
warning: 'rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800',
|
'rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-200',
|
||||||
danger: 'rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800',
|
warning:
|
||||||
|
'rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-200',
|
||||||
|
danger:
|
||||||
|
'rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-200',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toneClass(tone: Tone) {
|
export function toneClass(tone: Tone) {
|
||||||
const tones: Record<Tone, string> = {
|
const tones: Record<Tone, string> = {
|
||||||
neutral: 'border-zinc-200 bg-zinc-50 text-zinc-700',
|
neutral:
|
||||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
'border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
|
||||||
warning: 'border-amber-200 bg-amber-50 text-amber-800',
|
success:
|
||||||
danger: 'border-rose-200 bg-rose-50 text-rose-700',
|
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300',
|
||||||
info: 'border-sky-200 bg-sky-50 text-sky-700',
|
warning:
|
||||||
|
'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-300',
|
||||||
|
danger:
|
||||||
|
'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-300',
|
||||||
|
info: 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/70 dark:bg-sky-950/50 dark:text-sky-300',
|
||||||
}
|
}
|
||||||
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]}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { useAuth } from './app/auth'
|
import { useAuth } from './app/auth'
|
||||||
|
import { initTheme } from './app/theme'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
|
initTheme()
|
||||||
|
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
|
|
||||||
if (auth.state.token && !auth.state.initialized) {
|
if (auth.state.token && !auth.state.initialized) {
|
||||||
|
|||||||
@@ -137,3 +137,65 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .bg-white,
|
||||||
|
.dark .bg-white\/95 {
|
||||||
|
background-color: rgb(24 24 27);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-zinc-50,
|
||||||
|
.dark .bg-zinc-50\/70 {
|
||||||
|
background-color: rgb(9 9 11 / 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-emerald-50,
|
||||||
|
.dark .bg-emerald-50\/60,
|
||||||
|
.dark .bg-emerald-50\/70 {
|
||||||
|
background-color: rgb(2 44 34 / 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-sky-50,
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-700,
|
||||||
|
.dark .text-zinc-600 {
|
||||||
|
color: rgb(212 212 216);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-zinc-500,
|
||||||
|
.dark .text-zinc-400 {
|
||||||
|
color: rgb(161 161 170);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:text-zinc-950:hover,
|
||||||
|
.dark .hover\:text-zinc-900:hover {
|
||||||
|
color: rgb(250 250 250);
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,28 +114,34 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="flex min-h-[100dvh] items-center justify-center bg-zinc-50 px-4 py-8 text-zinc-950">
|
<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"
|
||||||
|
>
|
||||||
<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-6 py-5 text-center">
|
<div class="border-b border-zinc-200 px-6 py-5 text-center dark:border-zinc-800">
|
||||||
<div
|
<div
|
||||||
class="mx-auto mb-3 flex size-11 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm"
|
class="mx-auto mb-3 flex size-11 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm"
|
||||||
>
|
>
|
||||||
<QrCode class="size-5" />
|
<QrCode class="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-xl font-semibold tracking-normal text-zinc-950">接龙自动打卡系统</h1>
|
<h1 class="text-xl font-semibold tracking-normal text-zinc-950 dark:text-zinc-50">
|
||||||
<p class="mt-1 text-sm text-zinc-500">{{ currentSubtitle }}</p>
|
接龙自动打卡系统
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ currentSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="grid grid-cols-2 rounded-md border border-zinc-200 bg-zinc-50 p-1 text-sm">
|
<div
|
||||||
|
class="grid grid-cols-2 rounded-md 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 px-3 py-2 text-center font-medium transition"
|
class="rounded px-3 py-2 text-center font-medium transition"
|
||||||
:class="
|
:class="
|
||||||
loginMode === 'qrcode'
|
loginMode === 'qrcode'
|
||||||
? 'bg-white text-zinc-900 shadow-sm'
|
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50'
|
||||||
: 'text-zinc-500 hover:text-zinc-900'
|
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
|
||||||
"
|
"
|
||||||
@click="switchMode('qrcode')"
|
@click="switchMode('qrcode')"
|
||||||
>
|
>
|
||||||
@@ -146,8 +152,8 @@ onBeforeUnmount(() => {
|
|||||||
class="rounded px-3 py-2 text-center font-medium transition"
|
class="rounded px-3 py-2 text-center font-medium transition"
|
||||||
:class="
|
:class="
|
||||||
loginMode === 'password'
|
loginMode === 'password'
|
||||||
? 'bg-white text-zinc-900 shadow-sm'
|
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50'
|
||||||
: 'text-zinc-500 hover:text-zinc-900'
|
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
|
||||||
"
|
"
|
||||||
@click="switchMode('password')"
|
@click="switchMode('password')"
|
||||||
>
|
>
|
||||||
@@ -161,10 +167,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">用户名</span>
|
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">用户名</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"
|
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400 dark:text-zinc-500"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="alias"
|
v-model="alias"
|
||||||
@@ -176,10 +182,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">密码</span>
|
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">密码</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"
|
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400 dark:text-zinc-500"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="password"
|
v-model="password"
|
||||||
@@ -207,7 +213,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ loading ? '登录中' : '登录' }}
|
{{ loading ? '登录中' : '登录' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="text-center text-sm font-medium text-zinc-600 transition hover:text-zinc-950"
|
class="text-center text-sm font-medium text-zinc-600 transition hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-zinc-50"
|
||||||
type="button"
|
type="button"
|
||||||
@click="switchMode('qrcode')"
|
@click="switchMode('qrcode')"
|
||||||
>
|
>
|
||||||
@@ -217,10 +223,10 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div v-else class="mt-6 grid gap-4">
|
<div v-else class="mt-6 grid gap-4">
|
||||||
<label class="grid gap-2">
|
<label class="grid gap-2">
|
||||||
<span class="text-xs font-semibold text-zinc-500">用户名</span>
|
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">用户名</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"
|
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400 dark:text-zinc-500"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="alias"
|
v-model="alias"
|
||||||
@@ -252,15 +258,15 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="qrImage"
|
v-if="qrImage"
|
||||||
class="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-center"
|
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"
|
class="mx-auto size-48 rounded-md bg-white object-contain dark:bg-zinc-100"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="mt-3 inline-flex items-center gap-2 text-sm font-medium text-zinc-600 transition hover:text-zinc-900"
|
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"
|
||||||
type="button"
|
type="button"
|
||||||
@click="requestQrCode"
|
@click="requestQrCode"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -31,24 +31,26 @@ 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 class="border-b border-amber-200 bg-amber-50/80 p-6">
|
<div
|
||||||
|
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">
|
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
当前账号
|
当前账号
|
||||||
{{ 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">
|
<div class="rounded-md border border-zinc-200 p-3 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
<dt class="text-xs text-zinc-500">创建时间</dt>
|
<dt class="text-xs text-zinc-500 dark:text-zinc-400">创建时间</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">
|
<div class="rounded-md border border-zinc-200 p-3 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
<dt class="text-xs text-zinc-500">审批状态</dt>
|
<dt class="text-xs text-zinc-500 dark:text-zinc-400">审批状态</dt>
|
||||||
<dd class="mt-1 text-sm font-medium">待审批</dd>
|
<dd class="mt-1 text-sm font-medium">待审批</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Eye, Plus, Save, Trash2 } from 'lucide-vue-next'
|
import { Eye, Plus, Save, Trash2 } from 'lucide-vue-next'
|
||||||
import { 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'
|
||||||
|
import TemplateConfigEditor from '@/components/templates/TemplateConfigEditor.vue'
|
||||||
|
import {
|
||||||
|
buildTemplatePreviewPayload,
|
||||||
|
parseTemplateFieldConfig,
|
||||||
|
validateFieldConfig,
|
||||||
|
} from '@/components/templates/template-config'
|
||||||
import {
|
import {
|
||||||
alertClass,
|
alertClass,
|
||||||
buttonBase,
|
buttonBase,
|
||||||
@@ -10,7 +16,6 @@ import {
|
|||||||
cardClass,
|
cardClass,
|
||||||
inputClass,
|
inputClass,
|
||||||
sectionHeaderClass,
|
sectionHeaderClass,
|
||||||
textareaClass,
|
|
||||||
toneClass,
|
toneClass,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { extractErrorMessage, formatDateTime, stringifyJson } from '@/utils/format'
|
import { extractErrorMessage, formatDateTime, stringifyJson } from '@/utils/format'
|
||||||
@@ -21,6 +26,7 @@ const message = ref('')
|
|||||||
const templates = ref<Template[]>([])
|
const templates = ref<Template[]>([])
|
||||||
const editingId = ref<number | 'new' | null>(null)
|
const editingId = ref<number | 'new' | null>(null)
|
||||||
const preview = ref<TemplatePreview | null>(null)
|
const preview = ref<TemplatePreview | null>(null)
|
||||||
|
const editorValid = ref(true)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -29,6 +35,12 @@ const form = reactive({
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const localPreviewPayload = computed(() => {
|
||||||
|
const result = parseTemplateFieldConfig(form.field_config)
|
||||||
|
if (!result.ok) return null
|
||||||
|
return buildTemplatePreviewPayload(result.config)
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -68,7 +80,10 @@ async function save() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
message.value = ''
|
message.value = ''
|
||||||
try {
|
try {
|
||||||
JSON.parse(form.field_config)
|
const parsed = parseTemplateFieldConfig(form.field_config)
|
||||||
|
if (!parsed.ok) throw new Error(parsed.message || '字段配置无效')
|
||||||
|
const validation = validateFieldConfig(parsed.config)
|
||||||
|
if (!validation.ok) throw new Error(validation.message || '字段配置无效')
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
description: form.description || null,
|
description: form.description || null,
|
||||||
@@ -185,27 +200,28 @@ onMounted(load)
|
|||||||
:class="[cardClass, 'grid gap-4 overflow-hidden']"
|
:class="[cardClass, 'grid gap-4 overflow-hidden']"
|
||||||
@submit.prevent="save"
|
@submit.prevent="save"
|
||||||
>
|
>
|
||||||
<div class="border-b border-zinc-200 bg-zinc-50/70 px-5 py-4">
|
<div
|
||||||
|
class="border-b border-zinc-200 bg-zinc-50/70 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-950/50"
|
||||||
|
>
|
||||||
<h2 class="font-semibold">{{ editingId === 'new' ? '新建模板' : '编辑模板' }}</h2>
|
<h2 class="font-semibold">{{ editingId === 'new' ? '新建模板' : '编辑模板' }}</h2>
|
||||||
<p class="mt-1 text-sm text-zinc-500">字段配置必须是合法 JSON。</p>
|
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
使用结构化字段编辑器维护配置,必要时切换 JSON 修复。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 p-5">
|
<div class="grid gap-4 p-5">
|
||||||
<label class="grid gap-2">
|
<label class="grid gap-2">
|
||||||
<span class="text-xs font-semibold text-zinc-500">名称</span>
|
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">名称</span>
|
||||||
<input v-model="form.name" :class="inputClass" required />
|
<input v-model="form.name" :class="inputClass" required />
|
||||||
</label>
|
</label>
|
||||||
<label class="grid gap-2">
|
<label class="grid gap-2">
|
||||||
<span class="text-xs font-semibold text-zinc-500">描述</span>
|
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">描述</span>
|
||||||
<input v-model="form.description" :class="inputClass" />
|
<input v-model="form.description" :class="inputClass" />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input v-model="form.is_active" type="checkbox" />
|
<input v-model="form.is_active" type="checkbox" />
|
||||||
启用模板
|
启用模板
|
||||||
</label>
|
</label>
|
||||||
<label class="grid gap-2">
|
<TemplateConfigEditor v-model="form.field_config" @valid="editorValid = $event" />
|
||||||
<span class="text-xs font-semibold text-zinc-500">字段配置 JSON</span>
|
|
||||||
<textarea v-model="form.field_config" :class="textareaClass" class="min-h-64" />
|
|
||||||
</label>
|
|
||||||
<div v-if="error" :class="alertClass.danger">
|
<div v-if="error" :class="alertClass.danger">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +229,11 @@ onMounted(load)
|
|||||||
{{ message }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button :class="[buttonBase, buttonTone.primary]" type="submit">
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.primary]"
|
||||||
|
:disabled="!editorValid"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
@@ -235,6 +255,14 @@ onMounted(load)
|
|||||||
>{{ stringifyJson(preview.preview_payload) }}</pre
|
>{{ stringifyJson(preview.preview_payload) }}</pre
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="editingId && localPreviewPayload" :class="[cardClass, 'p-5']">
|
||||||
|
<h2 class="font-semibold">当前配置预览</h2>
|
||||||
|
<pre
|
||||||
|
class="mt-3 max-h-96 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100"
|
||||||
|
>{{ stringifyJson(localPreviewPayload) }}</pre
|
||||||
|
>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client", "node"],
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
@@ -62,3 +62,10 @@ def test_new_frontend_replaces_starter_component() -> None:
|
|||||||
|
|
||||||
assert "HelloWorld" not in app
|
assert "HelloWorld" not in app
|
||||||
assert not (SRC_ROOT / "components" / "HelloWorld.vue").exists()
|
assert not (SRC_ROOT / "components" / "HelloWorld.vue").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_switch_is_icon_only_but_accessible() -> None:
|
||||||
|
layout = (SRC_ROOT / "components" / "AppLayout.vue").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "切换主题,当前${themeLabel}" in layout
|
||||||
|
assert "{{ themeLabel }}" not in layout
|
||||||
|
|||||||
Reference in New Issue
Block a user