feat: new frontend demo

This commit is contained in:
2026-05-04 00:58:19 +08:00
parent 903bed57c0
commit 44f89c4f54
37 changed files with 4200 additions and 117 deletions
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { Eye, Plus, Save, Trash2 } from 'lucide-vue-next'
import { onMounted, reactive, ref } from 'vue'
import { templateApi, type Template, type TemplatePreview } from '@/api'
import StateBlock from '@/components/StateBlock.vue'
import {
buttonBase,
buttonTone,
cardClass,
inputClass,
textareaClass,
toneClass,
} from '@/components/ui'
import { extractErrorMessage, formatDateTime, stringifyJson } from '@/utils/format'
const loading = ref(true)
const error = ref('')
const message = ref('')
const templates = ref<Template[]>([])
const editingId = ref<number | 'new' | null>(null)
const preview = ref<TemplatePreview | null>(null)
const form = reactive({
name: '',
description: '',
field_config:
'{\n "signature": {\n "display_name": "姓名",\n "field_type": "text",\n "default_value": "",\n "required": true\n }\n}',
is_active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
templates.value = await templateApi.list()
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
loading.value = false
}
}
function startCreate() {
editingId.value = 'new'
preview.value = null
form.name = ''
form.description = ''
form.field_config =
'{\n "signature": {\n "display_name": "姓名",\n "field_type": "text",\n "default_value": "",\n "required": true\n }\n}'
form.is_active = true
}
function startEdit(template: Template) {
editingId.value = template.id
preview.value = null
form.name = template.name
form.description = template.description ?? ''
try {
form.field_config = stringifyJson(JSON.parse(template.field_config))
} catch {
form.field_config = template.field_config
}
form.is_active = template.is_active
}
async function save() {
error.value = ''
message.value = ''
try {
JSON.parse(form.field_config)
const payload = {
name: form.name,
description: form.description || null,
field_config: form.field_config,
is_active: form.is_active,
}
if (editingId.value === 'new') {
await templateApi.create(payload)
} else if (typeof editingId.value === 'number') {
await templateApi.update(editingId.value, payload)
}
editingId.value = null
message.value = '模板已保存'
await load()
} catch (err) {
error.value = extractErrorMessage(err)
}
}
async function showPreview(template: Template) {
error.value = ''
try {
preview.value = await templateApi.preview(template.id)
} catch (err) {
error.value = extractErrorMessage(err)
}
}
async function remove(template: Template) {
if (!window.confirm(`确认删除模板「${template.name}」?`)) return
error.value = ''
try {
await templateApi.delete(template.id)
await load()
} catch (err) {
error.value = extractErrorMessage(err)
}
}
onMounted(load)
</script>
<template>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
<section :class="[cardClass, 'overflow-hidden']">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<h2 class="font-semibold">模板管理</h2>
<button :class="[buttonBase, buttonTone.primary]" type="button" @click="startCreate">
<Plus class="size-4" />
新建模板
</button>
</div>
<StateBlock v-if="loading" title="正在加载模板" type="loading" />
<StateBlock
v-else-if="error && templates.length === 0"
title="模板加载失败"
:description="error"
type="error"
action-label="重试"
@action="load"
/>
<div v-else class="divide-y divide-zinc-200">
<article
v-for="template in templates"
:key="template.id"
class="flex flex-wrap items-center justify-between gap-3 p-4"
>
<div>
<div class="flex items-center gap-2">
<h3 class="font-semibold">{{ template.name }}</h3>
<span :class="toneClass(template.is_active ? 'success' : 'neutral')">{{
template.is_active ? '启用' : '停用'
}}</span>
</div>
<p class="mt-1 text-sm text-zinc-500">
{{ template.description || '无描述' }} · {{ formatDateTime(template.created_at) }}
</p>
</div>
<div class="flex gap-2">
<button
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="showPreview(template)"
>
<Eye class="size-4" />
预览
</button>
<button
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="startEdit(template)"
>
编辑
</button>
<button
:class="[buttonBase, buttonTone.danger]"
type="button"
@click="remove(template)"
>
<Trash2 class="size-4" />
删除
</button>
</div>
</article>
</div>
</section>
<aside class="grid gap-5">
<form v-if="editingId" :class="[cardClass, 'grid gap-4 p-5']" @submit.prevent="save">
<h2 class="font-semibold">{{ editingId === 'new' ? '新建模板' : '编辑模板' }}</h2>
<label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">名称</span>
<input v-model="form.name" :class="inputClass" required />
</label>
<label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">描述</span>
<input v-model="form.description" :class="inputClass" />
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.is_active" type="checkbox" />
启用模板
</label>
<label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">字段配置 JSON</span>
<textarea v-model="form.field_config" :class="textareaClass" class="min-h-64" />
</label>
<div
v-if="error"
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
>
{{ error }}
</div>
<div
v-if="message"
class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700"
>
{{ message }}
</div>
<div class="flex gap-2">
<button :class="[buttonBase, buttonTone.primary]" type="submit">
<Save class="size-4" />
保存
</button>
<button
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="editingId = null"
>
取消
</button>
</div>
</form>
<section v-if="preview" :class="[cardClass, 'p-5']">
<h2 class="font-semibold">{{ preview.template_name }} 预览</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(preview.preview_payload) }}</pre
>
</section>
</aside>
</div>
</template>