feat(web): 用量日志页(阶段 6)
- 筛选区:key 标签输入(粘贴多行/逗号拆分、去重、localStorage 记忆)+ datetimerange(默认近 7 天、今天/近7天/近30天 快捷项)+ 查询(点按才发请求) - 卡片区:调用次数 / Token 用量 / 预估消费(2 位小数);未折算条数提示条;数据截止右端小字 + 悬停 per-key 明细(同步中 / 未收录 / 已停用) - 明细表格:8 列 + 展开行(缓存 / 流式 / request_id / 错误详情 / 请求路径 / quota 原值 / 订阅归属完整组);错误行 Tag;分页器(页码 + 20/50/100/200) - 六态:加载(卡片骨架 + 表格 loading)/ 初始引导 / 未收录提示 / 时段无记录 / 数据同步中… / 失败提示 + 重试 - 修复:naive-ui 展开行须写在列定义(type: 'expand');useMessage 显式导入(auto-imports 全局声明在 vue-tsc 6 下未生效) - 走查:Playwright + 系统 Chrome 逐态断言与截图,无 console 错误;新增 format 工具单测 6 项
This commit is contained in:
Vendored
+7
@@ -11,9 +11,13 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NLayout: typeof import('naive-ui')['NLayout']
|
||||
@@ -22,8 +26,11 @@ declare module 'vue' {
|
||||
NMenu: typeof import('naive-ui')['NMenu']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NSkeleton: typeof import('naive-ui')['NSkeleton']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatAmount, formatCount, formatTime, formatTimeShort } from '@/utils/format'
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('ISO(+08:00)→ `YYYY-MM-DD HH:mm:ss`', () => {
|
||||
expect(formatTime('2026-09-11T19:37:12+08:00')).toBe('2026-09-11 19:37:12')
|
||||
})
|
||||
|
||||
it('空值 → 「—」;无法识别原样返回', () => {
|
||||
expect(formatTime(null)).toBe('—')
|
||||
expect(formatTime(undefined)).toBe('—')
|
||||
expect(formatTime('not-a-time')).toBe('not-a-time')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTimeShort(当天简写)', () => {
|
||||
const now = new Date('2026-09-11T13:00:00Z') // 北京时间 21:00
|
||||
|
||||
it('当天 → `HH:mm:ss`', () => {
|
||||
expect(formatTimeShort('2026-09-11T19:37:12+08:00', now)).toBe('19:37:12')
|
||||
})
|
||||
|
||||
it('跨日 → 完整形式', () => {
|
||||
expect(formatTimeShort('2026-09-10T23:59:59+08:00', now)).toBe('2026-09-10 23:59:59')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCount / formatAmount', () => {
|
||||
it('千分位;null → 「—」', () => {
|
||||
expect(formatCount(53939847)).toBe('53,939,847')
|
||||
expect(formatCount(0)).toBe('0')
|
||||
expect(formatCount(null)).toBe('—')
|
||||
})
|
||||
|
||||
it('金额:汇总 2 位 / 明细 4 位;null → 「—」(不退化为额度美元)', () => {
|
||||
expect(formatAmount(23.456)).toBe('23.46')
|
||||
expect(formatAmount(0.008079785625, 4)).toBe('0.0081')
|
||||
expect(formatAmount(0)).toBe('0.00')
|
||||
expect(formatAmount(null)).toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 展示格式化(交互设计 §2 #4/#5):
|
||||
* - 时间统一到秒:`2026-09-11 19:37:12`(当天语境简写 `HH:mm:ss`)
|
||||
* - 金额分层:汇总 2 位小数 / 明细行 4 位小数;`null` → 「—」(绝不退化为额度美元)
|
||||
* - token 数与 quota 原值千分位;不标币种
|
||||
*/
|
||||
|
||||
/** ISO 8601(后端统一 `+08:00`)→ `2026-09-11 19:37:12`;无法识别原样返回;空值 → 「—」 */
|
||||
export function formatTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/.exec(iso)
|
||||
if (!m) return iso
|
||||
return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`
|
||||
}
|
||||
|
||||
/** 「今天」的北京时间日期串(`YYYY-MM-DD`)——输出时间自带 +08:00,按字符串前缀比较即可 */
|
||||
function beijingDateString(now: Date): string {
|
||||
return new Date(now.getTime() + 8 * 3_600_000).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** 当天语境简写 `HH:mm:ss`;跨日回落到完整形式 */
|
||||
export function formatTimeShort(iso: string | null | undefined, now: Date = new Date()): string {
|
||||
if (!iso) return '—'
|
||||
const full = formatTime(iso)
|
||||
if (!/^\d{4}-\d{2}-\d{2} /.test(full)) return full
|
||||
return iso.slice(0, 10) === beijingDateString(now) ? full.slice(11) : full
|
||||
}
|
||||
|
||||
/** 千分位整数(token 数 / quota 原值 / 条数);null → 「—」 */
|
||||
export function formatCount(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined) return '—'
|
||||
return n.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
/** 实付人民币:汇总 2 位 / 明细行 4 位;null → 「—」 */
|
||||
export function formatAmount(v: number | null | undefined, digits = 2): string {
|
||||
if (v === null || v === undefined) return '—'
|
||||
return v.toFixed(digits)
|
||||
}
|
||||
@@ -1,13 +1,412 @@
|
||||
<script setup lang="ts">
|
||||
// 用量日志页(交互设计 §3.1)——骨架占位:
|
||||
// 筛选区(key 标签输入 + datetimerange + 查询)→ 卡片区(次数 / 用量 / 消费)→ 未收录提示 → 明细表格 + 分页器
|
||||
// 数据形状见 docs/design/API契约.md §2.1
|
||||
// 用量日志页(交互设计 §3.1):筛选区(key 标签 + datetimerange + 快捷项)→ 卡片区
|
||||
// → 未收录提示 → 明细表格(展开行)+ 分页器;六种状态模式见交互设计 §2 #6
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NTag,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
KEYS_MAX,
|
||||
LOGS_DEFAULT_LIMIT,
|
||||
LOGS_LIMIT_OPTIONS,
|
||||
LOG_TYPE,
|
||||
LOG_TYPE_LABEL,
|
||||
type LogsQueryResponse,
|
||||
type QueryMetaKey,
|
||||
type QueryMetaKeyRegistered,
|
||||
type SummaryResponse,
|
||||
type UsageLogRow,
|
||||
} from '@naku/shared'
|
||||
|
||||
import { ApiError, api } from '@/api/client'
|
||||
import { formatAmount, formatCount, formatTime, formatTimeShort } from '@/utils/format'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const DAY_MS = 24 * 3_600_000
|
||||
const STORAGE_KEY = 'naku:usage-log-keys'
|
||||
|
||||
// ---- 筛选区状态(key 输入 localStorage 记住上次输入;点「查询」才发请求) ----
|
||||
const keyTags = ref<string[]>([])
|
||||
const range = ref<[number, number] | null>([Date.now() - 7 * DAY_MS, Date.now()])
|
||||
const shortcuts = {
|
||||
今天: (): [number, number] => {
|
||||
const start = new Date()
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return [start.getTime(), Date.now()]
|
||||
},
|
||||
近7天: (): [number, number] => [Date.now() - 7 * DAY_MS, Date.now()],
|
||||
近30天: (): [number, number] => [Date.now() - 30 * DAY_MS, Date.now()],
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
keyTags.value = loadStoredKeys()
|
||||
})
|
||||
|
||||
function loadStoredKeys(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
const arr = raw ? (JSON.parse(raw) as unknown) : []
|
||||
return Array.isArray(arr) ? arr.filter((k): k is string => typeof k === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 粘贴多行 / 逗号 / 分号自动拆分并按 key 去重 */
|
||||
function normalizeTags(tags: string[]): string[] {
|
||||
const out: string[] = []
|
||||
for (const tag of tags) {
|
||||
for (const part of String(tag).split(/[\s,,;;]+/)) {
|
||||
const k = part.trim()
|
||||
if (k && !out.includes(k)) out.push(k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function onTagsUpdate(value: string[]): void {
|
||||
keyTags.value = normalizeTags(value)
|
||||
}
|
||||
|
||||
// ---- 查询状态 ----
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
/** 是否已发起过查询(false = 初始态:页面中央引导,不渲染卡片与表格) */
|
||||
const queried = ref(false)
|
||||
const summary = ref<SummaryResponse | null>(null)
|
||||
const logs = ref<LogsQueryResponse | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref<number>(LOGS_DEFAULT_LIMIT)
|
||||
/** 最近一次查询参数——翻页只复用(不重算汇总、不读输入框) */
|
||||
const activeQuery = ref<{ keys: string[]; start: string; end: string } | null>(null)
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
if (e instanceof ApiError) return e.message
|
||||
return e instanceof Error ? e.message : '未知错误'
|
||||
}
|
||||
|
||||
async function runQuery(): Promise<void> {
|
||||
const keys = normalizeTags(keyTags.value)
|
||||
if (keys.length === 0) {
|
||||
message.warning('请先输入至少一个 key')
|
||||
return
|
||||
}
|
||||
if (keys.length > KEYS_MAX) {
|
||||
message.warning(`一次最多查询 ${KEYS_MAX} 个 key(当前 ${keys.length} 个)`)
|
||||
return
|
||||
}
|
||||
if (!range.value) {
|
||||
message.warning('请选择时间范围')
|
||||
return
|
||||
}
|
||||
const [startMs, endMs] = range.value
|
||||
if (startMs >= endMs) {
|
||||
message.warning('时间范围非法:开始必须早于结束')
|
||||
return
|
||||
}
|
||||
|
||||
keyTags.value = keys
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
|
||||
} catch {
|
||||
/* localStorage 不可用(隐私模式)时静默跳过 */
|
||||
}
|
||||
|
||||
const q = {
|
||||
keys,
|
||||
start: new Date(startMs).toISOString(),
|
||||
end: new Date(endMs).toISOString(),
|
||||
}
|
||||
activeQuery.value = q
|
||||
page.value = 1
|
||||
queried.value = true
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// 约定 5 / §2.1:首次并行请求,翻页只调明细
|
||||
const [summaryRes, logsRes] = await Promise.all([
|
||||
api.usageSummary<SummaryResponse>(q),
|
||||
api.usageLogsQuery<LogsQueryResponse>({
|
||||
...q,
|
||||
limit: pageSize.value,
|
||||
offset: 0,
|
||||
}),
|
||||
])
|
||||
summary.value = summaryRes
|
||||
logs.value = logsRes
|
||||
} catch (e) {
|
||||
error.value = describeError(e)
|
||||
summary.value = null
|
||||
logs.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 翻页 / 改每页条数:只调明细端点(汇总不重算) */
|
||||
async function loadLogs(): Promise<void> {
|
||||
const q = activeQuery.value
|
||||
if (!q) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
logs.value = await api.usageLogsQuery<LogsQueryResponse>({
|
||||
...q,
|
||||
limit: pageSize.value,
|
||||
offset: (page.value - 1) * pageSize.value,
|
||||
})
|
||||
} catch (e) {
|
||||
error.value = describeError(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageSizeChange(): void {
|
||||
page.value = 1
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
// ---- 派生状态 ----
|
||||
const unregistered = computed(() =>
|
||||
(summary.value?.meta.keys ?? []).filter((k): k is Extract<QueryMetaKey, { registered: false }> => !k.registered),
|
||||
)
|
||||
const registeredKeys = computed(() =>
|
||||
(summary.value?.meta.keys ?? []).filter((k): k is QueryMetaKeyRegistered => k.registered),
|
||||
)
|
||||
/** 数据截止:null = 参与 key 全部未首同步(交互设计「数据同步中…」) */
|
||||
const dataUntil = computed(() => summary.value?.meta.data_until ?? null)
|
||||
const dataUntilText = computed(() =>
|
||||
dataUntil.value ? formatTimeShort(dataUntil.value) : '数据同步中…',
|
||||
)
|
||||
|
||||
// ---- 明细表格 ----
|
||||
const columns = computed<DataTableColumns<UsageLogRow>>(() => [
|
||||
// 展开列(naive-ui 要求展开能力写在列定义里:type: 'expand' + renderExpand)
|
||||
{ type: 'expand', renderExpand: (row) => renderExpand(row), expandable: () => true },
|
||||
{ title: '时间', key: 'created_at', width: 176, render: (row) => formatTime(row.created_at) },
|
||||
{ title: 'key', key: 'token_name', width: 120, ellipsis: { tooltip: true } },
|
||||
{ title: '模型', key: 'model_name', minWidth: 160, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 88,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
size: 'small',
|
||||
bordered: false,
|
||||
type: row.type === LOG_TYPE.Error ? 'error' : 'default',
|
||||
},
|
||||
{ default: () => LOG_TYPE_LABEL[row.type] ?? String(row.type) },
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '输入 tokens',
|
||||
key: 'prompt_tokens',
|
||||
width: 116,
|
||||
align: 'right',
|
||||
render: (row) => formatCount(row.prompt_tokens),
|
||||
},
|
||||
{
|
||||
title: '输出 tokens',
|
||||
key: 'completion_tokens',
|
||||
width: 116,
|
||||
align: 'right',
|
||||
render: (row) => formatCount(row.completion_tokens),
|
||||
},
|
||||
{
|
||||
title: '消费',
|
||||
key: 'amount_rmb',
|
||||
width: 104,
|
||||
align: 'right',
|
||||
render: (row) => formatAmount(row.amount_rmb, 4),
|
||||
},
|
||||
{ title: '耗时', key: 'use_time', width: 80, align: 'right', render: (row) => `${row.use_time}s` },
|
||||
])
|
||||
|
||||
/** 展开行:完整字段(缓存 / 流式 / request_id / 错误详情 / 请求路径 / quota 原值 / 订阅归属完整组) */
|
||||
function renderExpand(row: UsageLogRow): ReturnType<typeof h> {
|
||||
const other = row.other ?? {}
|
||||
const text = (v: unknown): string =>
|
||||
v === null || v === undefined || v === '' ? '—' : typeof v === 'number' ? formatCount(v) : String(v)
|
||||
|
||||
const items: Array<{ label: string; value: string }> = [
|
||||
{ label: '缓存 tokens', value: text(other.cache_tokens) },
|
||||
{ label: '流式', value: row.is_stream ? '是' : '否' },
|
||||
{ label: 'quota 原值', value: formatCount(row.quota) },
|
||||
{ label: '请求路径', value: text(other.request_path) },
|
||||
{ label: 'request_id', value: text(row.request_id) },
|
||||
{ label: 'upstream_request_id', value: text(row.upstream_request_id) },
|
||||
{ label: '渠道', value: `${row.channel}${row.channel_name ? `(${row.channel_name})` : ''}` },
|
||||
{ label: '分组', value: text(row.group_name) },
|
||||
{ label: 'token_id', value: String(row.token_id) },
|
||||
{ label: '账号', value: `${row.username}(#${row.user_id})` },
|
||||
{ label: 'IP', value: text(row.ip) },
|
||||
{ label: '订阅', value: text(other.subscription_plan_title) },
|
||||
{ label: '订阅 ID', value: text(other.subscription_id) },
|
||||
{ label: '订阅总额度', value: text(other.subscription_total) },
|
||||
{ label: '订阅已用', value: text(other.subscription_used) },
|
||||
{ label: '订阅剩余', value: text(other.subscription_remain) },
|
||||
{ label: '计费来源', value: text(other.billing_source) },
|
||||
{ label: '模型倍率', value: text(other.model_ratio) },
|
||||
]
|
||||
if (row.content) items.push({ label: '错误详情', value: row.content })
|
||||
|
||||
return h(
|
||||
NDescriptions,
|
||||
{ column: 3, size: 'small', bordered: true, labelPlacement: 'top' },
|
||||
{
|
||||
default: () =>
|
||||
items.map((item) =>
|
||||
h(NDescriptionsItem, { label: item.label, key: item.label }, { default: () => item.value }),
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="usage-log">
|
||||
<h1>用量日志</h1>
|
||||
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.1" />
|
||||
<h1 class="page-title">用量查询</h1>
|
||||
|
||||
<!-- 筛选区 -->
|
||||
<n-card size="small" class="filter-card">
|
||||
<div class="filter-row">
|
||||
<n-dynamic-tags
|
||||
class="key-input"
|
||||
:value="keyTags"
|
||||
:disabled="loading"
|
||||
:max="20"
|
||||
@update:value="onTagsUpdate"
|
||||
/>
|
||||
<n-date-picker
|
||||
v-model:value="range"
|
||||
type="datetimerange"
|
||||
clearable
|
||||
:shortcuts="shortcuts"
|
||||
class="range-input"
|
||||
/>
|
||||
<n-button type="primary" :loading="loading" @click="runQuery">查询</n-button>
|
||||
</div>
|
||||
<div class="filter-hint">粘贴 sk-key(多行 / 逗号一次输入多个);时间范围默认最近 7 天</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 初始态(未输入 key) -->
|
||||
<div v-if="!queried" class="initial-empty">
|
||||
<n-empty size="large" description="输入你的 key 开始查询" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 查询失败(网络 / 5xx) -->
|
||||
<n-alert v-if="error" type="error" class="alert" title="查询失败">
|
||||
<div class="alert-body">
|
||||
<span>{{ error }}</span>
|
||||
<n-button size="tiny" @click="activeQuery ? loadLogs() : runQuery">重试</n-button>
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<!-- 卡片区(加载中显示骨架) -->
|
||||
<div class="cards">
|
||||
<n-card size="small" class="card">
|
||||
<n-skeleton v-if="loading && !summary" text :repeat="3" />
|
||||
<template v-else>
|
||||
<div class="card-label">调用次数</div>
|
||||
<div class="card-value">{{ formatCount(summary?.request_count ?? 0) }}</div>
|
||||
<div class="card-sub">
|
||||
消费 {{ formatCount(summary?.consume_count ?? 0) }} · 错误
|
||||
{{ formatCount(summary?.error_count ?? 0) }}
|
||||
</div>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" class="card">
|
||||
<n-skeleton v-if="loading && !summary" text :repeat="3" />
|
||||
<template v-else>
|
||||
<div class="card-label">Token 用量</div>
|
||||
<div class="card-value">
|
||||
{{
|
||||
formatCount(
|
||||
(summary?.prompt_tokens ?? 0) + (summary?.completion_tokens ?? 0),
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="card-sub">缓存命中 {{ formatCount(summary?.cache_tokens ?? 0) }}</div>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" class="card">
|
||||
<n-skeleton v-if="loading && !summary" text :repeat="3" />
|
||||
<template v-else>
|
||||
<div class="card-label">预估消费</div>
|
||||
<div class="card-value amount">{{ formatAmount(summary?.amount_rmb ?? 0) }}</div>
|
||||
<div class="card-sub">额度记账 {{ formatCount(summary?.quota ?? 0) }}</div>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<div class="data-until">
|
||||
<n-tooltip trigger="hover" :disabled="registeredKeys.length === 0">
|
||||
<template #trigger>
|
||||
<span class="data-until-text">数据截止 {{ dataUntilText }}</span>
|
||||
</template>
|
||||
<div class="until-detail">
|
||||
<div v-for="k in summary?.meta.keys ?? []" :key="k.registered ? k.key_id : k.key_hint">
|
||||
<template v-if="k.registered">
|
||||
{{ k.token_name }}:
|
||||
{{ k.log_synced_at ? formatTimeShort(k.log_synced_at) : '数据同步中…' }}
|
||||
<span class="until-status">
|
||||
({{ k.status === 'disabled' ? '已停用' : '运行中' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ k.key_hint }}:未收录</template>
|
||||
</div>
|
||||
</div>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未收录提示条 -->
|
||||
<n-alert v-if="unregistered.length > 0" type="warning" class="alert">
|
||||
以下 key 未收录:{{ unregistered.map((k) => k.key_hint).join('、') }}(请联系管理员登记)
|
||||
</n-alert>
|
||||
|
||||
<!-- 未折算提示(金额口径说明) -->
|
||||
<n-alert v-if="(summary?.unpriced_count ?? 0) > 0" type="info" class="alert" :bordered="false">
|
||||
其中 {{ formatCount(summary?.unpriced_count) }} 条未登记购买、未计入金额(明细中显示「—」)
|
||||
</n-alert>
|
||||
|
||||
<!-- 明细表格 -->
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="logs?.items ?? []"
|
||||
:loading="loading"
|
||||
:row-key="(row: UsageLogRow) => row.id"
|
||||
size="small"
|
||||
>
|
||||
<template #empty>
|
||||
<n-empty :description="loading ? '加载中…' : '该时段没有记录'" />
|
||||
</template>
|
||||
</n-data-table>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<div class="pager">
|
||||
<n-pagination
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:item-count="logs?.total ?? 0"
|
||||
:page-sizes="[...LOGS_LIMIT_OPTIONS]"
|
||||
show-size-picker
|
||||
@update:page="loadLogs"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,4 +416,85 @@
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.key-input {
|
||||
flex: 1 1 420px;
|
||||
min-width: 320px;
|
||||
}
|
||||
.range-input {
|
||||
width: 380px;
|
||||
}
|
||||
.filter-hint {
|
||||
margin-top: 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.initial-empty {
|
||||
padding: 120px 0;
|
||||
}
|
||||
.alert {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.alert-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.cards {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.card {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.card-label {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-value {
|
||||
margin-top: 6px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.card-sub {
|
||||
margin-top: 6px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.data-until {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-until-text {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
.until-detail {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.until-status {
|
||||
color: #bbb;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+4
-2
@@ -7,7 +7,7 @@
|
||||
| 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) |
|
||||
| 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) |
|
||||
| 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 |
|
||||
| 当前进度 | 阶段 0–5 已完成;下一批:**阶段 6**(前端 `/usage-log`) |
|
||||
| 当前进度 | 阶段 0–6 已完成;下一批:**阶段 7**(前端管理面:口令门帘 + 侧边栏 + 三页) |
|
||||
|
||||
---
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
| 16 | seed 脚本用假 key 常量 `sk-seed-demo-key-0001`(`SEED_KEY` 可覆盖);`apps/server/scripts/seed.ts` 幂等可重跑 | 实施计划 §6 已定「假 key 常量」;仅开发期 |
|
||||
| 17 | **jsonb 列写入统一走 `sql\`${obj}::jsonb\``**(`toJsonb()`,见 `src/sync/log-row.ts`)——drizzle + `bun-sql` 组合直接传 JS 对象会**双重编码**成 `jsonb` 的 *string* 值,SQL 侧 `other->>'k'` / `? 'k'` 全部失效(2026-09-15 实测定位) | 已在 `other` / `model_limits` 落地;新增 jsonb 列时照此办理 |
|
||||
| 18 | seed 为「日志行数最多的订阅」登记示例购买,其余订阅保持未登记(保留 `amount_rmb = null` 的展示样本) | 样例实测含两个订阅(89 / 90);可改 |
|
||||
| 19 | naive-ui composable(`useMessage` 等)在页面里**显式 import**——`auto-imports.d.ts` 的 `declare global` 在 vue-tsc 6 / TS 6 下未被识别(2026-09-15 实测;`components.d.ts` 的模板组件自动导入不受影响) | 若将来工具链修复可回退;显式 import 无副作用 |
|
||||
| 20 | 表格展开行写在列定义里(`{ type: 'expand', renderExpand }`)——naive-ui 的表级 `render-expand` prop 不生成展开列(2026-09-15 实测) | naive-ui 行为,非项目决策 |
|
||||
|
||||
## 2. 前提(2026-09-14 定)
|
||||
|
||||
@@ -61,7 +63,7 @@
|
||||
| 3 | **管理面其余**:purchases 五端点 + qpu 一端点 | curl 实测:CRUD 全流程 + 观测聚合(`matched_purchase_id` 判定正确) | ✅ 2026-09-15(33 项单测绿;curl 16 步——登记 201 / 缺必填与非法值与期间顺序 400 / 部分更新与显式清空 / 排序 / 404 / 物理删除 / 观测聚合 `matched_purchase_id` 判定正确) |
|
||||
| 4 | **同步引擎**:tick(5 分钟 + 防重入 + 启动即跑)· 日志整批事务入库 · 额度采集 · QPU 采样 · 401 状态机(N=3)· **seed 脚本**(§6) | 库中出现数据(seed 或真实采集);`[sync]` 日志符合引擎设计;401 状态机单测(连续 401 → 摘除且 `disabled_source = auto_401`) | ✅ 2026-09-15(45 项单测绿;seed 灌入 984 条日志 + QPU 基线 + 示例购买;引擎实测——幂等重跑新增 0 条、摘要日志符合设计、**401 端到端:1→2→3 次摘除且摘除后不再轮询**) |
|
||||
| 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加;**无归属购买的行 `amount_rmb = null` 且计入 `unpriced_count`**(2026-09-14 定) | ✅ 2026-09-15(56 项单测绿;curl 17 步——明细字段齐全 / 分页 / type 过滤 / 参数校验 6 类 400 / 未收录 200 空结果 / `data_until` 取最慢 / 金额按归属购买折算、未折算行 `null` + `unpriced_count` 计数正确) |
|
||||
| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:seed 数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ☐ |
|
||||
| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:seed 数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ✅ 2026-09-15(Playwright + 系统 Chrome 逐态断言:初始 / 加载 / 查询(卡片 984 · 298,041,838 · 4.90)/ 展开行 / 翻页 / 未收录 / 无记录 / 同步中 / 失败,无 console 错误;金额 `null` 显示「—」并伴未折算提示条) |
|
||||
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ |
|
||||
| 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user