重做首页常用订阅账号卡片视觉设计 (#515)
* feat(home): 重做首页常用订阅账号卡片视觉设计 * fix(home): 常用订阅账号容器改为换行网格,去掉横向滚动 * fix(home): 首页常用订阅账号上限改回 4 个,PC 默认单行不换行 * fix(home): 修复 mini card 焦点窗口选择与标签周期丢失问题 * fix(home): 修复迷你订阅卡额度与可用状态显示 * fix(home): 优先展示已耗尽额度窗口
This commit is contained in:
1 parent
30a1bac965
commit
a7d11adf96
9 files changed
+815
-101
No files matched your search
@@ -28,6 +28,7 @@ type HomeSubscriptionAccountsResponse struct {
|
||||
|
||||
type HomeSubscriptionAccountResponse struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelMark string `json:"channel_mark"`
|
||||
ChannelIcon string `json:"channel_icon"`
|
||||
Capabilities channel.CapabilityDescriptor `json:"capabilities"`
|
||||
@@ -171,6 +172,7 @@ func (s *Service) readHomeSubscriptionAccounts(
|
||||
return HomeSubscriptionAccountsResponse{}, app_errors.ErrInternalServer
|
||||
}
|
||||
item.ChannelID = activity.ChannelID
|
||||
item.ChannelName = descriptor.Name
|
||||
item.ChannelMark = descriptor.Mark
|
||||
item.ChannelIcon = descriptor.Icon
|
||||
item.Capabilities = descriptor.Capabilities
|
||||
|
||||
@@ -160,6 +160,9 @@ func TestReadHomeSubscriptionAccountsLimitsTheHomepageToFourCards(t *testing.T)
|
||||
if item.Credential.Account.Email != want {
|
||||
t.Fatalf("item %d email = %q, want %q", index, item.Credential.Account.Email, want)
|
||||
}
|
||||
if item.ChannelName != "Codex" {
|
||||
t.Fatalf("item %d channel name = %q, want Codex", index, item.ChannelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface HomeStatisticsDto {
|
||||
|
||||
export interface HomeSubscriptionAccountDto {
|
||||
channel_id: string
|
||||
channel_name: string
|
||||
channel_mark: string
|
||||
channel_icon: string
|
||||
capabilities: ChannelCapabilitiesDto
|
||||
@@ -184,6 +185,7 @@ const accessKeyRankingFields = ['access_key', ...rankingMetricFields] as const
|
||||
const subscriptionAccountsFields = ['observed_at_ms', 'items'] as const
|
||||
const subscriptionAccountFields = [
|
||||
'channel_id',
|
||||
'channel_name',
|
||||
'channel_mark',
|
||||
'channel_icon',
|
||||
'capabilities',
|
||||
@@ -294,6 +296,7 @@ function projectHomeSubscriptionAccount(value: unknown): HomeSubscriptionAccount
|
||||
}
|
||||
return {
|
||||
channel_id: projectNonBlankTrimmedString(record.channel_id),
|
||||
channel_name: projectNonBlankTrimmedString(record.channel_name),
|
||||
channel_mark: projectNonBlankTrimmedString(record.channel_mark),
|
||||
channel_icon: projectNonBlankTrimmedString(record.channel_icon),
|
||||
capabilities: projectHomeSubscriptionCapabilities(record.capabilities),
|
||||
|
||||
@@ -51,16 +51,10 @@ const props = withDefaults(
|
||||
channelMark?: string
|
||||
capabilities: ChannelCapabilitiesDto
|
||||
saveProxy: (value: ProxyMutation) => Promise<void>
|
||||
readonly?: boolean
|
||||
groupCount?: number
|
||||
availableGroupCount?: number
|
||||
}>(),
|
||||
{
|
||||
channelIcon: undefined,
|
||||
channelMark: undefined,
|
||||
readonly: false,
|
||||
groupCount: 1,
|
||||
availableGroupCount: 1,
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
@@ -99,7 +93,7 @@ watch(
|
||||
props.busy,
|
||||
] as const,
|
||||
([expanded, loaded, detailBusy, error, busy]) => {
|
||||
if (!props.readonly && expanded && !loaded && !detailBusy && !error && !busy) {
|
||||
if (expanded && !loaded && !detailBusy && !error && !busy) {
|
||||
emit('load-details', props.item)
|
||||
}
|
||||
},
|
||||
@@ -382,17 +376,7 @@ const unifiedStatus = computed<UnifiedStatus>(() => {
|
||||
}
|
||||
return 'available'
|
||||
})
|
||||
const showAggregateAvailability = computed(
|
||||
() =>
|
||||
props.readonly &&
|
||||
props.groupCount > 1 &&
|
||||
(props.availableGroupCount < props.groupCount || unifiedStatus.value === 'available'),
|
||||
)
|
||||
const statusTone = computed<'success' | 'warning' | 'danger' | 'neutral'>(() => {
|
||||
if (showAggregateAvailability.value) {
|
||||
if (props.availableGroupCount === props.groupCount) return 'success'
|
||||
return props.availableGroupCount > 0 ? 'warning' : 'danger'
|
||||
}
|
||||
const tones: Record<UnifiedStatus, 'success' | 'warning' | 'danger' | 'neutral'> = {
|
||||
available: 'success',
|
||||
quota_exhausted: 'warning',
|
||||
@@ -406,19 +390,9 @@ const statusTone = computed<'success' | 'warning' | 'danger' | 'neutral'>(() =>
|
||||
return tones[unifiedStatus.value]
|
||||
})
|
||||
const statusLabel = computed(() =>
|
||||
showAggregateAvailability.value
|
||||
? t('home.ledger.subscriptionAccounts.availableGroups', {
|
||||
available: n(props.availableGroupCount),
|
||||
total: n(props.groupCount),
|
||||
})
|
||||
: t(`group.credentials.subscription.status.${unifiedStatus.value}`),
|
||||
)
|
||||
const groupCountLabel = computed(() =>
|
||||
t('home.ledger.subscriptionAccounts.groupCount', { count: n(props.groupCount) }),
|
||||
)
|
||||
const displayDisabled = computed(
|
||||
() => !showAggregateAvailability.value && unifiedStatus.value === 'disabled',
|
||||
t(`group.credentials.subscription.status.${unifiedStatus.value}`),
|
||||
)
|
||||
const displayDisabled = computed(() => unifiedStatus.value === 'disabled')
|
||||
const authErrorKeys: Readonly<Record<string, string>> = {
|
||||
refresh_rejected: 'group.credentials.subscription.authError.refreshRejected',
|
||||
refresh_identity_changed: 'group.credentials.subscription.authError.identityChanged',
|
||||
@@ -557,7 +531,7 @@ function quotaFillStyle(window: CredentialQuotaWindowDto): Record<string, string
|
||||
}
|
||||
|
||||
function toggleDetails(): void {
|
||||
if (props.readonly || props.detailBusy) return
|
||||
if (props.detailBusy) return
|
||||
detailsExpanded.value = !detailsExpanded.value
|
||||
}
|
||||
|
||||
@@ -596,7 +570,6 @@ function runMenuAction(
|
||||
{
|
||||
'subscription-account--disabled': displayDisabled,
|
||||
'subscription-account--refreshing': refreshingObservation,
|
||||
'subscription-account--readonly': readonly,
|
||||
},
|
||||
]"
|
||||
:aria-busy="refreshingObservation ? 'true' : undefined"
|
||||
@@ -699,7 +672,7 @@ function runMenuAction(
|
||||
<div class="subscription-account__main">
|
||||
<header class="subscription-account__top">
|
||||
<div class="subscription-account__top-row">
|
||||
<label v-if="!readonly" class="subscription-account__select">
|
||||
<label class="subscription-account__select">
|
||||
<span class="sr-only">{{
|
||||
t('group.credentials.subscription.selectAccount', { account: accountName })
|
||||
}}</span>
|
||||
@@ -735,18 +708,9 @@ function runMenuAction(
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</StatusBadge>
|
||||
<span v-if="readonly" class="subscription-account__group-count">
|
||||
{{ groupCountLabel }}
|
||||
</span>
|
||||
<ProxyScopeIndicator
|
||||
v-if="!readonly && capabilities.outbound_proxy"
|
||||
:view="item.proxy"
|
||||
/>
|
||||
<ProxyScopeIndicator v-if="capabilities.outbound_proxy" :view="item.proxy" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!readonly || (supportsQuotaObservation && observation?.observed_at_ms != null)"
|
||||
class="subscription-account__actions"
|
||||
>
|
||||
<div class="subscription-account__actions">
|
||||
<span
|
||||
v-if="supportsQuotaObservation && observation?.observed_at_ms != null"
|
||||
class="subscription-account__sync-age"
|
||||
@@ -760,7 +724,7 @@ function runMenuAction(
|
||||
/>
|
||||
</span>
|
||||
<AppTooltip
|
||||
v-if="supportsQuotaObservation && !readonly"
|
||||
v-if="supportsQuotaObservation"
|
||||
:content="t('group.credentials.subscription.sync')"
|
||||
>
|
||||
<IconButton
|
||||
@@ -780,7 +744,6 @@ function runMenuAction(
|
||||
</IconButton>
|
||||
</AppTooltip>
|
||||
<AppPopover
|
||||
v-if="!readonly"
|
||||
v-model:open="menuOpen"
|
||||
align="end"
|
||||
content-class="app-popover__content--account-menu"
|
||||
@@ -969,11 +932,8 @@ function runMenuAction(
|
||||
hint
|
||||
/>
|
||||
</span>
|
||||
<span v-if="!readonly" class="subscription-account__spacer"></span>
|
||||
<AppTooltip
|
||||
v-if="!readonly"
|
||||
:content="t('group.credentials.subscription.resetCreditsActionTooltip')"
|
||||
>
|
||||
<span class="subscription-account__spacer"></span>
|
||||
<AppTooltip :content="t('group.credentials.subscription.resetCreditsActionTooltip')">
|
||||
<AppButton
|
||||
class="subscription-account__credits-action"
|
||||
variant="ghost"
|
||||
@@ -987,7 +947,7 @@ function runMenuAction(
|
||||
</AppTooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="!readonly" class="subscription-account__detail-control">
|
||||
<div class="subscription-account__detail-control">
|
||||
<AppTooltip
|
||||
:content="
|
||||
t(
|
||||
@@ -1043,7 +1003,7 @@ function runMenuAction(
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="!readonly && detailsExpanded"
|
||||
v-if="detailsExpanded"
|
||||
:id="`credential-detail-${item.credential_id}`"
|
||||
class="subscription-account__detail"
|
||||
aria-live="polite"
|
||||
@@ -1393,9 +1353,6 @@ function runMenuAction(
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) 0;
|
||||
}
|
||||
.subscription-account--readonly .subscription-account__main {
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
.subscription-account__top {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@@ -1515,16 +1472,6 @@ function runMenuAction(
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.subscription-account__group-count {
|
||||
flex: none;
|
||||
border-radius: var(--radius-tag);
|
||||
background: var(--color-surface-sunken);
|
||||
padding: 3px 7px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-label-xs);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.subscription-account__actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
CredentialItemDto,
|
||||
CredentialQuotaLabelKey,
|
||||
CredentialQuotaWindowDto,
|
||||
} from '@/api/control/types'
|
||||
import type { HomeSubscriptionAccountDto } from '@/app/resources/home'
|
||||
import ChannelIcon from '@/components/brand/ChannelIcon.vue'
|
||||
import AppRelativeTime from '@/components/ui/AppRelativeTime.vue'
|
||||
import AppTooltip from '@/components/ui/AppTooltip.vue'
|
||||
import OverflowTooltip from '@/components/ui/OverflowTooltip.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { formatLocalInstant } from '@/lib/format'
|
||||
import { quotaProgressTone, type QuotaProgressTone } from '@/lib/quota-progress'
|
||||
|
||||
const props = defineProps<{ account: HomeSubscriptionAccountDto }>()
|
||||
|
||||
const { locale, n, t, te } = useI18n()
|
||||
|
||||
type UnifiedStatus =
|
||||
| 'available'
|
||||
| 'quota_exhausted'
|
||||
| 'cooldown'
|
||||
| 'blacklisted'
|
||||
| 'refreshing'
|
||||
| 'needs_reauth'
|
||||
| 'outcome_unknown'
|
||||
| 'disabled'
|
||||
type CardTone = 'success' | 'warning' | 'danger' | 'neutral'
|
||||
type ResetCreditDotTone = 'default' | 'warning' | 'danger'
|
||||
|
||||
// 与分组详情页 SubscriptionAccountCard 同步:重置卡到期档位用真实时钟而非一次性
|
||||
// Date.now(),避免首页长时间停留在前台时点阵颜色停留在打开时刻。
|
||||
const nowMs = ref(Date.now())
|
||||
let clockTimer: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
clockTimer = window.setInterval(() => {
|
||||
nowMs.value = Date.now()
|
||||
}, 30_000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (clockTimer !== undefined) window.clearInterval(clockTimer)
|
||||
})
|
||||
|
||||
const credential = computed<CredentialItemDto>(() => props.account.credential)
|
||||
const snapshot = computed(() => credential.value.observation?.snapshot)
|
||||
const accountName = computed(() => credential.value.account.email ?? credential.value.mask)
|
||||
const planLabel = computed(() => snapshot.value?.plan_summary.name?.trim() ?? '')
|
||||
const channelTooltip = computed(() =>
|
||||
[props.account.channel_name, planLabel.value].filter(Boolean).join(' · '),
|
||||
)
|
||||
|
||||
function isAccountWideQuotaWindow(window: CredentialQuotaWindowDto): boolean {
|
||||
return window.scope === 'account'
|
||||
}
|
||||
|
||||
function quotaWindowDuration(window: CredentialQuotaWindowDto): number {
|
||||
const seconds = window.window_seconds
|
||||
return seconds !== undefined && Number.isFinite(seconds) && seconds > 0
|
||||
? seconds
|
||||
: Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
// 按 scope + 周期排序全部窗口;焦点数字与状态判断都必须看完整列表,
|
||||
// 只有底部分段带需要限制展示数量(见下方 quotaWindows)。
|
||||
const sortedQuotaWindows = computed(() =>
|
||||
[...(snapshot.value?.quota_windows ?? [])].sort((left, right) => {
|
||||
const scopeDifference =
|
||||
Number(!isAccountWideQuotaWindow(left)) - Number(!isAccountWideQuotaWindow(right))
|
||||
return scopeDifference !== 0
|
||||
? scopeDifference
|
||||
: quotaWindowDuration(left) - quotaWindowDuration(right)
|
||||
}),
|
||||
)
|
||||
|
||||
// 分段带最多展示 4 条;若焦点窗口(见下方 lead)因排序被挤到第 5 位之后,
|
||||
// 仍强制并入前排,保证大号数字与分段带指向同一个窗口,而不是各说各话。
|
||||
const quotaWindows = computed(() => {
|
||||
const capped = sortedQuotaWindows.value.slice(0, 4)
|
||||
const leadWindow = lead.value?.window
|
||||
if (leadWindow && !capped.some((window) => window.id === leadWindow.id)) {
|
||||
return [leadWindow, ...capped.slice(0, 3)]
|
||||
}
|
||||
return capped
|
||||
})
|
||||
|
||||
const unifiedStatus = computed<UnifiedStatus>(() => {
|
||||
const item = credential.value
|
||||
if (item.configured_status === 'disabled') return 'disabled'
|
||||
if (item.auth_state === 'refreshing') return 'refreshing'
|
||||
if (item.auth_state === 'reauthorization_required') return 'needs_reauth'
|
||||
if (item.auth_state === 'outcome_unknown') return 'outcome_unknown'
|
||||
if (item.effective_status === 'blacklisted') return 'blacklisted'
|
||||
if (item.effective_status === 'cooldown') return 'cooldown'
|
||||
if (
|
||||
props.account.capabilities.quota_observation &&
|
||||
sortedQuotaWindows.value.some(
|
||||
(window) => window.scope === 'account' && window.state === 'exhausted',
|
||||
)
|
||||
) {
|
||||
return 'quota_exhausted'
|
||||
}
|
||||
return 'available'
|
||||
})
|
||||
|
||||
// 同一账号可能被多个分组共用;分组数 > 1 时可用性以「当前有几个分组能调度到它」为准。
|
||||
// 分组详情页 SubscriptionAccountCard 原本也有同名聚合口径,但那条路径只在 readonly
|
||||
// 模式下触发,首页改用本组件后已成为死代码并被清理,这里是唯一仍生效的实现。
|
||||
const showAggregateAvailability = computed(
|
||||
() =>
|
||||
props.account.group_count > 1 &&
|
||||
(props.account.available_group_count < props.account.group_count ||
|
||||
unifiedStatus.value === 'available'),
|
||||
)
|
||||
|
||||
const cardTone = computed<CardTone>(() => {
|
||||
if (showAggregateAvailability.value) {
|
||||
if (props.account.available_group_count === props.account.group_count) return 'success'
|
||||
return props.account.available_group_count > 0 ? 'warning' : 'danger'
|
||||
}
|
||||
const tones: Record<UnifiedStatus, CardTone> = {
|
||||
available: 'success',
|
||||
quota_exhausted: 'warning',
|
||||
cooldown: 'warning',
|
||||
blacklisted: 'danger',
|
||||
refreshing: 'neutral',
|
||||
needs_reauth: 'danger',
|
||||
outcome_unknown: 'danger',
|
||||
disabled: 'neutral',
|
||||
}
|
||||
return tones[unifiedStatus.value]
|
||||
})
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
showAggregateAvailability.value
|
||||
? t('home.ledger.subscriptionAccounts.availableGroups', {
|
||||
available: n(props.account.available_group_count),
|
||||
total: n(props.account.group_count),
|
||||
})
|
||||
: t(`group.credentials.subscription.status.${unifiedStatus.value}`),
|
||||
)
|
||||
|
||||
const displayDisabled = computed(
|
||||
() => !showAggregateAvailability.value && unifiedStatus.value === 'disabled',
|
||||
)
|
||||
|
||||
// 单分组且状态正常时不显示状态角标:首页只在有异常,或聚合可用性值得一提时才发声,
|
||||
// 呼应 HomeAttention「没有要处理的事就整块不渲染」的克制原则。
|
||||
const showStatusChip = computed(
|
||||
() => showAggregateAvailability.value || unifiedStatus.value !== 'available',
|
||||
)
|
||||
|
||||
function remainingPercent(window: CredentialQuotaWindowDto): number | undefined {
|
||||
if (window.utilization !== undefined) return Math.round((1 - window.utilization) * 100)
|
||||
if (window.remaining !== undefined && window.limit && window.limit > 0) {
|
||||
return Math.max(0, Math.min(100, Math.round((window.remaining / window.limit) * 100)))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function quotaTone(window: CredentialQuotaWindowDto): QuotaProgressTone | undefined {
|
||||
const value = remainingPercent(window)
|
||||
return value === undefined ? undefined : quotaProgressTone(value, window.state === 'exhausted')
|
||||
}
|
||||
|
||||
function quotaFillStyle(window: CredentialQuotaWindowDto): Record<string, string> {
|
||||
const value = remainingPercent(window)
|
||||
return value === undefined ? {} : { width: `${value}%` }
|
||||
}
|
||||
|
||||
// 与分组详情页 quotaWindowPeriodLabel 完全一致:日/时之外还兜底分钟与秒,
|
||||
// 避免非整日整时的窗口(例如 30 分钟)拿不到任何周期文案。
|
||||
function quotaWindowPeriodLabel(seconds: number | undefined): string {
|
||||
if (seconds === undefined || !Number.isSafeInteger(seconds) || seconds <= 0) return ''
|
||||
const day = 24 * 60 * 60
|
||||
const hour = 60 * 60
|
||||
const minute = 60
|
||||
if (seconds % day === 0) return `${seconds / day}d`
|
||||
if (seconds % hour === 0) return `${seconds / hour}h`
|
||||
if (seconds % minute === 0) return `${seconds / minute}min`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const quotaSubjectKeys: Readonly<Record<string, CredentialQuotaLabelKey>> = {
|
||||
session: 'session',
|
||||
weekly: 'weekly',
|
||||
'extra usage': 'extra_usage',
|
||||
'included usage': 'included_usage',
|
||||
'pay as you go': 'pay_as_you_go',
|
||||
'oauth apps': 'oauth_apps',
|
||||
}
|
||||
|
||||
function normalizedQuotaLabelPart(value: string): string {
|
||||
return value.trim().toLowerCase().replaceAll('_', ' ').replaceAll('-', ' ')
|
||||
}
|
||||
|
||||
function translatedQuotaLabel(labelKey: CredentialQuotaLabelKey, fallback: string): string {
|
||||
const key = `group.credentials.subscription.quotaLabels.${labelKey}`
|
||||
return te(key) ? t(key) : fallback
|
||||
}
|
||||
|
||||
// 与分组详情页 quotaWindowLabel 完全一致:label_key 为 oauth_apps 时保留周期后缀
|
||||
// (例如「OAuth 应用 · 7d」),否则不同周期的同类窗口会显示成完全相同的文案。
|
||||
function quotaWindowLabel(window: CredentialQuotaWindowDto): string {
|
||||
const period = quotaWindowPeriodLabel(window.window_seconds)
|
||||
if (period && window.scope === 'account') return period
|
||||
|
||||
if (window.label_key) {
|
||||
const subject = translatedQuotaLabel(window.label_key, window.label)
|
||||
return window.label_key === 'oauth_apps' && period ? `${subject} · ${period}` : subject
|
||||
}
|
||||
|
||||
const parts = window.label
|
||||
.split('·')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
if (parts.length === 0) return period
|
||||
|
||||
const firstPart = normalizedQuotaLabelPart(parts[0] ?? '')
|
||||
if (period && (firstPart === 'session' || firstPart === 'weekly')) return period
|
||||
|
||||
return parts
|
||||
.map((part) => {
|
||||
const labelKey = quotaSubjectKeys[normalizedQuotaLabelPart(part)]
|
||||
return labelKey ? translatedQuotaLabel(labelKey, part) : part
|
||||
})
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
function quotaValueLabel(window: CredentialQuotaWindowDto): string {
|
||||
const value = remainingPercent(window)
|
||||
if (value !== undefined) {
|
||||
return t('group.credentials.subscription.remainingPercent', { value: n(value) })
|
||||
}
|
||||
if (window.remaining !== undefined && window.limit !== undefined) {
|
||||
return t('group.credentials.subscription.remaining', {
|
||||
remaining: n(window.remaining),
|
||||
limit: n(window.limit),
|
||||
})
|
||||
}
|
||||
if (window.remaining !== undefined) {
|
||||
return t('group.credentials.subscription.remainingAmount', { remaining: n(window.remaining) })
|
||||
}
|
||||
return t('group.credentials.subscription.unknown')
|
||||
}
|
||||
|
||||
interface QuotaWindowPeriod {
|
||||
startMS: number
|
||||
endMS: number
|
||||
}
|
||||
|
||||
function quotaWindowPeriod(window: CredentialQuotaWindowDto): QuotaWindowPeriod | undefined {
|
||||
const endMS = window.reset_at_ms
|
||||
const seconds = window.window_seconds
|
||||
if (
|
||||
endMS === undefined ||
|
||||
seconds === undefined ||
|
||||
!Number.isSafeInteger(endMS) ||
|
||||
!Number.isSafeInteger(seconds) ||
|
||||
endMS <= 0 ||
|
||||
seconds <= 0
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const durationMS = seconds * 1_000
|
||||
if (!Number.isSafeInteger(durationMS) || durationMS > endMS) return undefined
|
||||
return { startMS: endMS - durationMS, endMS }
|
||||
}
|
||||
|
||||
function quotaWindowNeedsRefresh(window: CredentialQuotaWindowDto): boolean {
|
||||
return window.reset_at_ms !== undefined && window.reset_at_ms <= nowMs.value
|
||||
}
|
||||
|
||||
// 与分组详情页 quotaPeriodTooltip 完全一致:给出周期起止,周期已结束但数据未刷新时
|
||||
// 换成「待刷新」提示——而不是一句可能已经过期的相对时间。
|
||||
function quotaPeriodTooltip(window: CredentialQuotaWindowDto): string | undefined {
|
||||
const resetAtMS = window.reset_at_ms
|
||||
if (resetAtMS === undefined) return undefined
|
||||
const resetAt = formatLocalInstant(resetAtMS, locale.value)
|
||||
const period = quotaWindowPeriod(window)
|
||||
const periodLabel = period
|
||||
? t('group.credentials.subscription.quotaPeriod', {
|
||||
start: formatLocalInstant(period.startMS, locale.value),
|
||||
end: resetAt,
|
||||
})
|
||||
: resetAt
|
||||
return quotaWindowNeedsRefresh(window)
|
||||
? t('group.credentials.subscription.quotaPendingHint', { period: periodLabel })
|
||||
: periodLabel
|
||||
}
|
||||
|
||||
function quotaTooltip(window: CredentialQuotaWindowDto): string {
|
||||
return [quotaWindowLabel(window), quotaValueLabel(window), quotaPeriodTooltip(window)]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const quotaResetPrefix = computed(() => t('group.credentials.subscription.quotaResetPrefix'))
|
||||
const quotaResetSuffix = computed(() => t('group.credentials.subscription.quotaResetSuffix'))
|
||||
|
||||
// 焦点窗口:明确耗尽的窗口优先,即使上游没有提供百分比;否则在全部窗口(而非
|
||||
// 展示截断后的 quotaWindows)中选择剩余百分比最低者。这样状态角标、窗口名称与
|
||||
// 重置时间始终指向同一条真正受限的额度窗口。
|
||||
const lead = computed<
|
||||
{ window: CredentialQuotaWindowDto; percent: number | undefined } | undefined
|
||||
>(() => {
|
||||
const exhausted = sortedQuotaWindows.value.find((window) => window.state === 'exhausted')
|
||||
if (exhausted) return { window: exhausted, percent: remainingPercent(exhausted) }
|
||||
|
||||
let best: { window: CredentialQuotaWindowDto; percent: number } | undefined
|
||||
for (const window of sortedQuotaWindows.value) {
|
||||
const percent = remainingPercent(window)
|
||||
if (percent === undefined) continue
|
||||
if (best === undefined || percent < best.percent) best = { window, percent }
|
||||
}
|
||||
if (best) return best
|
||||
const [first] = sortedQuotaWindows.value
|
||||
return first ? { window: first, percent: undefined } : undefined
|
||||
})
|
||||
const leadTone = computed(() => (lead.value ? quotaTone(lead.value.window) : undefined))
|
||||
|
||||
const resetCreditsAvailable = computed(() => snapshot.value?.reset_credits_available ?? 0)
|
||||
const resetCredits = computed(() => snapshot.value?.reset_credits ?? [])
|
||||
const availableResetCreditDetails = computed(() =>
|
||||
resetCredits.value.slice(0, resetCreditsAvailable.value),
|
||||
)
|
||||
const hasResetCredits = computed(
|
||||
() =>
|
||||
props.account.capabilities.credential_actions.includes('reset_credit') &&
|
||||
resetCreditsAvailable.value > 0,
|
||||
)
|
||||
|
||||
// 点阵最多画 5 个,避免账号数多、卡片窄时被点阵撑爆;确切张数留给 tooltip 与读屏文本。
|
||||
const resetCreditDots = computed(() =>
|
||||
Array.from({ length: Math.min(resetCreditsAvailable.value, 5) }, (_, index) => {
|
||||
const expiresAtMS = availableResetCreditDetails.value[index]?.expires_at_ms
|
||||
let tone: ResetCreditDotTone = 'default'
|
||||
if (expiresAtMS !== undefined) {
|
||||
const remainingMS = expiresAtMS - nowMs.value
|
||||
tone =
|
||||
remainingMS <= 24 * 60 * 60 * 1_000
|
||||
? 'danger'
|
||||
: remainingMS <= 48 * 60 * 60 * 1_000
|
||||
? 'warning'
|
||||
: 'default'
|
||||
}
|
||||
return { index, tone }
|
||||
}),
|
||||
)
|
||||
|
||||
function resetCreditExpiryLabel(credit: (typeof resetCredits.value)[number]): string {
|
||||
if (credit.expires_at_ms === undefined) {
|
||||
return t('group.credentials.subscription.resetCreditPermanent')
|
||||
}
|
||||
if (credit.expires_at_ms <= nowMs.value) {
|
||||
return t('group.credentials.subscription.resetCreditExpired')
|
||||
}
|
||||
return formatLocalInstant(credit.expires_at_ms, locale.value)
|
||||
}
|
||||
|
||||
// 与分组详情页一致:一枚 tooltip 覆盖全部重置卡(标题 + 逐张到期时间),而不是逐点悬浮。
|
||||
const resetCreditsTooltip = computed(() => {
|
||||
const lines = availableResetCreditDetails.value.length
|
||||
? availableResetCreditDetails.value.map((credit, index) =>
|
||||
t('group.credentials.subscription.resetCreditsTooltipItem', {
|
||||
index: index + 1,
|
||||
expires: resetCreditExpiryLabel(credit),
|
||||
}),
|
||||
)
|
||||
: [t('group.credentials.subscription.resetCreditsTooltipNoDetails')]
|
||||
const knownCount = availableResetCreditDetails.value.length
|
||||
if (knownCount < resetCreditsAvailable.value) {
|
||||
lines.push(
|
||||
t('group.credentials.subscription.resetCreditsTooltipMore', {
|
||||
count: n(resetCreditsAvailable.value - knownCount),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return [t('group.credentials.subscription.resetCreditsTooltipTitle'), ...lines].join('\n')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="home-subscription-mini"
|
||||
:class="{ 'home-subscription-mini--off': displayDisabled }"
|
||||
:aria-label="`${accountName} · ${statusLabel}`"
|
||||
>
|
||||
<span class="sr-only">{{ statusLabel }}</span>
|
||||
|
||||
<div class="home-subscription-mini__top">
|
||||
<span class="home-subscription-mini__channel" role="img" :aria-label="channelTooltip">
|
||||
<ChannelIcon :icon="account.channel_icon" :mark="account.channel_mark" />
|
||||
</span>
|
||||
<OverflowTooltip class="home-subscription-mini__account" :content="accountName">
|
||||
{{ accountName }}
|
||||
</OverflowTooltip>
|
||||
<span v-if="planLabel" class="home-subscription-mini__plan">{{ planLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="home-subscription-mini__lead">
|
||||
<StatusBadge
|
||||
v-if="showStatusChip"
|
||||
class="home-subscription-mini__status"
|
||||
:tone="cardTone"
|
||||
:icon="displayDisabled ? 'off' : undefined"
|
||||
size="compact"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</StatusBadge>
|
||||
<span
|
||||
v-else-if="lead && lead.percent !== undefined"
|
||||
class="home-subscription-mini__num"
|
||||
:class="`home-subscription-mini__num--${leadTone ?? 'unknown'}`"
|
||||
>
|
||||
<strong>{{ n(lead.percent) }}</strong
|
||||
><span aria-hidden="true">%</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="lead && lead.window.remaining !== undefined"
|
||||
class="home-subscription-mini__num home-subscription-mini__num--amount"
|
||||
:class="`home-subscription-mini__num--${leadTone ?? 'unknown'}`"
|
||||
>
|
||||
{{ quotaValueLabel(lead.window) }}
|
||||
</span>
|
||||
<span v-else class="home-subscription-mini__num home-subscription-mini__num--empty">—</span>
|
||||
|
||||
<span class="home-subscription-mini__meta">
|
||||
<template v-if="lead">
|
||||
{{ quotaWindowLabel(lead.window) }}
|
||||
<template v-if="lead.window.reset_at_ms">
|
||||
·
|
||||
<AppTooltip
|
||||
v-if="quotaWindowNeedsRefresh(lead.window)"
|
||||
:content="quotaPeriodTooltip(lead.window) ?? ''"
|
||||
>
|
||||
<span class="home-subscription-mini__pending" tabindex="0">{{
|
||||
t('group.credentials.subscription.quotaPendingRefresh')
|
||||
}}</span>
|
||||
</AppTooltip>
|
||||
<template v-else>
|
||||
<span v-if="quotaResetPrefix">{{ quotaResetPrefix }}</span>
|
||||
<AppRelativeTime
|
||||
:instant="lead.window.reset_at_ms"
|
||||
:locale="locale"
|
||||
:empty-label="t('group.credentials.subscription.unknown')"
|
||||
:tooltip-content="quotaPeriodTooltip(lead.window)"
|
||||
hint
|
||||
/><span v-if="quotaResetSuffix">{{ quotaResetSuffix }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>{{ t('group.credentials.subscription.noQuota') }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="quotaWindows.length > 1 || hasResetCredits"
|
||||
class="home-subscription-mini__resources"
|
||||
>
|
||||
<div
|
||||
v-if="quotaWindows.length > 1"
|
||||
class="home-subscription-mini__strip"
|
||||
role="group"
|
||||
:aria-label="t('group.credentials.subscription.title')"
|
||||
>
|
||||
<AppTooltip v-for="window in quotaWindows" :key="window.id" :content="quotaTooltip(window)">
|
||||
<span
|
||||
class="home-subscription-mini__seg"
|
||||
:class="`home-subscription-mini__seg--${quotaTone(window) ?? 'unknown'}`"
|
||||
:role="remainingPercent(window) === undefined ? 'img' : 'progressbar'"
|
||||
:aria-label="quotaTooltip(window)"
|
||||
:aria-valuemin="remainingPercent(window) === undefined ? undefined : 0"
|
||||
:aria-valuemax="remainingPercent(window) === undefined ? undefined : 100"
|
||||
:aria-valuenow="remainingPercent(window)"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
v-if="remainingPercent(window) !== undefined"
|
||||
class="home-subscription-mini__seg-fill"
|
||||
:style="quotaFillStyle(window)"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</span>
|
||||
</AppTooltip>
|
||||
</div>
|
||||
<span v-else class="home-subscription-mini__spacer" aria-hidden="true"></span>
|
||||
|
||||
<AppTooltip v-if="hasResetCredits" :content="resetCreditsTooltip">
|
||||
<span
|
||||
class="home-subscription-mini__credits"
|
||||
tabindex="0"
|
||||
:aria-label="resetCreditsTooltip"
|
||||
>
|
||||
<i
|
||||
v-for="dot in resetCreditDots"
|
||||
:key="dot.index"
|
||||
class="home-subscription-mini__credit-dot"
|
||||
:class="`home-subscription-mini__credit-dot--${dot.tone}`"
|
||||
></i>
|
||||
<span
|
||||
v-if="resetCreditsAvailable > resetCreditDots.length"
|
||||
class="home-subscription-mini__credit-more"
|
||||
aria-hidden="true"
|
||||
>+</span
|
||||
>
|
||||
</span>
|
||||
</AppTooltip>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-subscription-mini {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 10px 11px;
|
||||
}
|
||||
|
||||
.home-subscription-mini--off {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.home-subscription-mini--off .home-subscription-mini__account,
|
||||
.home-subscription-mini--off .home-subscription-mini__meta {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.home-subscription-mini__top {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.home-subscription-mini__channel {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.home-subscription-mini__seg:focus-visible,
|
||||
.home-subscription-mini__credits:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.home-subscription-mini__channel :deep(.channel-icon) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-subscription-mini__account {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-subscription-mini__plan {
|
||||
flex: none;
|
||||
border-radius: 5px;
|
||||
background: var(--color-tag);
|
||||
padding: 1px 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-label-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-subscription-mini__lead {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.home-subscription-mini__num {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: baseline;
|
||||
gap: 1px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.home-subscription-mini__num strong {
|
||||
font-size: 25px;
|
||||
font-weight: 640;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.home-subscription-mini__num span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.home-subscription-mini__num--amount {
|
||||
max-width: 9em;
|
||||
overflow: hidden;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 与分组详情页 SubscriptionAccountCard 的配额条同一套强调色(oklch,单值适配明暗),
|
||||
而非通用状态语义色——这三个数值代表的是额度剩余量,不是运行状态。 */
|
||||
.home-subscription-mini__num--success {
|
||||
color: oklch(70% 0.16 158);
|
||||
}
|
||||
|
||||
.home-subscription-mini__num--warning {
|
||||
color: oklch(75% 0.152 75);
|
||||
}
|
||||
|
||||
.home-subscription-mini__num--danger {
|
||||
color: oklch(65% 0.2 22);
|
||||
}
|
||||
|
||||
.home-subscription-mini__num--unknown {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.home-subscription-mini__num--empty {
|
||||
color: var(--color-text-faint);
|
||||
font-size: 21px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.home-subscription-mini__meta {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding-bottom: 3px;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
line-height: 1.3;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-subscription-mini__meta :deep(.app-relative-time--hint) {
|
||||
text-decoration-color: var(--color-border-control);
|
||||
}
|
||||
|
||||
.home-subscription-mini__pending {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted;
|
||||
text-decoration-color: var(--color-border-control);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.home-subscription-mini__pending:focus-visible {
|
||||
border-radius: 3px;
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.home-subscription-mini__resources {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
row-gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.home-subscription-mini__status {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.home-subscription-mini__strip {
|
||||
display: flex;
|
||||
min-width: 32px;
|
||||
flex: 1 1 32px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.home-subscription-mini__spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.home-subscription-mini__seg {
|
||||
position: relative;
|
||||
display: block;
|
||||
flex: 1 1 0;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--seg-track, var(--color-surface-sunken));
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* 与分组详情页 SubscriptionAccountCard 的配额条同一套颜色值(quota--success/warning/
|
||||
danger),保持首页摘要与分组详情页对同一份额度数据的视觉表达一致。 */
|
||||
.home-subscription-mini__seg--success {
|
||||
--seg-track: light-dark(#dcfeea, #112b21);
|
||||
--seg-fill: oklch(70% 0.16 158);
|
||||
}
|
||||
|
||||
.home-subscription-mini__seg--warning {
|
||||
--seg-track: light-dark(#fff2e2, #302212);
|
||||
--seg-fill: oklch(75% 0.152 75);
|
||||
}
|
||||
|
||||
.home-subscription-mini__seg--danger {
|
||||
--seg-track: light-dark(#fef0f0, #371a1d);
|
||||
--seg-fill: oklch(65% 0.2 22);
|
||||
}
|
||||
|
||||
.home-subscription-mini__seg-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: inherit;
|
||||
background: var(--seg-fill, var(--color-border-control));
|
||||
transition: width var(--duration-fast) var(--easing-standard);
|
||||
}
|
||||
|
||||
.home-subscription-mini__credits {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.home-subscription-mini__credit-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--color-action);
|
||||
}
|
||||
|
||||
.home-subscription-mini__credit-dot--warning {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.home-subscription-mini__credit-dot--danger {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.home-subscription-mini__credit-more {
|
||||
color: var(--color-text-faint);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-subscription-mini__seg-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,17 +2,13 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { HomeSubscriptionAccountsDto } from '@/app/resources/home'
|
||||
import SubscriptionAccountCard from '@/features/groups/credentials/SubscriptionAccountCard.vue'
|
||||
|
||||
import HomeSubscriptionAccountMiniCard from './HomeSubscriptionAccountMiniCard.vue'
|
||||
import HomeSectionHeading from './HomeSectionHeading.vue'
|
||||
|
||||
defineProps<{ accounts: HomeSubscriptionAccountsDto }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function ignoreReadonlyProxyMutation(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,25 +21,11 @@ function ignoreReadonlyProxyMutation(): Promise<void> {
|
||||
id="home-subscription-accounts-title"
|
||||
:title="t('home.ledger.subscriptionAccounts.title')"
|
||||
/>
|
||||
<div class="home-subscription-accounts__grid">
|
||||
<SubscriptionAccountCard
|
||||
<div class="home-subscription-accounts__row">
|
||||
<HomeSubscriptionAccountMiniCard
|
||||
v-for="account in accounts.items"
|
||||
:key="`${account.channel_id}-${account.credential.credential_id}`"
|
||||
:item="account.credential"
|
||||
:selected="false"
|
||||
:busy="false"
|
||||
:refreshing-observation="false"
|
||||
observation-error=""
|
||||
:detail-busy="false"
|
||||
:detail-loaded="false"
|
||||
detail-error=""
|
||||
:channel-icon="account.channel_icon"
|
||||
:channel-mark="account.channel_mark"
|
||||
:capabilities="account.capabilities"
|
||||
:save-proxy="ignoreReadonlyProxyMutation"
|
||||
:group-count="account.group_count"
|
||||
:available-group-count="account.available_group_count"
|
||||
readonly
|
||||
:account="account"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -58,16 +40,18 @@ function ignoreReadonlyProxyMutation(): Promise<void> {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.home-subscription-accounts__grid {
|
||||
/*
|
||||
* 后端最多返回 4 个账号(homeSubscriptionAccountLimit),配合每列至少 232px:
|
||||
* 桌面宽度下正好排出 4 列,4 个账号天然一行放完,不会换行也不需要横向滚动;
|
||||
* 宽度不够(含移动端)时 auto-fill 自动减少列数,多出的账号才换到下一行。
|
||||
*/
|
||||
.home-subscription-accounts__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
align-items: start;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.home-subscription-accounts__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.home-subscription-accounts__row > * {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -290,8 +290,7 @@ export default {
|
||||
action: 'Resolve',
|
||||
},
|
||||
subscriptionAccounts: {
|
||||
title: 'Recently used subscription accounts',
|
||||
groupCount: 'Groups: {count}',
|
||||
title: 'Recently used',
|
||||
availableGroups: '{available}/{total} available',
|
||||
},
|
||||
spend: {
|
||||
|
||||
@@ -288,8 +288,7 @@ export default {
|
||||
action: '対応',
|
||||
},
|
||||
subscriptionAccounts: {
|
||||
title: '最近使用したサブスクリプションアカウント',
|
||||
groupCount: '{count} グループ',
|
||||
title: '最近よく使う',
|
||||
availableGroups: '{available}/{total} 利用可能',
|
||||
},
|
||||
spend: {
|
||||
|
||||
@@ -275,8 +275,7 @@ export default {
|
||||
action: '处理',
|
||||
},
|
||||
subscriptionAccounts: {
|
||||
title: '最近常用订阅账号',
|
||||
groupCount: '{count} 个分组',
|
||||
title: '最近常用',
|
||||
availableGroups: '{available}/{total} 可用',
|
||||
},
|
||||
spend: {
|
||||
|
||||
Reference in new issue
Block a user