feat(fam-ui): 各页面加载状态 - 数据到达前不再是空白/半截页面
新增 Spinner 组件,接入时间轴/AI对话历史/人物管理/统计图表/模型统计/ 服务状态 6 个页面。People.vue 特殊处理:命名或合并后触发的刷新不重新 显示整页 Spinner(此时列表已有数据,会闪一下丢失滚动位置),只有首次 加载(列表为空)才转圈。ServiceStatus.vue 同理,手动点刷新只变按钮文 字,不重新蒙一层 Spinner。
This commit is contained in:
15
fam-ui/src/components/Spinner.vue
Normal file
15
fam-ui/src/components/Spinner.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: { type: String, default: '加载中…' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-text-mute">
|
||||
<svg class="h-6 w-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span class="text-sm">{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,20 +3,25 @@ import { onMounted, ref, watch } from 'vue'
|
||||
import { api, fmtDateTime } from '../api.js'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
|
||||
const page = ref(0)
|
||||
const pageSize = 20
|
||||
const history = ref([])
|
||||
const loadError = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
async function load() {
|
||||
loadError.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.chatHistory({ limit: pageSize, offset: page.value * pageSize })
|
||||
history.value = data.history
|
||||
} catch (e) {
|
||||
loadError.value = e.message
|
||||
history.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +32,8 @@ onMounted(load)
|
||||
<template>
|
||||
<PageHeader icon="📝" title="对话历史" sub="历史问答记录" />
|
||||
|
||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||
<Spinner v-if="loading" text="加载对话历史…" />
|
||||
<EmptyState v-else-if="loadError" icon="⚠" :text="loadError" />
|
||||
<EmptyState v-else-if="!history.length" icon="💬" text="暂无对话记录" />
|
||||
|
||||
<template v-else>
|
||||
|
||||
@@ -3,16 +3,29 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { api, fmtDateTime } from '../api.js'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
import Badge from '../components/Badge.vue'
|
||||
|
||||
const aggregate = ref([])
|
||||
const calls = ref([])
|
||||
const loadError = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
// Gemini 适配器把实际用的 key 编号拼进 model 字段(如 "gemini-flash-latest·key2"),
|
||||
// 这里拆出来单独显示成一个小徽章,而不是让用户在一长串字符串里自己找。
|
||||
function splitKey(model) {
|
||||
const idx = (model || '').lastIndexOf('·key')
|
||||
if (idx === -1) return { model, key: '' }
|
||||
return { model: model.slice(0, idx), key: model.slice(idx + 1) }
|
||||
}
|
||||
|
||||
const aggRows = computed(() => aggregate.value.map(r => {
|
||||
const total = (r.ok_cnt || 0) + (r.fail_cnt || 0)
|
||||
const rate = total ? ((r.ok_cnt || 0) / total * 100) : 0
|
||||
const { model, key } = splitKey(r.model)
|
||||
return {
|
||||
model: `${r.provider} / ${r.model}`,
|
||||
provider: r.provider,
|
||||
model, key,
|
||||
ok: r.ok_cnt || 0,
|
||||
fail: r.fail_cnt || 0,
|
||||
rate: rate.toFixed(1),
|
||||
@@ -21,22 +34,28 @@ const aggRows = computed(() => aggregate.value.map(r => {
|
||||
}
|
||||
}))
|
||||
|
||||
const callRows = computed(() => calls.value.map(c => ({
|
||||
const callRows = computed(() => calls.value.map(c => {
|
||||
const { model, key } = splitKey(c.model)
|
||||
return {
|
||||
time: c.started_at ? fmtDateTime(c.started_at) : '—',
|
||||
model: `${c.provider} / ${c.model}`,
|
||||
provider: c.provider, model, key,
|
||||
duration: (c.duration_sec || 0).toFixed(1),
|
||||
success: !!c.success,
|
||||
error: (c.error || '').slice(0, 60),
|
||||
filename: (c.filename || '').slice(0, 40),
|
||||
})))
|
||||
}
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.modelStats()
|
||||
aggregate.value = data.aggregate
|
||||
calls.value = data.recent_calls
|
||||
} catch (e) {
|
||||
loadError.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -44,6 +63,8 @@ onMounted(async () => {
|
||||
<template>
|
||||
<PageHeader icon="🤖" title="云端模型统计" sub="请求时间 · 耗时 · 成功/失败 · 失败原因" />
|
||||
|
||||
<Spinner v-if="loading" text="加载模型统计…" />
|
||||
<template v-else>
|
||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||
|
||||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">按模型聚合</div>
|
||||
@@ -53,6 +74,7 @@ onMounted(async () => {
|
||||
<thead>
|
||||
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||||
<th class="px-4 py-2 font-medium">模型</th>
|
||||
<th class="px-4 py-2 font-medium">Key</th>
|
||||
<th class="px-4 py-2 font-medium">成功</th>
|
||||
<th class="px-4 py-2 font-medium">失败</th>
|
||||
<th class="px-4 py-2 font-medium">成功率%</th>
|
||||
@@ -62,7 +84,8 @@ onMounted(async () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in aggRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||||
<td class="px-4 py-2 font-medium text-text">{{ r.model }}</td>
|
||||
<td class="px-4 py-2 font-medium text-text">{{ r.provider }} / {{ r.model }}</td>
|
||||
<td class="px-4 py-2"><Badge v-if="r.key" tone="violet">{{ r.key }}</Badge><span v-else class="text-text-faint">—</span></td>
|
||||
<td class="px-4 py-2 text-ok">{{ r.ok }}</td>
|
||||
<td class="px-4 py-2 text-danger">{{ r.fail }}</td>
|
||||
<td class="px-4 py-2 font-mono tabular">{{ r.rate }}</td>
|
||||
@@ -81,6 +104,7 @@ onMounted(async () => {
|
||||
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||||
<th class="px-4 py-2 font-medium">请求时间</th>
|
||||
<th class="px-4 py-2 font-medium">模型</th>
|
||||
<th class="px-4 py-2 font-medium">Key</th>
|
||||
<th class="px-4 py-2 font-medium">耗时s</th>
|
||||
<th class="px-4 py-2 font-medium">状态</th>
|
||||
<th class="px-4 py-2 font-medium">失败原因</th>
|
||||
@@ -90,7 +114,8 @@ onMounted(async () => {
|
||||
<tbody>
|
||||
<tr v-for="(c, i) in callRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||||
<td class="px-4 py-2 font-mono tabular">{{ c.time }}</td>
|
||||
<td class="px-4 py-2">{{ c.model }}</td>
|
||||
<td class="px-4 py-2">{{ c.provider }} / {{ c.model }}</td>
|
||||
<td class="px-4 py-2"><Badge v-if="c.key" tone="violet">{{ c.key }}</Badge><span v-else class="text-text-faint">—</span></td>
|
||||
<td class="px-4 py-2 font-mono tabular">{{ c.duration }}</td>
|
||||
<td class="px-4 py-2" :class="c.success ? 'text-ok' : 'text-danger'">{{ c.success ? '✅ 成功' : '❌ 失败' }}</td>
|
||||
<td class="px-4 py-2 text-text-faint">{{ c.error }}</td>
|
||||
@@ -101,7 +126,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-text-mute">
|
||||
统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。
|
||||
统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。Key 列只显示配置里第几个
|
||||
Gemini Key 被用到(key1=主 key,key2/3/4=备用 key),不会显示密钥原文。
|
||||
失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -4,13 +4,18 @@ import { api } from '../api.js'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import PersonCard from '../components/PersonCard.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
|
||||
const groups = ref([])
|
||||
const allLabels = ref([])
|
||||
const loadError = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
async function load() {
|
||||
loadError.value = ''
|
||||
// 命名/合并后也会调用 load() 刷新——这时列表已经有内容,不重新显示大 Spinner
|
||||
// 闪掉整页(丢失滚动位置),只有首次加载(还没有任何数据)才转圈。
|
||||
if (!groups.value.length) loading.value = true
|
||||
try {
|
||||
const data = await api.people()
|
||||
groups.value = data.groups
|
||||
@@ -18,6 +23,8 @@ async function load() {
|
||||
} catch (e) {
|
||||
loadError.value = e.message
|
||||
groups.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +37,8 @@ onMounted(load)
|
||||
<template>
|
||||
<PageHeader icon="👤" title="人物管理" sub="所有出现的人物 · 命名与合并(回推甲骨文)" />
|
||||
|
||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||
<Spinner v-if="loading && !groups.length" text="加载人物列表…" />
|
||||
<EmptyState v-else-if="loadError" icon="⚠" :text="loadError" />
|
||||
<EmptyState v-else-if="!groups.length" icon="👤" text="暂未发现任何人物(甲骨文尚未同步)" />
|
||||
|
||||
<template v-else>
|
||||
|
||||
@@ -6,6 +6,7 @@ import EmptyState from '../components/EmptyState.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ServiceCard from '../components/ServiceCard.vue'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
|
||||
const data = ref(null)
|
||||
const oracleError = ref('')
|
||||
@@ -52,10 +53,14 @@ const SVC_BADGE = {
|
||||
<PageHeader icon="🖥" title="服务状态" sub="各服务实时动态 · 最近 7 天活动记录" />
|
||||
|
||||
<div class="mb-5 flex items-center gap-3">
|
||||
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">🔄 刷新</button>
|
||||
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">
|
||||
{{ loading && data ? '刷新中…' : '🔄 刷新' }}
|
||||
</button>
|
||||
<span class="text-xs text-text-mute">点击刷新立即更新</span>
|
||||
</div>
|
||||
|
||||
<Spinner v-if="loading && !data" text="加载服务状态…" />
|
||||
<template v-else>
|
||||
<p v-if="oracleError" class="mb-4 text-sm text-warn">{{ oracleError }}</p>
|
||||
<EmptyState v-if="!data?.oracle && !data?.nas_sync" text="暂时无法获取服务状态" />
|
||||
|
||||
@@ -112,4 +117,5 @@ const SVC_BADGE = {
|
||||
<p class="mt-2 text-xs text-text-mute">共 {{ activities.length }} 条记录(活动日志仅保留最近 7 天)</p>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -3,17 +3,20 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { api, fmtDateOnly, fmtDateTime } from '../api.js'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
|
||||
const modelChart = ref([]) // [{label, value}]
|
||||
const attention = ref([])
|
||||
const syncStatus = ref(null)
|
||||
const loadError = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
const BAR_COLORS = ['#5b8cff', '#8b6bff', '#3ddc9b', '#f5b84e', '#4fd1e8', '#ff7373']
|
||||
const maxVal = computed(() => Math.max(1, ...modelChart.value.map(m => m.value)))
|
||||
|
||||
async function load() {
|
||||
loadError.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const [ms, att, statusData] = await Promise.all([
|
||||
api.modelStats(),
|
||||
@@ -30,6 +33,8 @@ async function load() {
|
||||
syncStatus.value = statusData.sync
|
||||
} catch (e) {
|
||||
loadError.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +44,8 @@ onMounted(load)
|
||||
<template>
|
||||
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件 / 同步状态" />
|
||||
|
||||
<Spinner v-if="loading" text="加载统计数据…" />
|
||||
<template v-else>
|
||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||
|
||||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">模型来源分布</div>
|
||||
@@ -82,4 +89,5 @@ onMounted(load)
|
||||
<div v-if="syncStatus.last_error" class="font-semibold text-danger">⚠ {{ syncStatus.last_error }}</div>
|
||||
</div>
|
||||
<EmptyState v-else text="无法获取同步状态" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import StatCard from '../components/StatCard.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import EventItem from '../components/EventItem.vue'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
|
||||
const dateFilter = ref('')
|
||||
const page = ref(0)
|
||||
@@ -14,6 +15,8 @@ const stats = ref(null)
|
||||
const selectedId = ref(null)
|
||||
const detail = ref(null)
|
||||
const loadError = ref('')
|
||||
const videosLoading = ref(true)
|
||||
const detailLoading = ref(false)
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
@@ -25,6 +28,7 @@ async function loadStats() {
|
||||
|
||||
async function loadVideos() {
|
||||
loadError.value = ''
|
||||
videosLoading.value = true
|
||||
try {
|
||||
const data = await api.videos({ date: dateFilter.value || undefined, page: page.value })
|
||||
videos.value = data.videos
|
||||
@@ -34,15 +38,20 @@ async function loadVideos() {
|
||||
} catch (e) {
|
||||
loadError.value = e.message
|
||||
videos.value = []
|
||||
} finally {
|
||||
videosLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(id) {
|
||||
if (!id) { detail.value = null; return }
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await api.videoDetail(id)
|
||||
} catch (e) {
|
||||
detail.value = null
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +106,8 @@ const modelBadges = computed(() => {
|
||||
<StatCard :value="stats.attention ?? 0" label="需关注" tone="warn" />
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||
<Spinner v-if="videosLoading" text="加载视频会话…" />
|
||||
<EmptyState v-else-if="loadError" icon="⚠" :text="loadError" />
|
||||
<EmptyState v-else-if="!videos.length" icon="🗓" text="该日期暂无监控会话(甲骨文尚未同步数据?)" />
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_2.35fr]">
|
||||
@@ -120,7 +130,8 @@ const modelBadges = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail">
|
||||
<Spinner v-if="detailLoading" text="加载会话详情…" />
|
||||
<div v-else-if="detail">
|
||||
<div class="mb-6 rounded-2xl border border-border-hi p-5 shadow-[var(--shadow-card)]"
|
||||
style="background: linear-gradient(135deg, rgb(91 140 255 / .1), rgb(139 107 255 / .06));">
|
||||
<div class="flex flex-wrap items-center gap-2.5 text-lg font-bold text-[#f7f9fc]">
|
||||
|
||||
Reference in New Issue
Block a user