diff --git a/web/src/pages/admin/UsagePage.tsx b/web/src/pages/admin/UsagePage.tsx
index e1efce4..c75fcf3 100644
--- a/web/src/pages/admin/UsagePage.tsx
+++ b/web/src/pages/admin/UsagePage.tsx
@@ -664,9 +664,16 @@ export default function UsagePage() {
width: '172px',
hideOnMobile: true,
render: (row) => {
- const label = row.account_name || '-';
+ const name = row.account_name || '-';
+ const email = row.account_email?.trim();
+ const title = email && name !== '-' ? `${name}\n${email}` : name;
return (
-
{label}
+
+ {name}
+ {email && name !== '-' ? (
+ {email}
+ ) : null}
+
);
},
};
diff --git a/web/src/pages/admin/accounts/useAccountTableColumns.tsx b/web/src/pages/admin/accounts/useAccountTableColumns.tsx
index 4bad5e7..ee169e2 100644
--- a/web/src/pages/admin/accounts/useAccountTableColumns.tsx
+++ b/web/src/pages/admin/accounts/useAccountTableColumns.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useRef, type MouseEvent } from 'react';
+import { useMemo, useRef, type CSSProperties, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
@@ -22,6 +22,13 @@ import {
type QuotaRefreshResult = Awaited
>;
+const ACCOUNT_GROUP_CHIP_COLOR = 'oklch(62.04% 0.1950 253.83)';
+const ACCOUNT_GROUP_CHIP_STYLE: CSSProperties = {
+ background: `color-mix(in srgb, ${ACCOUNT_GROUP_CHIP_COLOR} 18%, transparent)`,
+ boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${ACCOUNT_GROUP_CHIP_COLOR} 34%, transparent)`,
+ color: ACCOUNT_GROUP_CHIP_COLOR,
+};
+
type UseAccountTableColumnsArgs = {
applyQuotaRefreshResult: (id: number, result: QuotaRefreshResult) => void;
groupMap: Map;
@@ -144,7 +151,7 @@ export function useAccountTableColumns({
{name}
@@ -152,7 +159,7 @@ export function useAccountTableColumns({
{hiddenCount > 0 ? (
+{hiddenCount}
diff --git a/web/src/pages/user/UserKeysPage.tsx b/web/src/pages/user/UserKeysPage.tsx
index 9d3e815..b166154 100644
--- a/web/src/pages/user/UserKeysPage.tsx
+++ b/web/src/pages/user/UserKeysPage.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useMemo, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apikeysApi } from '../../shared/api/apikeys';
@@ -17,6 +17,7 @@ import { getTotalPages } from '../../shared/utils/pagination';
import { TablePaginationFooter } from '../../shared/components/TablePaginationFooter';
import { TableLoadingRow } from '../../shared/components/TableLoadingRow';
import { CommonTable } from '../../shared/components/CommonTable';
+import { MetricChips } from '../../shared/components/MetricChips';
import { useClipboard } from '../../shared/hooks/useClipboard';
import { useCopyFeedback } from '../../shared/hooks/useCopyFeedback';
import {
@@ -43,6 +44,12 @@ import { UseKeyModal, useUseKeyModal } from './userkeys/UseKeyModal';
import { CcsImportModal, useCcsImportModal } from './userkeys/CcsImportModal';
import { type KeyForm, emptyForm } from './userkeys/types';
+const GROUP_CHIP_COLOR = 'oklch(62.04% 0.1950 253.83)';
+const GROUP_CHIP_STYLE: CSSProperties = {
+ background: `color-mix(in srgb, ${GROUP_CHIP_COLOR} 18%, transparent)`,
+ boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${GROUP_CHIP_COLOR} 34%, transparent)`,
+ color: GROUP_CHIP_COLOR,
+};
export default function UserKeysPage() {
const { t } = useTranslation();
const { toast } = useToast();
@@ -181,7 +188,8 @@ export default function UserKeysPage() {
const payload: UpdateAPIKeyReq = {
name: form.name,
group_id: form.group_id ? Number(form.group_id) : undefined,
- quota_usd: form.quota_usd ? Number(form.quota_usd) : undefined,
+ // 空字符串显式改为 0 = 无限配额;省略字段只表示不修改旧配额
+ quota_usd: form.quota_usd.trim() ? Number(form.quota_usd) : 0,
sell_rate: form.sell_rate ? Number(form.sell_rate) : 0,
// 空字符串显式改为 0 = 关闭并发限制;后端看到 0 会清除旧值
max_concurrency: form.max_concurrency ? Number(form.max_concurrency) : 0,
@@ -313,9 +321,9 @@ export default function UserKeysPage() {
{t('user_keys.title')}
{t('user_keys.group')}
{t('common.status')}
- {t('user_keys.quota_label')}
- {t('user_keys.markup_title', '销售/成本')}
- {t('api_keys.usage')}
+ {t('user_keys.quota_label')}
+ {t('user_keys.markup_title', '销售/成本')}
+ {t('api_keys.usage')}
{t('user_keys.expires_at')}
{t('common.actions')}
@@ -335,7 +343,8 @@ export default function UserKeysPage() {
) : (
rows.map((row) => {
const group = row.group_id == null ? null : groupMap.get(row.group_id);
- const groupName = row.group_id == null
+ const isGroupUnbound = row.group_id == null;
+ const groupName = isGroupUnbound
? t('user_keys.group_unbound')
: group?.name || `#${row.group_id}`;
const hasSellRate = row.sell_rate != null && row.sell_rate > 0;
@@ -363,28 +372,34 @@ export default function UserKeysPage() {
-
{groupName}
- {group && (
-
- {t('user_keys.group_rate_short', '分组倍率')}:{' '}
- {hasOverride && userOverride != null ? (
-
- {userOverride.toFixed(2)}
-
- {t('user_keys.user_override_tag', '专属')}
-
-
- ) : (
- group.rate_multiplier.toFixed(2)
- )}
-
- )}
- {hasSellRate && (
-
- {t('user_keys.sell_rate_short', '销售倍率')}: {row.sell_rate!.toFixed(2)}
-
+
+
+ {isGroupUnbound ? : null}
+ {groupName}
+
+
+ {(group || hasSellRate) && (
+
)}
@@ -392,49 +407,68 @@ export default function UserKeysPage() {
-
- {row.quota_usd > 0 ? (
- <>
- ${row.used_quota.toFixed(4)} / ${row.quota_usd.toFixed(4)}
- >
- ) : (
- {t('user_keys.quota_unlimited_hint')}
- )}
-
+ 0 ? row.quota_usd : undefined,
+ color: 'success',
+ label: t('user_keys.quota_total_short', '总配额'),
+ value: '∞',
+ },
+ ]}
+ />
- {!row.sell_rate || row.sell_rate <= 0 ? (
- —
- ) : (
-
-
- {t('user_keys.sell_rate_short', '倍率')}:
- {row.sell_rate.toFixed(2)}
-
-
- {t('user_keys.cost_actual', '成本')}:
- ${(row.used_quota_actual || 0).toFixed(4)}
-
-
- {t('user_keys.profit', '利润')}:
- 0 ? 'var(--ag-success)' : undefined }}>
- ${profit.toFixed(4)}
-
-
-
- )}
+
-
-
- {t('api_keys.today')}:
- ${row.today_cost.toFixed(4)}
-
-
- {t('api_keys.thirty_days')}:
- ${row.thirty_day_cost.toFixed(4)}
-
-
+
{row.expires_at
diff --git a/web/src/shared/components/MetricChips.tsx b/web/src/shared/components/MetricChips.tsx
new file mode 100644
index 0000000..cf663b2
--- /dev/null
+++ b/web/src/shared/components/MetricChips.tsx
@@ -0,0 +1,73 @@
+import { Chip } from '@heroui/react';
+
+type MetricChipColor = 'default' | 'warning' | 'success' | 'accent';
+
+export type MetricChipItem = {
+ amount?: number;
+ color: MetricChipColor;
+ decimals?: number;
+ dollarTone?: MetricChipColor;
+ highlightDollar?: boolean;
+ label: string;
+ mutedWhenZero?: boolean;
+ value?: string;
+};
+
+function formatMoneyAmount(value: number, decimals = 4) {
+ return (Number.isFinite(value) ? value : 0).toFixed(decimals);
+}
+
+function formatMetricTitleValue(item: MetricChipItem) {
+ if (item.amount != null) return `$${formatMoneyAmount(item.amount, item.decimals)}`;
+ return item.value ?? '';
+}
+
+function MetricChip({ amount, color, decimals, dollarTone, highlightDollar, label, mutedWhenZero, value }: MetricChipItem) {
+ const amountText = amount == null ? null : formatMoneyAmount(amount, decimals);
+ const isMutedZero = mutedWhenZero && amount === 0;
+ const chipClassName = [
+ 'ag-metric-chip',
+ isMutedZero ? 'ag-metric-chip--zero' : '',
+ ].filter(Boolean).join(' ');
+ const effectiveDollarTone = dollarTone ?? (highlightDollar ? 'warning' : undefined);
+ const dollarClassName = [
+ 'ag-metric-dollar',
+ effectiveDollarTone ? `ag-metric-dollar--${effectiveDollarTone}` : '',
+ ].filter(Boolean).join(' ');
+
+ return (
+
+ {label}
+
+ {amountText == null ? (
+ value === '∞' ? {value} : value
+ ) : (
+ <>
+ $
+ {amountText}
+ >
+ )}
+
+
+ );
+}
+
+export function MetricChips({
+ className,
+ items,
+}: {
+ className?: string;
+ items: MetricChipItem[];
+}) {
+ const title = items
+ .map((item) => `${item.label} ${formatMetricTitleValue(item)}`)
+ .join(' / ');
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/web/src/shared/types/index.ts b/web/src/shared/types/index.ts
index 6e8d1cb..171402c 100644
--- a/web/src/shared/types/index.ts
+++ b/web/src/shared/types/index.ts
@@ -473,6 +473,7 @@ export interface UsageLogResp {
api_key_deleted: boolean;
account_id: number;
account_name?: string;
+ account_email?: string;
group_id: number;
platform: string;
model: string;
diff --git a/web/src/styles/pages.css b/web/src/styles/pages.css
index 05c08e7..5564589 100644
--- a/web/src/styles/pages.css
+++ b/web/src/styles/pages.css
@@ -14,6 +14,109 @@
margin-inline: auto;
}
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips {
+ display: inline-grid;
+ align-items: center;
+ gap: 0.375rem;
+ font-family: var(--ag-font-mono);
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--quota {
+ width: min(100%, 17.5rem);
+ min-width: 17.5rem;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--stack {
+ grid-template-columns: minmax(0, 1fr);
+ align-items: stretch;
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--usage {
+ width: min(100%, 10.25rem);
+ min-width: 10.25rem;
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--markup {
+ width: min(100%, 10.25rem);
+ min-width: 10.25rem;
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--compact-y {
+ gap: 0.25rem;
+}
+
+.ag-groups-table .ag-groups-metric-cell {
+ padding-block: 0.625rem !important;
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip {
+ display: inline-grid !important;
+ width: 100%;
+ min-width: 0;
+ grid-template-columns: 2.75rem minmax(5.25rem, 1fr);
+ align-items: center;
+ color: var(--ag-text) !important;
+ gap: 0.375rem;
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--markup .ag-metric-chip {
+ grid-template-columns: 3.25rem minmax(5.25rem, 1fr);
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chips--usage .ag-metric-chip {
+ grid-template-columns: 3.25rem minmax(5.25rem, 1fr);
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip-label,
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip-value {
+ min-width: 0;
+ color: var(--ag-text) !important;
+ overflow: hidden;
+ opacity: 1;
+ white-space: nowrap;
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip-label {
+ color: var(--ag-text-secondary) !important;
+ font-weight: 600;
+ letter-spacing: 0;
+ text-overflow: ellipsis;
+ text-align: left;
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip-value {
+ display: inline-flex;
+ min-width: 5.25rem;
+ align-items: center;
+ justify-self: end;
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+ justify-content: flex-end;
+ text-align: right;
+ text-overflow: ellipsis;
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-chip--zero {
+ background: var(--ag-default-bg) !important;
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-dollar--warning {
+ color: var(--ag-warning);
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-dollar--success {
+ color: var(--ag-success);
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-dollar--accent {
+ color: var(--ag-primary);
+}
+
+:is(.ag-api-keys-table, .ag-groups-table) .ag-metric-infinity {
+ font-weight: 800;
+}
+
.ag-elevation-modal.ag-create-key-modal,
.ag-elevation-modal.modal__dialog--lg.ag-create-key-modal {
width: min(45rem, calc(100vw - 2rem)) !important;