Files
geo/apps/admin-web/src/views/AIPointUsageView.vue
T
root 9683a53403 refactor(admin-web/ai-points): rework usage page into card layout
把页面改造成两张白底卡片:顶部卡片承载页头 + 概览四宫格,下面卡片承载用量
台账。指标格子改成圆形图标 + 文字两栏布局,排版更接近现有控制台其它视图。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:35:58 +08:00

431 lines
10 KiB
Vue

<script setup lang="ts">
import {
CalculatorOutlined,
FieldTimeOutlined,
QuestionCircleOutlined,
ReloadOutlined,
ThunderboltOutlined,
} from "@ant-design/icons-vue";
import type { AIPointUsageLog } from "@geo/shared-types";
import { useQuery } from "@tanstack/vue-query";
import type { TableColumnsType } from "ant-design-vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { workspaceApi } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
const { t } = useI18n();
const page = ref(1);
const pageSize = ref(10);
const quotaQuery = useQuery({
queryKey: ["workspace", "quota-summary", "ai-points"],
queryFn: () => workspaceApi.quotaSummary(),
});
const usageQuery = useQuery({
queryKey: computed(() => ["workspace", "ai-point-usage", page.value, pageSize.value]),
queryFn: () => workspaceApi.aiPointUsage({ page: page.value, page_size: pageSize.value }),
});
const quotaSummary = computed(() => quotaQuery.data.value);
const usageRows = computed(() => usageQuery.data.value?.items ?? []);
const usageTotal = computed(() => usageQuery.data.value?.total ?? 0);
const aiPointUsedPercent = computed(() => {
const total = quotaSummary.value?.ai_points_total ?? 0;
const used = quotaSummary.value?.ai_points_used ?? 0;
if (total <= 0) {
return 0;
}
return Math.min(100, Math.round((used / total) * 100));
});
const loading = computed(() => quotaQuery.isPending.value || usageQuery.isPending.value);
const refreshing = computed(() => quotaQuery.isFetching.value || usageQuery.isFetching.value);
const columns = computed<TableColumnsType<AIPointUsageLog>>(() => [
{
title: t("aiPoints.table.usageType"),
dataIndex: "usage_type",
key: "usage_type",
},
{
title: t("aiPoints.table.points"),
dataIndex: "points",
key: "points",
width: 110,
align: "right",
},
{
title: t("aiPoints.table.requestChars"),
dataIndex: "request_chars",
key: "request_chars",
width: 150,
},
{
title: t("common.status"),
dataIndex: "status",
key: "status",
width: 118,
},
{
title: t("common.createdAt"),
dataIndex: "created_at",
key: "created_at",
width: 172,
},
]);
function refresh(): void {
void quotaQuery.refetch();
void usageQuery.refetch();
}
function handleTableChange(nextPage: number, nextPageSize: number): void {
page.value = nextPage;
pageSize.value = nextPageSize;
}
function getAIPointUsageTypeLabel(usageType: string): string {
const key = `shell.aiUsageTypes.${usageType}`;
const label = t(key);
return label === key ? usageType : label;
}
function getAIPointStatusMeta(status: string): { label: string; color: string } {
const key = `shell.aiUsageStatus.${status}`;
const label = t(key);
const normalizedLabel = label === key ? status : label;
switch (status) {
case "completed":
return { label: normalizedLabel, color: "success" };
case "pending":
return { label: normalizedLabel, color: "processing" };
case "refunded":
return { label: normalizedLabel, color: "default" };
default:
return { label: normalizedLabel, color: "error" };
}
}
function formatAIPointDelta(item: AIPointUsageLog): string {
return item.status === "refunded" ? `+${item.points}` : `-${item.points}`;
}
</script>
<template>
<div class="ai-points-view">
<section class="ai-points-view__top-card">
<div class="ai-points-view__header">
<div class="ai-points-view__header-title">
<h2>{{ t("route.aiPoints.title") }}</h2>
<p>{{ t("route.aiPoints.description") }}</p>
</div>
<div class="ai-points-view__header-actions">
<a-button :loading="refreshing" @click="refresh">
<template #icon><ReloadOutlined /></template>
{{ t("common.refresh") }}
</a-button>
</div>
</div>
<a-divider style="margin: 0; border-color: #f0f0f0;" />
<div class="ai-points-view__summary">
<div class="ai-points-balance">
<span class="summary-label">{{ t("aiPoints.balance") }}</span>
<strong>{{ quotaSummary?.ai_points_balance ?? "--" }}</strong>
<a-progress :percent="aiPointUsedPercent" :show-info="false" stroke-color="#1677ff" />
</div>
<div class="summary-metric">
<div class="summary-metric-icon">
<ThunderboltOutlined />
</div>
<div class="summary-metric-content">
<span>{{ t("common.used") }}</span>
<strong>{{ quotaSummary?.ai_points_used ?? "--" }} / {{ quotaSummary?.ai_points_total ?? "--" }}</strong>
</div>
</div>
<div class="summary-metric">
<div class="summary-metric-icon">
<CalculatorOutlined />
</div>
<div class="summary-metric-content">
<span>{{ t("aiPoints.baseChars") }}</span>
<strong>{{ quotaSummary?.ai_point_base_chars ?? "--" }}</strong>
</div>
</div>
<div class="summary-metric">
<div class="summary-metric-icon">
<FieldTimeOutlined />
</div>
<div class="summary-metric-content">
<span>{{ t("shell.resetAt") }}</span>
<strong>{{ formatDateTime(quotaSummary?.ai_points_reset_at) }}</strong>
</div>
</div>
</div>
</section>
<section class="ai-points-view__table-card">
<div class="ai-points-view__table-head">
<div>
<p class="eyebrow">{{ t("aiPoints.ledgerTitle") }}</p>
<h3>{{ t("aiPoints.ledgerTotal", { total: usageTotal }) }}</h3>
</div>
</div>
<a-table
:columns="columns"
:data-source="usageRows"
:loading="loading"
:locale="{ emptyText: t('shell.aiUsageEmpty') }"
:pagination="{
current: page,
pageSize,
total: usageTotal,
showSizeChanger: true,
onChange: handleTableChange,
onShowSizeChange: handleTableChange,
}"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'usage_type'">
<div class="usage-type-cell">
<strong>{{ getAIPointUsageTypeLabel(record.usage_type) }}</strong>
<span v-if="record.model">{{ record.model }}</span>
<span v-else-if="record.error_message" class="usage-error">{{ record.error_message }}</span>
</div>
</template>
<template v-else-if="column.key === 'points'">
<span class="points-delta" :class="{ 'points-delta--refund': record.status === 'refunded' }">
{{ formatAIPointDelta(record) }}
</span>
</template>
<template v-else-if="column.key === 'request_chars'">
<span class="usage-rule">
{{ t("shell.aiUsageRule", { chars: record.request_chars, base: record.base_chars }) }}
</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag class="status-tag" :color="getAIPointStatusMeta(record.status).color" :bordered="false">
{{ getAIPointStatusMeta(record.status).label }}
<a-tooltip v-if="record.status === 'pending'" :title="t('shell.aiUsagePendingHelp')">
<QuestionCircleOutlined class="status-help-icon" />
</a-tooltip>
</a-tag>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
</template>
</a-table>
</section>
</div>
</template>
<style scoped>
.ai-points-view {
display: flex;
flex-direction: column;
gap: 22px;
}
.ai-points-view__top-card {
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
overflow: hidden;
}
.ai-points-view__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
}
.ai-points-view__header-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.ai-points-view__header-title p {
margin: 6px 0 0 0;
font-size: 13px;
color: #8c8c8c;
}
.ai-points-view__header-actions {
display: flex;
gap: 12px;
}
.ai-points-view__summary {
display: grid;
grid-template-columns: minmax(260px, 1.5fr) repeat(3, minmax(160px, 1fr));
gap: 16px;
padding: 24px;
}
.ai-points-balance,
.summary-metric {
border: 1px solid #e6edf5;
border-radius: 10px;
background: #fafcff;
}
.ai-points-balance {
padding: 20px;
}
.summary-label,
.summary-metric span,
.usage-type-cell span,
.usage-rule {
color: #8c8c8c;
}
.ai-points-balance strong {
display: block;
margin: 8px 0 16px;
color: #1a1a1a;
font-size: 32px;
font-weight: 600;
line-height: 1.2;
}
.summary-metric {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
}
.summary-metric-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #e6f4ff;
color: #1677ff;
font-size: 20px;
}
.summary-metric-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-metric-content span {
font-size: 13px;
line-height: 1.4;
}
.summary-metric-content strong {
color: #1a1a1a;
font-size: 18px;
font-weight: 600;
line-height: 1.2;
}
.ai-points-view__table-card {
padding: 24px;
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
}
.ai-points-view__table-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.ai-points-view__table-head h3 {
margin: 6px 0 0;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
}
.usage-type-cell {
display: grid;
gap: 3px;
}
.usage-type-cell strong {
color: #1a1a1a;
font-weight: 600;
}
.usage-type-cell span,
.usage-rule {
font-size: 12px;
line-height: 18px;
}
.usage-error {
color: #cf1322 !important;
}
.points-delta {
color: #cf1322;
font-weight: 600;
}
.points-delta--refund {
color: #389e0d;
}
.status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
}
.status-help-icon {
color: #1677ff;
font-size: 12px;
cursor: help;
}
@media (max-width: 1100px) {
.ai-points-view__summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.ai-points-view__header {
flex-direction: column;
gap: 16px;
}
.ai-points-view__summary {
grid-template-columns: 1fr;
}
.ai-points-view__table-head {
flex-direction: column;
align-items: stretch;
}
}
</style>