feat(web): 管理面三页(阶段 7)
- key 管理:清单 8 列 + 展开行(总额度 / 模型限制 / 401 计数 / 额度新鲜度 / 停用来源)+ 状态徽章三面孔(运行中 / 已停用 / 已失效 + tooltip);登记弹窗(三类失败文案);停用弹确认、启用直接执行 - 购买记录:清单 8 列 + 右侧抽屉表单(登记 / 编辑 / 补录共用;期间顺序校验);核对提示(未登记订阅提示条 + 观测折叠区 + 补录预填订阅 ID 与观测额度) - 换算参数:时间线两列 + 「当前生效」标记 + 页首说明(纯只读) - 修复:补 n-config-provider 中文 locale(naive-ui 默认英文按钮);两处表格 scroll-x(列宽被压缩致表头截断) - 走查:Playwright 15 步全操作通过(含三类登记结果、确认弹窗、补录与 CRUD)
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件:全局 provider(message / dialog / notification)+ 路由出口
|
||||
// 根组件:全局 provider(message / dialog / notification)+ naive-ui 中文 locale + 路由出口
|
||||
import { RouterView } from 'vue-router'
|
||||
import { dateZhCN, zhCN } from 'naive-ui'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
@@ -11,6 +13,7 @@ import { RouterView } from 'vue-router'
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
Vendored
+11
@@ -14,19 +14,30 @@ declare module 'vue' {
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NDrawer: typeof import('naive-ui')['NDrawer']
|
||||
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
||||
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NForm: typeof import('naive-ui')['NForm']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NLayout: typeof import('naive-ui')['NLayout']
|
||||
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
|
||||
NMenu: typeof import('naive-ui')['NMenu']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NSkeleton: typeof import('naive-ui')['NSkeleton']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
|
||||
@@ -1,13 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
// key 管理页(交互设计 §3.2)——骨架占位:
|
||||
// 清单 8 列(key 名 / 状态徽章 / 已用 / 剩余 / 到期 / 登记时间 / 数据截止 / 操作)
|
||||
// 登记:页头「+ 登记 key」→ n-modal;启停:行内按钮(停用弹确认、启用直接执行)
|
||||
// 数据形状见 docs/design/API契约.md §2.2
|
||||
// key 管理页(交互设计 §3.2):清单 8 列 + 展开行(总额度 / 模型限制 / 401 计数 / 额度新鲜度);
|
||||
// 登记(n-modal,三类失败文案);启停(停用弹确认、启用直接执行)
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NPopconfirm,
|
||||
NTag,
|
||||
NTooltip,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import type { ApiKeyRow } from '@naku/shared'
|
||||
|
||||
import { ApiError, api } from '@/api/client'
|
||||
import { formatCount, formatTime, formatTimeShort } from '@/utils/format'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const rows = ref<ApiKeyRow[]>([])
|
||||
const loading = ref(false)
|
||||
const patchingId = ref<number | null>(null)
|
||||
|
||||
const showModal = ref(false)
|
||||
const keyInput = ref('')
|
||||
const registering = ref(false)
|
||||
const registerError = ref('')
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await api.apiKeysList<ApiKeyRow[]>()
|
||||
} catch (e) {
|
||||
message.error(e instanceof ApiError ? e.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态徽章三面孔:运行中(绿)· 已停用(灰,manual)· 已失效(红,auto_401 + tooltip) */
|
||||
function renderStatus(row: ApiKeyRow) {
|
||||
if (row.status === 'active') {
|
||||
return h(NTag, { type: 'success', size: 'small', bordered: false }, { default: () => '运行中' })
|
||||
}
|
||||
if (row.disabled_source === 'auto_401') {
|
||||
return h(
|
||||
NTooltip,
|
||||
{ trigger: 'hover' },
|
||||
{
|
||||
trigger: () =>
|
||||
h(NTag, { type: 'error', size: 'small', bordered: false }, { default: () => '已失效' }),
|
||||
default: () => '连续 401 自动摘除——请确认 key 是否已被上游禁用',
|
||||
},
|
||||
)
|
||||
}
|
||||
return h(NTag, { size: 'small', bordered: false }, { default: () => '已停用' })
|
||||
}
|
||||
|
||||
async function patch(row: ApiKeyRow, status: 'active' | 'disabled'): Promise<void> {
|
||||
patchingId.value = row.id
|
||||
try {
|
||||
const updated = await api.apiKeyPatch<ApiKeyRow>(row.id, { status })
|
||||
rows.value = rows.value.map((r) => (r.id === updated.id ? updated : r))
|
||||
message.success(status === 'active' ? '已启用' : '已停用')
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.code === 'key_rejected') message.error('启用失败:上游拒绝该 key')
|
||||
else if (e.code === 'upstream_unavailable') message.error('启用失败:上游暂不可用,请稍后重试')
|
||||
else if (e.code === 'not_found') message.error('记录不存在,请刷新')
|
||||
else message.error(e.message)
|
||||
} else {
|
||||
message.error('操作失败')
|
||||
}
|
||||
} finally {
|
||||
patchingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function describeRegisterError(e: unknown): string {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.code === 'key_rejected') return '上游拒绝该 key(请确认 key 有效、未被上游禁用)'
|
||||
if (e.code === 'already_registered') return '该 key 已登记'
|
||||
if (e.code === 'upstream_unavailable') return '上游暂不可用,请稍后重试'
|
||||
return e.message
|
||||
}
|
||||
return '登记失败'
|
||||
}
|
||||
|
||||
async function submitRegister(): Promise<void> {
|
||||
const key = keyInput.value.trim()
|
||||
if (!key) {
|
||||
registerError.value = '请粘贴 sk-key'
|
||||
return
|
||||
}
|
||||
registering.value = true
|
||||
registerError.value = ''
|
||||
try {
|
||||
const created = await api.apiKeyRegister<ApiKeyRow>({ key })
|
||||
rows.value = [...rows.value, created]
|
||||
showModal.value = false
|
||||
keyInput.value = ''
|
||||
message.success('登记成功,首轮数据同步中…')
|
||||
} catch (e) {
|
||||
registerError.value = describeRegisterError(e)
|
||||
} finally {
|
||||
registering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<ApiKeyRow>>(() => [
|
||||
{ type: 'expand', renderExpand: (row) => renderExpand(row), expandable: () => true },
|
||||
{ title: 'key 名', key: 'token_name', minWidth: 140, ellipsis: { tooltip: true } },
|
||||
{ title: '状态', key: 'status', width: 96, render: (row) => renderStatus(row) },
|
||||
{
|
||||
title: '累计已用',
|
||||
key: 'total_used',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (row) => formatCount(row.total_used),
|
||||
},
|
||||
{
|
||||
title: '累计剩余',
|
||||
key: 'total_available',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (row) => (row.unlimited_quota ? '不限' : formatCount(row.total_available)),
|
||||
},
|
||||
{
|
||||
title: '到期',
|
||||
key: 'expires_at',
|
||||
width: 176,
|
||||
render: (row) => (row.expires_at ? formatTime(row.expires_at) : '不过期'),
|
||||
},
|
||||
{
|
||||
title: '登记时间',
|
||||
key: 'registered_at',
|
||||
width: 176,
|
||||
render: (row) => formatTime(row.registered_at),
|
||||
},
|
||||
{
|
||||
title: '数据截止',
|
||||
key: 'log_synced_at',
|
||||
width: 120,
|
||||
render: (row) => (row.log_synced_at ? formatTimeShort(row.log_synced_at) : '同步中'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 110,
|
||||
render: (row) => {
|
||||
const busy = patchingId.value === row.id
|
||||
if (row.status === 'active') {
|
||||
return h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => patch(row, 'disabled') },
|
||||
{
|
||||
trigger: () => h(NButton, { size: 'tiny', loading: busy }, { default: () => '停用' }),
|
||||
default: () => '确认停用该 key?停用后不再轮询。',
|
||||
},
|
||||
)
|
||||
}
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'tiny',
|
||||
type: 'primary',
|
||||
loading: busy,
|
||||
onClick: () => patch(row, 'active'),
|
||||
},
|
||||
{ default: () => '启用' },
|
||||
)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
/** 展开行:其余字段(总额度 / 模型限制 / 401 计数 / 额度新鲜度) */
|
||||
function renderExpand(row: ApiKeyRow): ReturnType<typeof h> {
|
||||
const limits = Object.keys(row.model_limits ?? {})
|
||||
const items = [
|
||||
{ label: '总额度', value: formatCount(row.total_granted) },
|
||||
{ label: '无限额度', value: row.unlimited_quota ? '是' : '否' },
|
||||
{ label: '模型限制', value: row.model_limits_enabled ? limits.join('、') || '(空)' : '未启用' },
|
||||
{ label: '连续 401 计数', value: String(row.consecutive_401s) },
|
||||
{
|
||||
label: '额度新鲜度',
|
||||
value: row.usage_synced_at ? formatTime(row.usage_synced_at) : '未采集',
|
||||
},
|
||||
{ label: '停用来源', value: row.disabled_source ?? '—' },
|
||||
]
|
||||
return h('div', { class: 'expand-grid' }, items.map((i) => h('div', [
|
||||
h('span', { class: 'expand-label' }, `${i.label}:`),
|
||||
h('span', i.value),
|
||||
])))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="api-keys">
|
||||
<div class="header">
|
||||
<h2>key 管理</h2>
|
||||
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.2" />
|
||||
<n-button type="primary" @click="showModal = true">+ 登记 key</n-button>
|
||||
</div>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:row-key="(row: ApiKeyRow) => row.id"
|
||||
:scroll-x="1280"
|
||||
size="small"
|
||||
>
|
||||
<template #empty>
|
||||
<n-empty :description="loading ? '加载中…' : '还没有登记任何 key'" />
|
||||
</template>
|
||||
</n-data-table>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
title="登记 key"
|
||||
class="register-modal"
|
||||
:style="{ width: '480px' }"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="keyInput"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="粘贴 sk-key(仅登记本次粘贴的 key)"
|
||||
/>
|
||||
<n-text v-if="registerError" type="error" class="register-error">{{ registerError }}</n-text>
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<n-button @click="showModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="registering" @click="submitRegister">登记</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.register-error {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
:deep(.expand-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
:deep(.expand-label) {
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,355 @@
|
||||
<script setup lang="ts">
|
||||
// 购买记录页(交互设计 §3.3)——骨架占位:
|
||||
// 清单 8 列 + 核对提示(未登记观测提示条 + 折叠区 + 补录)+ 登记/编辑用右侧 n-drawer
|
||||
// 数据形状见 docs/design/API契约.md §2.3
|
||||
// 购买记录页(交互设计 §3.3):清单 8 列 + 右侧抽屉表单(登记 / 编辑 / 补录);
|
||||
// 核对提示:观测到未登记的订阅 → 提示条 + 折叠区(补录预填 subscription_id 与观测额度)
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NPopconfirm, NTag, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import type { ObservationItem, PurchaseKind, PurchaseRow } from '@naku/shared'
|
||||
|
||||
import { ApiError, api } from '@/api/client'
|
||||
import { formatAmount, formatCount, formatTime } from '@/utils/format'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const rows = ref<PurchaseRow[]>([])
|
||||
const observations = ref<ObservationItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadObservations()
|
||||
})
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await api.purchasesList<PurchaseRow[]>()
|
||||
} catch (e) {
|
||||
message.error(e instanceof ApiError ? e.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadObservations(): Promise<void> {
|
||||
try {
|
||||
const res = await api.purchasesObservations<{ items: ObservationItem[] }>()
|
||||
observations.value = res.items
|
||||
} catch {
|
||||
observations.value = [] // 观测是辅助信息,失败不打扰主流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 观测到但未登记(补录引导的目标) */
|
||||
const unregisteredObs = computed(() =>
|
||||
observations.value.filter((o) => o.matched_purchase_id === null),
|
||||
)
|
||||
|
||||
// ---- 抽屉表单(登记 / 编辑 / 补录共用) ----
|
||||
const showDrawer = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formError = ref('')
|
||||
const form = reactive({
|
||||
kind: 'subscription' as PurchaseKind,
|
||||
paidAt: null as number | null,
|
||||
paidAmount: null as number | null,
|
||||
quotaGranted: null as number | null,
|
||||
subscriptionId: null as number | null,
|
||||
periodStart: null as number | null,
|
||||
periodEnd: null as number | null,
|
||||
note: '',
|
||||
})
|
||||
|
||||
function resetForm(): void {
|
||||
form.kind = 'subscription'
|
||||
form.paidAt = Date.now()
|
||||
form.paidAmount = null
|
||||
form.quotaGranted = null
|
||||
form.subscriptionId = null
|
||||
form.periodStart = null
|
||||
form.periodEnd = null
|
||||
form.note = ''
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
showDrawer.value = true
|
||||
}
|
||||
|
||||
/** 补录:预填 subscription_id 与观测额度(期间不预填——观测范围 ≠ 订阅期间) */
|
||||
function openBackfill(obs: ObservationItem): void {
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
form.subscriptionId = obs.subscription_id
|
||||
form.quotaGranted = obs.observed_quota_total ?? null
|
||||
form.note = `补录(观测名称:${obs.plan_title ?? '未知'})`
|
||||
showDrawer.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: PurchaseRow): void {
|
||||
editingId.value = row.id
|
||||
form.kind = row.kind
|
||||
form.paidAt = row.paid_at ? new Date(row.paid_at).getTime() : null
|
||||
form.paidAmount = row.paid_amount
|
||||
form.quotaGranted = row.quota_granted
|
||||
form.subscriptionId = row.subscription_id
|
||||
form.periodStart = row.period_start ? new Date(row.period_start).getTime() : null
|
||||
form.periodEnd = row.period_end ? new Date(row.period_end).getTime() : null
|
||||
form.note = row.note ?? ''
|
||||
formError.value = ''
|
||||
showDrawer.value = true
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!form.paidAt) {
|
||||
formError.value = '请选择支付时间'
|
||||
return
|
||||
}
|
||||
if (form.paidAmount === null) {
|
||||
formError.value = '请填写实付金额'
|
||||
return
|
||||
}
|
||||
if (form.quotaGranted === null) {
|
||||
formError.value = '请填写本笔额度(折算链的分母)'
|
||||
return
|
||||
}
|
||||
if (form.periodStart && form.periodEnd && form.periodStart >= form.periodEnd) {
|
||||
formError.value = '期间起点必须早于终点(左闭右开)'
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
kind: form.kind,
|
||||
paid_at: new Date(form.paidAt).toISOString(),
|
||||
paid_amount: form.paidAmount,
|
||||
quota_granted: form.quotaGranted,
|
||||
subscription_id: form.subscriptionId,
|
||||
period_start: form.periodStart ? new Date(form.periodStart).toISOString() : null,
|
||||
period_end: form.periodEnd ? new Date(form.periodEnd).toISOString() : null,
|
||||
note: form.note.trim() ? form.note.trim() : null,
|
||||
}
|
||||
saving.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
if (editingId.value === null) {
|
||||
await api.purchasesCreate<PurchaseRow>(payload)
|
||||
message.success('已登记')
|
||||
} else {
|
||||
await api.purchasesPatch<PurchaseRow>(editingId.value, payload)
|
||||
message.success('已保存')
|
||||
}
|
||||
showDrawer.value = false
|
||||
await Promise.all([load(), loadObservations()])
|
||||
} catch (e) {
|
||||
formError.value = e instanceof ApiError ? e.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: PurchaseRow): Promise<void> {
|
||||
try {
|
||||
await api.purchasesDelete<PurchaseRow>(row.id)
|
||||
message.success('已删除')
|
||||
await Promise.all([load(), loadObservations()])
|
||||
} catch (e) {
|
||||
message.error(e instanceof ApiError ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function formatPeriod(row: PurchaseRow): string {
|
||||
if (!row.period_start && !row.period_end) return '—'
|
||||
const start = row.period_start ? formatTime(row.period_start) : '…'
|
||||
const end = row.period_end ? formatTime(row.period_end) : '未结束'
|
||||
return `${start} ~ ${end}`
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<PurchaseRow>>(() => [
|
||||
{ title: '时间', key: 'paid_at', width: 176, render: (row) => formatTime(row.paid_at) },
|
||||
{
|
||||
title: '类型',
|
||||
key: 'kind',
|
||||
width: 88,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: row.kind === 'subscription' ? 'info' : 'default' },
|
||||
{ default: () => (row.kind === 'subscription' ? '订阅' : '充值') },
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '实付金额',
|
||||
key: 'paid_amount',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (row) => formatAmount(row.paid_amount),
|
||||
},
|
||||
{
|
||||
title: '订阅',
|
||||
key: 'subscription_id',
|
||||
width: 80,
|
||||
render: (row) => (row.subscription_id === null ? '—' : `#${row.subscription_id}`),
|
||||
},
|
||||
{ title: '期间', key: 'period', minWidth: 300, render: (row) => formatPeriod(row) },
|
||||
{
|
||||
title: '额度',
|
||||
key: 'quota_granted',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (row) => formatCount(row.quota_granted),
|
||||
},
|
||||
{ title: '备注', key: 'note', minWidth: 140, ellipsis: { tooltip: true }, render: (row) => row.note ?? '—' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row) =>
|
||||
h('div', { class: 'row-actions' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => remove(row) },
|
||||
{
|
||||
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '删除' }),
|
||||
default: () => '确认删除该笔记录?(物理删除,其日志金额随即算不出来)',
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="purchases">
|
||||
<div class="header">
|
||||
<h2>购买记录</h2>
|
||||
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.3" />
|
||||
<n-button type="primary" @click="openCreate">+ 登记购买</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 核对提示:观测到未登记的订阅 -->
|
||||
<n-alert v-if="unregisteredObs.length > 0" type="warning" class="alert">
|
||||
观测到 {{ unregisteredObs.length }} 个未登记的订阅——补录后其日志即可折算金额
|
||||
</n-alert>
|
||||
|
||||
<n-collapse v-if="observations.length > 0" class="obs-collapse">
|
||||
<n-collapse-item :title="`订阅观测(${observations.length} 项)`" name="obs">
|
||||
<n-data-table
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:data="observations"
|
||||
:row-key="(row: ObservationItem) => row.subscription_id"
|
||||
:columns="[
|
||||
{ title: '订阅', key: 'subscription_id', render: (r: ObservationItem) => `#${r.subscription_id}` },
|
||||
{ title: '名称', key: 'plan_title', render: (r: ObservationItem) => r.plan_title ?? '—' },
|
||||
{ title: '日志条数', key: 'log_count', render: (r: ObservationItem) => formatCount(r.log_count) },
|
||||
{
|
||||
title: '观测范围',
|
||||
key: 'range',
|
||||
render: (r: ObservationItem) => `${formatTime(r.first_seen_at)} ~ ${formatTime(r.last_seen_at)}`,
|
||||
},
|
||||
{
|
||||
title: '观测额度',
|
||||
key: 'observed_quota_total',
|
||||
render: (r: ObservationItem) => formatCount(r.observed_quota_total),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'matched',
|
||||
render: (r: ObservationItem) =>
|
||||
r.matched_purchase_id === null
|
||||
? h(NButton, { size: 'tiny', type: 'primary', onClick: () => openBackfill(r) }, { default: () => '补录' })
|
||||
: `已登记(#${r.matched_purchase_id})`,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:row-key="(row: PurchaseRow) => row.id"
|
||||
:scroll-x="1180"
|
||||
size="small"
|
||||
>
|
||||
<template #empty>
|
||||
<n-empty :description="loading ? '加载中…' : '还没有购买记录'" />
|
||||
</template>
|
||||
</n-data-table>
|
||||
|
||||
<!-- 登记 / 编辑 / 补录:右侧抽屉 -->
|
||||
<n-drawer v-model:show="showDrawer" :width="420">
|
||||
<n-drawer-content :title="editingId === null ? '登记购买记录' : '编辑购买记录'" closable>
|
||||
<n-form label-placement="top" size="small">
|
||||
<n-form-item label="类型">
|
||||
<n-radio-group v-model:value="form.kind">
|
||||
<n-radio-button value="subscription">订阅</n-radio-button>
|
||||
<n-radio-button value="topup">充值</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="支付时间(必填)">
|
||||
<n-date-picker v-model:value="form.paidAt" type="datetime" clearable class="full" />
|
||||
</n-form-item>
|
||||
<n-form-item label="实付金额 / 元(必填)">
|
||||
<n-input-number v-model:value="form.paidAmount" :min="0" :precision="2" class="full" />
|
||||
</n-form-item>
|
||||
<n-form-item label="本笔额度 quota(必填)">
|
||||
<n-input-number v-model:value="form.quotaGranted" :min="0" class="full" />
|
||||
</n-form-item>
|
||||
<n-form-item label="订阅 ID(钱包页「订阅 #N」)">
|
||||
<n-input-number v-model:value="form.subscriptionId" :min="1" class="full" />
|
||||
</n-form-item>
|
||||
<n-form-item label="期间起点">
|
||||
<n-date-picker v-model:value="form.periodStart" type="datetime" clearable class="full" />
|
||||
</n-form-item>
|
||||
<n-form-item label="期间终点(未结束可空)">
|
||||
<n-date-picker v-model:value="form.periodEnd" type="datetime" clearable class="full" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注">
|
||||
<n-input v-model:value="form.note" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-text v-if="formError" type="error">{{ formError }}</n-text>
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<n-button @click="showDrawer = false">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.obs-collapse {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
:deep(.row-actions) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
// 换算参数时间线页(交互设计 §3.3)——骨架占位:
|
||||
// 表格两列(生效时间 / QPU 值)+「当前生效」标记 + 页首说明文案;纯只读
|
||||
// 数据形状见 docs/design/API契约.md §2.4
|
||||
// 换算参数时间线页(交互设计 §3.3):表格两列(生效时间 / QPU 值)+「当前生效」标记;纯只读
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { NTag, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import type { QpuRow } from '@naku/shared'
|
||||
|
||||
import { ApiError, api } from '@/api/client'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const rows = ref<QpuRow[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await api.qpuList<QpuRow[]>()
|
||||
} catch (e) {
|
||||
message.error(e instanceof ApiError ? e.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/** 当前生效 = 时间线最后一行 */
|
||||
const currentId = computed(() => rows.value[rows.value.length - 1]?.id ?? null)
|
||||
|
||||
const columns = computed<DataTableColumns<QpuRow>>(() => [
|
||||
{ title: '生效时间', key: 'effective_from', render: (row) => formatTime(row.effective_from) },
|
||||
{
|
||||
title: 'QPU 值',
|
||||
key: 'qpu',
|
||||
render: (row) => row.qpu.toLocaleString('en-US'),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'current',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
row.id === currentId.value
|
||||
? h(NTag, { size: 'small', type: 'success', bordered: false }, { default: () => '当前生效' })
|
||||
: null,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="qpu">
|
||||
<h2>换算参数时间线</h2>
|
||||
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.3" />
|
||||
<p class="intro">
|
||||
quota → 额度美元的换算比率历史;引擎自动采样、只记变化点;当前生效值 = 最近一行。
|
||||
</p>
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:row-key="(row: QpuRow) => row.id"
|
||||
size="small"
|
||||
>
|
||||
<template #empty>
|
||||
<n-empty :description="loading ? '加载中…' : '暂无采样数据(引擎首次运行后出现基线)'" />
|
||||
</template>
|
||||
</n-data-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.intro {
|
||||
margin: 0 0 16px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
| 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) |
|
||||
| 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) |
|
||||
| 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 |
|
||||
| 当前进度 | 阶段 0–6 已完成;下一批:**阶段 7**(前端管理面:口令门帘 + 侧边栏 + 三页) |
|
||||
| 当前进度 | 阶段 0–7 已完成;下一批:**阶段 8**(收尾:部署文档 + 端到端走查 + 文档状态更新) |
|
||||
|
||||
---
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
| 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` 显示「—」 | ✅ 2026-09-15(Playwright + 系统 Chrome 逐态断言:初始 / 加载 / 查询(卡片 984 · 298,041,838 · 4.90)/ 展开行 / 翻页 / 未收录 / 无记录 / 同步中 / 失败,无 console 错误;金额 `null` 显示「—」并伴未折算提示条) |
|
||||
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ |
|
||||
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ✅ 2026-09-15(Playwright 走查 15 步:错口令 → 门帘提示 / 正确口令进入 / 展开行 / 登记三类结果(拒绝 · 成功 · 已登记)/ 停用(确认弹窗)与启用 / 核对提示 / 补录(预填订阅 ID 与观测额度)/ 编辑 / 删除(确认弹窗)/ 换算参数「当前生效」/ 返回查看面;顺带补 naive-ui 中文 locale 与表格横向滚动) |
|
||||
| 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ |
|
||||
|
||||
## 4. 待定问题
|
||||
|
||||
Reference in New Issue
Block a user