feat: 初始化居家上门服务系统完整项目代码

- Spring Boot 后端服务 (hss-home-service)
- delivery-miniapp 配送小程序
- website 官网 (Nuxt)
- docs 架构设计文档
- Docker 容器化部署配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 09:04:49 +08:00
parent 46c7887a18
commit c02029a5f3
471 changed files with 42313 additions and 2 deletions

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const apps = ref<any[]>([])
const loading = ref(true)
const ready = ref(false)
const showCreate = ref(false)
const submitting = ref(false)
const form = ref({ patientId: '', serviceType: 'HOME_CARE', channel: 'WECHAT', contactName: '', contactPhone: '', address: '', regionCode: '441402001', notes: '' })
const serviceTypes = [{ v:'HOME_CARE',label:'居家护理' },{ v:'REHABILITATION',label:'康复训练' },{ v:'BATH_ASSIST',label:'助浴服务' },{ v:'ASSESSMENT',label:'能力评估' }]
const channels = ['WECHAT','APP','PHONE','COMMUNITY','HOSPITAL']
const statusMap: Record<string,string> = { DRAFT:'草稿',PENDING_ACCEPTANCE:'待受理',PENDING_ASSESSMENT:'待评估',ASSESSING:'评估中',ASSESSMENT_PASSED:'已评估',RETURNED:'已退回',CANCELLED:'已取消',REVIEWING:'复核中' }
const statusCls: Record<string,string> = { DRAFT:'bg-gray-100',PENDING_ACCEPTANCE:'bg-yellow-50 text-yellow-700',PENDING_ASSESSMENT:'bg-blue-50 text-blue-600',ASSESSING:'bg-indigo-50 text-indigo-600',ASSESSMENT_PASSED:'bg-green-50 text-green-600',RETURNED:'bg-red-50 text-red-600' }
function getAuthHeaders(): Record<string,string> {
const h: Record<string,string> = { 'Content-Type':'application/json','X-Tenant-Id':'1','X-Org-Id':'1','X-User-Role':'ADMIN','X-User-Id':'1' }
try { const u = JSON.parse(localStorage.getItem('hss_platform_user')||'{}'); if(u.userRole){h['X-User-Role']=u.userRole;h['X-User-Id']=u.userId} } catch {}
return h
}
async function apiFetch(path: string, opts: any = {}) {
const h = { ...getAuthHeaders(), 'Idempotency-Key': 'web-'+Date.now()+'-'+Math.random().toString(36).slice(2,6) }
return await $fetch('/api/hss' + path, { ...opts, headers: { ...h, ...(opts.headers||{}) } })
}
async function loadApps() {
try { const res = await apiFetch('/applications?page=1&size=30'); apps.value = res?.data?.records || res?.data || [] } catch {}
}
async function createApp() {
submitting.value = true
try { await apiFetch('/applications', { method:'POST', body: JSON.stringify(form.value) }); showCreate.value = false; await loadApps() }
catch(e: any) { alert(e?.data?.message || e?.message || '创建失败') }
finally { submitting.value = false }
}
async function doAction(id: number, action: string, body?: any) {
try { await apiFetch(`/applications/${id}/${action}`, { method:'POST', body: body ? JSON.stringify(body) : undefined }); await loadApps() }
catch(e: any) { alert(e?.data?.message || e?.message || '操作失败') }
}
onMounted(() => { ready.value = true; loadApps().finally(() => loading.value = false) })
</script>
<template>
<ClientOnly>
<div v-if="!ready" class="min-h-screen bg-surface flex items-center justify-center"><p class="text-text-secondary">加载中...</p></div>
<div v-else class="min-h-screen bg-surface flex">
<aside class="hidden lg:flex flex-col w-56 bg-white border-r shrink-0">
<div class="p-4 border-b"><a href="/platform" class="font-bold text-primary text-sm"> 返回工作台</a></div>
<div class="p-3"><div class="text-xs text-text-secondary">角色: {{ getAuthHeaders()['X-User-Role'] }}</div></div>
</aside>
<main class="flex-1 p-4 lg:p-8 overflow-auto">
<div class="flex items-center justify-between mb-6"><h2 class="text-xl font-bold">服务申请管理</h2><button @click="showCreate=!showCreate" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-700">+ 新建申请</button></div>
<div v-if="showCreate" class="bg-white rounded-2xl shadow-sm border p-6 mb-6 space-y-4">
<h3 class="font-bold">新建服务申请</h3>
<div class="grid md:grid-cols-2 gap-4">
<div><label class="text-xs text-text-secondary block mb-1">服务对象ID</label><input v-model="form.patientId" class="w-full px-3 py-2 border rounded-lg text-sm outline-none focus:border-primary" placeholder="如 2001"/></div>
<div><label class="text-xs text-text-secondary block mb-1">服务类型</label><select v-model="form.serviceType" class="w-full px-3 py-2 border rounded-lg text-sm outline-none focus:border-primary"><option v-for="s in serviceTypes" :key="s.v" :value="s.v">{{s.label}}</option></select></div>
<div><label class="text-xs text-text-secondary block mb-1">渠道</label><select v-model="form.channel" class="w-full px-3 py-2 border rounded-lg text-sm outline-none focus:border-primary"><option v-for="c in channels" :key="c" :value="c">{{c}}</option></select></div>
<div><label class="text-xs text-text-secondary block mb-1">联系人</label><input v-model="form.contactName" class="w-full px-3 py-2 border rounded-lg text-sm outline-none focus:border-primary" placeholder="姓名"/></div>
<div><label class="text-xs text-text-secondary block mb-1">电话</label><input v-model="form.contactPhone" class="w-full px-3 py-2 border rounded-lg text-sm outline-none focus:border-primary" placeholder="手机号"/></div>
<div><label class="text-xs text-text-secondary block mb-1">区域</label><input v-model="form.regionCode" class="w-full px-3 py-2 border rounded-lg text-sm outline-none focus:border-primary" placeholder="441402001"/></div>
<div class="md:col-span-2"><label class="text-xs text-text-secondary block mb-1">地址</label><input v-model="form.address" class="w-full px-3 py-2 border rounded-lg text-sm outline-none focus:border-primary" placeholder="详细地址"/></div>
</div>
<div class="flex gap-3"><button @click="createApp" :disabled="submitting" class="px-6 py-2 bg-primary text-white rounded-lg text-sm font-medium disabled:opacity-50">{{submitting?'提交中...':'提交申请'}}</button><button @click="showCreate=false" class="px-6 py-2 border rounded-lg text-sm">取消</button></div>
</div>
<div class="bg-white rounded-2xl shadow-sm border overflow-hidden">
<div v-if="loading" class="p-8 text-center text-text-secondary">加载中...</div>
<table v-else class="w-full text-sm"><thead><tr class="bg-gray-50 text-left text-xs text-text-secondary"><th class="px-4 py-3">ID</th><th class="px-4 py-3">对象</th><th class="px-4 py-3">类型</th><th class="px-4 py-3">状态</th><th class="px-4 py-3">联系人</th><th class="px-4 py-3">时间</th><th class="px-4 py-3">操作</th></tr></thead>
<tbody><tr v-for="a in apps" :key="a.id" class="border-t border-gray-50 hover:bg-gray-50">
<td class="px-4 py-3 font-mono text-xs">#{{a.id}}</td><td class="px-4 py-3">{{a.patientId}}</td><td class="px-4 py-3 text-xs">{{a.serviceType}}</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs font-medium" :class="statusCls[a.status]||'bg-gray-100'">{{statusMap[a.status]||a.status}}</span></td>
<td class="px-4 py-3 text-xs">{{a.contactName||'-'}}</td><td class="px-4 py-3 text-xs text-text-secondary">{{a.createdAt?.substring(0,16)||'-'}}</td>
<td class="px-4 py-3"><div class="flex gap-1 flex-wrap">
<button v-if="a.status==='DRAFT'" @click="doAction(a.id,'submit')" class="px-2 py-1 bg-blue-50 text-blue-600 rounded text-xs hover:bg-blue-100">提交</button>
<button v-if="a.status==='PENDING_ACCEPTANCE'" @click="doAction(a.id,'accept')" class="px-2 py-1 bg-green-50 text-green-600 rounded text-xs hover:bg-green-100">受理</button>
<button v-if="a.status==='PENDING_ACCEPTANCE'" @click="doAction(a.id,'return',{reason:'退回原因'})" class="px-2 py-1 bg-red-50 text-red-600 rounded text-xs hover:bg-red-100">退回</button>
<button v-if="!['CANCELLED','RETURNED'].includes(a.status)" @click="doAction(a.id,'cancel')" class="px-2 py-1 bg-gray-50 text-gray-500 rounded text-xs hover:bg-gray-100">取消</button>
</div></td>
</tr></tbody>
</table>
</div>
</main>
</div>
</ClientOnly>
</template>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usePlatformAuth } from '~/composables/usePlatformAuth'
import { useApi } from '~/composables/useApi'
definePageMeta({ layout: false })
const { isLoggedIn, user, logout, switchRole, getAuthHeaders, ROLES } = usePlatformAuth()
const { get, post } = useApi()
const stats = ref<Record<string,any>>({})
const recentOrders = ref<any[]>([])
const activeMenu = ref('dashboard')
if (!isLoggedIn.value) { await navigateTo('/platform/login') }
onMounted(async () => {
try { stats.value = await get<any>('/admin/dashboard').catch(() => ({})) } catch {}
})
const menuItems = [
{ key: 'dashboard', label: '工作台', icon: 'chart' },
{ key: 'applications', label: '服务申请', icon: 'clipboard', href: '/platform/applications' },
{ key: 'work-orders', label: '工单管理', icon: 'document', href: '/platform/work-orders' },
]
const roleMenus: Record<string, string[]> = {
RECEPTIONIST: ['applications'],
ASSESSOR: ['applications'],
DISPATCHER: ['work-orders'],
STAFF: ['work-orders'],
SETTLER: ['dashboard'],
SUPERVISOR: ['dashboard'],
ADMIN: ['applications', 'work-orders'],
}
</script>
<template>
<div class="min-h-screen bg-surface flex">
<!-- Sidebar -->
<aside class="hidden lg:flex flex-col w-56 bg-white border-r shrink-0">
<div class="p-4 border-b">
<NuxtLink to="/platform" class="flex items-center gap-2 font-bold text-primary">
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-white text-xs font-mono">H</div>
<span class="text-sm">智慧医养平台</span>
</NuxtLink>
</div>
<nav class="flex-1 p-3 space-y-1">
<NuxtLink v-for="m in menuItems" :key="m.key" :to="m.href || '/platform'"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
:class="activeMenu === m.key ? 'bg-primary-50 text-primary font-medium' : 'text-text-secondary hover:bg-gray-50'">
<AppIcon :name="m.icon" class="w-4 h-4" /> {{ m.label }}
</NuxtLink>
</nav>
<div class="p-3 border-t">
<div class="flex items-center gap-2 px-3 py-2 text-sm">
<div class="w-7 h-7 rounded-full bg-primary-50 text-primary flex items-center justify-center text-xs font-bold">{{ user?.userName?.charAt(0) }}</div>
<div class="min-w-0">
<div class="text-xs font-medium truncate">{{ user?.userName }}</div>
<select @change="switchRole(($event.target as HTMLSelectElement).value)"
class="text-xs text-text-secondary bg-transparent border-none outline-none cursor-pointer">
<option v-for="r in ROLES" :key="r.key" :value="r.key" :selected="user?.userRole === r.key">{{ r.label }}</option>
</select>
</div>
</div>
<button @click="logout(); navigateTo('/platform/login')"
class="w-full mt-2 px-3 py-2 text-xs text-text-secondary hover:text-red-500 transition-colors text-left rounded-lg hover:bg-red-50">退出登录</button>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
<!-- Mobile header -->
<div class="lg:hidden bg-white border-b px-4 py-3 flex items-center justify-between">
<NuxtLink to="/platform" class="font-bold text-primary text-sm">智慧医养平台</NuxtLink>
<div class="flex items-center gap-2">
<select @change="switchRole(($event.target as HTMLSelectElement).value)"
class="text-xs border rounded-lg px-2 py-1 outline-none">
<option v-for="r in ROLES" :key="r.key" :value="r.key" :selected="user?.userRole === r.key">{{ r.label }}</option>
</select>
<button @click="logout(); navigateTo('/platform/login')" class="text-xs text-red-500">退出</button>
</div>
</div>
<div class="p-4 lg:p-8">
<h2 class="text-xl font-bold mb-6">工作台 {{ ROLES.find(r=>r.key===user?.userRole)?.label || user?.userRole }}</h2>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl p-4 shadow-sm border"><div class="text-2xl font-bold font-mono text-primary">{{ stats.todayOrders || '--' }}</div><div class="text-xs text-text-secondary mt-1">今日工单</div></div>
<div class="bg-white rounded-xl p-4 shadow-sm border"><div class="text-2xl font-bold font-mono text-accent-700">{{ stats.inProgress || '--' }}</div><div class="text-xs text-text-secondary mt-1">进行中</div></div>
<div class="bg-white rounded-xl p-4 shadow-sm border"><div class="text-2xl font-bold font-mono text-green-600">{{ stats.completedToday || '--' }}</div><div class="text-xs text-text-secondary mt-1">已完成</div></div>
<div class="bg-white rounded-xl p-4 shadow-sm border"><div class="text-2xl font-bold font-mono text-red-500">{{ stats.exceptions || '--' }}</div><div class="text-xs text-text-secondary mt-1">异常</div></div>
</div>
<!-- Quick actions based on role -->
<div class="grid lg:grid-cols-2 gap-6">
<div class="bg-white rounded-2xl shadow-sm border p-6">
<h3 class="font-bold mb-4">快捷操作</h3>
<div class="grid grid-cols-2 gap-3">
<NuxtLink v-if="['RECEPTIONIST','ADMIN'].includes(user?.userRole||'')" to="/platform/applications"
class="p-4 rounded-xl bg-primary-50 text-primary text-sm font-medium hover:bg-primary hover:text-white transition-colors text-center">受理新申请</NuxtLink>
<NuxtLink v-if="['DISPATCHER','ADMIN'].includes(user?.userRole||'')" to="/platform/work-orders"
class="p-4 rounded-xl bg-accent-50 text-accent-700 text-sm font-medium hover:bg-accent hover:text-white transition-colors text-center">查看工单</NuxtLink>
<NuxtLink to="/demo" class="p-4 rounded-xl bg-gray-50 text-text-secondary text-sm font-medium hover:bg-gray-100 transition-colors text-center">平台演示</NuxtLink>
<NuxtLink to="/" class="p-4 rounded-xl bg-gray-50 text-text-secondary text-sm font-medium hover:bg-gray-100 transition-colors text-center">返回官网</NuxtLink>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm border p-6">
<h3 class="font-bold mb-4">当前角色权限</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between py-2 border-b border-gray-50"><span class="text-text-secondary">角色</span><span class="font-medium">{{ ROLES.find(r=>r.key===user?.userRole)?.label }}</span></div>
<div class="flex justify-between py-2 border-b border-gray-50"><span class="text-text-secondary">可操作模块</span><span class="font-medium">{{ (roleMenus[user?.userRole||'']||['dashboard']).join(', ') }}</span></div>
<div class="flex justify-between py-2"><span class="text-text-secondary">数据范围</span><span class="font-medium">本机构</span></div>
</div>
</div>
</div>
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
definePageMeta({ ssr: false })
import { ref } from 'vue'
import { usePlatformAuth } from '~/composables/usePlatformAuth'
const { login, PRESET_USERS } = usePlatformAuth()
const selected = ref('admin')
const loading = ref(false)
const error = ref('')
async function doLogin() {
loading.value = true; error.value = ''
const u = login(selected.value)
if (u) {
await navigateTo('/platform')
} else {
error.value = '登录失败'
}
loading.value = false
}
</script>
<template>
<div class="min-h-screen bg-surface flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
<span class="text-white font-bold text-2xl font-mono">H</span>
</div>
<h1 class="text-2xl font-bold">智慧医养居家上门服务平台</h1>
<p class="text-text-secondary text-sm mt-2">演示环境 选择角色即可登录</p>
</div>
<div class="bg-white rounded-2xl shadow-sm border p-6 space-y-4">
<label class="block text-sm font-medium text-text-secondary">选择登录角色</label>
<div class="grid grid-cols-2 gap-2">
<button v-for="(u, k) in PRESET_USERS" :key="k"
@click="selected = k"
class="text-left px-4 py-3 rounded-xl border-2 transition-all text-sm"
:class="selected === k ? 'border-primary bg-primary-50 text-primary' : 'border-gray-100 hover:border-gray-200'">
<div class="font-medium">{{ u.userName }}</div>
<div class="text-xs text-text-secondary">{{ u.userRole }}</div>
</button>
</div>
<p v-if="error" class="text-red-500 text-sm text-center">{{ error }}</p>
<button @click="doLogin" :disabled="loading"
class="w-full py-3 bg-primary text-white rounded-xl font-semibold hover:bg-primary-700 transition-colors disabled:opacity-50">
{{ loading ? '登录中...' : '进入平台' }}
</button>
<p class="text-xs text-text-secondary text-center">
演示环境使用 Header 认证无需密码选择角色后直接进入对应工作台
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const orders = ref<any[]>([])
const loading = ref(true)
const ready = ref(false)
const statusFilter = ref('')
const statusMap: Record<string,{label:string;cls:string}> = {
ORDER_CREATED: { label:'待派单', cls:'bg-gray-100 text-gray-600' },
ORDER_ASSIGNED: { label:'已派单', cls:'bg-blue-50 text-blue-600' },
ORDER_ACCEPTED: { label:'已接单', cls:'bg-indigo-50 text-indigo-600' },
ORDER_CHECKED_IN: { label:'已签到', cls:'bg-teal-50 text-teal-600' },
ORDER_IN_SERVICE: { label:'服务中', cls:'bg-accent-50 text-accent-700' },
ORDER_COMPLETED: { label:'已完成', cls:'bg-green-50 text-green-600' },
ORDER_EXCEPTION: { label:'异常', cls:'bg-red-50 text-red-600' },
ACCEPTED: { label:'已验收', cls:'bg-green-100 text-green-700' },
}
function getAuthHeaders(): Record<string,string> {
const h: Record<string,string> = { 'Content-Type':'application/json','X-Tenant-Id':'1','X-Org-Id':'1','X-User-Role':'ADMIN','X-User-Id':'1' }
try { const u = JSON.parse(localStorage.getItem('hss_platform_user')||'{}'); if(u.userRole){h['X-User-Role']=u.userRole;h['X-User-Id']=u.userId} } catch {}
return h
}
async function apiFetch(path: string, opts: any = {}) {
const h = { ...getAuthHeaders(), 'Idempotency-Key': 'web-'+Date.now() }
return await $fetch('/api/hss' + path, { ...opts, headers: { ...h, ...(opts.headers||{}) } })
}
async function loadOrders() {
try { const res = await apiFetch('/admin/work-orders?page=1&size=30'); orders.value = res?.data || [] } catch {}
}
async function doAction(id: number, action: string, body?: any) {
try { await apiFetch(`/work-orders/${id}/${action}`, { method:'POST', body: body ? JSON.stringify(body) : undefined }); await loadOrders() }
catch(e: any) { alert(e?.data?.message || e?.message || '操作失败') }
}
onMounted(() => { ready.value = true; loadOrders().finally(() => loading.value = false) })
</script>
<template>
<ClientOnly>
<div v-if="!ready" class="min-h-screen bg-surface flex items-center justify-center"><p class="text-text-secondary">加载中...</p></div>
<div v-else class="min-h-screen bg-surface flex">
<aside class="hidden lg:flex flex-col w-56 bg-white border-r shrink-0">
<div class="p-4 border-b"><a href="/platform" class="font-bold text-primary text-sm"> 返回工作台</a></div>
<div class="p-3 space-y-1">
<button v-for="s in ['', ...Object.keys(statusMap)]" :key="s" @click="statusFilter = s"
class="block w-full text-left px-3 py-2 rounded-lg text-xs transition-colors"
:class="statusFilter===s ? 'bg-primary-50 text-primary font-medium' : 'text-text-secondary hover:bg-gray-50'">
{{ s ? statusMap[s]?.label : '全部工单' }}
</button>
</div>
</aside>
<main class="flex-1 p-4 lg:p-8 overflow-auto">
<h2 class="text-xl font-bold mb-6">工单管理</h2>
<div class="bg-white rounded-2xl shadow-sm border overflow-hidden">
<div v-if="loading" class="p-8 text-center text-text-secondary">加载中...</div>
<table v-else class="w-full text-sm"><thead><tr class="bg-gray-50 text-left text-xs text-text-secondary"><th class="px-4 py-3">ID</th><th class="px-4 py-3">服务日期</th><th class="px-4 py-3">服务对象</th><th class="px-4 py-3">状态</th><th class="px-4 py-3">人员</th><th class="px-4 py-3">操作</th></tr></thead>
<tbody><tr v-for="o in orders.filter((x:any) => !statusFilter || x.status===statusFilter)" :key="o.id" class="border-t border-gray-50 hover:bg-gray-50">
<td class="px-4 py-3 font-mono text-xs">#{{o.id}}</td><td class="px-4 py-3 text-xs">{{o.serviceDate}}</td><td class="px-4 py-3 text-xs">{{o.patientId}}</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs font-medium" :class="statusMap[o.status]?.cls||'bg-gray-100'">{{statusMap[o.status]?.label||o.status}}</span></td>
<td class="px-4 py-3 text-xs">{{o.staffId||'-'}}</td>
<td class="px-4 py-3"><div class="flex gap-1 flex-wrap">
<button v-if="o.status==='ORDER_CREATED'" @click="doAction(o.id,'assign',{staffId:1,reason:'派单'})" class="px-2 py-1 bg-blue-50 text-blue-600 rounded text-xs hover:bg-blue-100">派单</button>
<button v-if="o.status==='ORDER_ASSIGNED'" @click="doAction(o.id,'accept')" class="px-2 py-1 bg-green-50 text-green-600 rounded text-xs hover:bg-green-100">接单</button>
<button v-if="o.status==='ORDER_ACCEPTED'" @click="doAction(o.id,'check-in',{latitude:24.2878,longitude:116.1271,photoFileId:'test',patientConfirmed:true})" class="px-2 py-1 bg-teal-50 text-teal-600 rounded text-xs hover:bg-teal-100">签到</button>
<button v-if="o.status==='ORDER_CHECKED_IN'" @click="doAction(o.id,'start-service')" class="px-2 py-1 bg-accent-50 text-accent-600 rounded text-xs hover:bg-accent-100">开始</button>
<button v-if="o.status==='ORDER_IN_SERVICE'" @click="doAction(o.id,'finish',{executionRecords:[{planItemId:1,status:'COMPLETED',notes:'完成'}],serviceSummary:'完成',signOffLatitude:24.2878,signOffLongitude:116.1271})" class="px-2 py-1 bg-green-50 text-green-600 rounded text-xs hover:bg-green-100">完成</button>
<button v-if="o.status==='ORDER_IN_SERVICE'" @click="doAction(o.id,'report-exception',{exceptionType:'PATIENT_ABSENT',description:'对象不在家'})" class="px-2 py-1 bg-red-50 text-red-600 rounded text-xs hover:bg-red-100">异常</button>
</div></td>
</tr></tbody>
</table>
</div>
</main>
</div>
</ClientOnly>
</template>