refactor(group-detail): 复用统一列表组件

This commit is contained in:
tbphp committed 2026-08-01 15:34:26 +08:00
1 parent 409340e4d5
commit 59fa5e2b39
16 files changed
+650 -589

No files matched your search

@@ -3,9 +3,11 @@ withDefaults(
defineProps<{
label: string
showResult?: boolean
singleColumn?: boolean
}>(),
{
showResult: false,
singleColumn: false,
},
)
</script>
@@ -13,6 +15,7 @@ withDefaults(
<template>
<form
class="collection-filter-bar"
:class="{ 'collection-filter-bar--single': singleColumn }"
role="search"
:aria-label="label"
autocomplete="off"
@@ -35,6 +38,10 @@ withDefaults(
padding: 22px 0 13px;
}
.collection-filter-bar.collection-filter-bar--single {
grid-template-columns: minmax(0, 1fr);
}
.collection-filter-field {
display: grid;
min-width: 0;
+24 -8
View File
@@ -154,20 +154,36 @@ function guardDismiss(event: Event): void {
padding: 10px 18px;
}
@keyframes app-drawer-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes app-drawer-overlay-out {
from { opacity: 1; }
to { opacity: 0; }
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes app-drawer-content-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes app-drawer-content-out {
from { transform: translateX(0); }
to { transform: translateX(100%); }
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
@media (max-width: 480px) {
.app-drawer__content {
+1 -6
View File
@@ -10,12 +10,7 @@ const current = toast.current
<template>
<Teleport to="body">
<Transition name="app-toast" mode="out-in">
<InlineFeedback
v-if="current"
:key="current.id"
:tone="current.tone"
appearance="toast"
>
<InlineFeedback v-if="current" :key="current.id" :tone="current.tone" appearance="toast">
{{ current.message }}
</InlineFeedback>
</Transition>
@@ -242,7 +242,6 @@ onBeforeUnmount(() => {
overflow-wrap: anywhere;
white-space: normal;
}
}
@media (max-width: 860px) {
@@ -708,11 +708,7 @@ onBeforeUnmount(clearLocalState)
<template #footer>
<div v-if="editing && base" class="access-key-drawer__delete">
<AccessKeyDeleteDialog
:access-key="base"
:total="total"
@deleted="handleDeleted"
/>
<AccessKeyDeleteDialog :access-key="base" :total="total" @deleted="handleDeleted" />
</div>
<AppButton variant="secondary" :disabled="closeBlocked" @click="setOpen(false)">
{{ t('common.cancel') }}
@@ -45,10 +45,7 @@ function optionDisabled(dimension: 'protocols' | 'models'): boolean {
return props.disabled || props.modes[dimension] !== 'restricted' || catalogUnavailable()
}
function requestScopeMode(
dimension: AccessKeyScopeDimension,
mode: AccessKeyScopeMode,
): void {
function requestScopeMode(dimension: AccessKeyScopeDimension, mode: AccessKeyScopeMode): void {
emit('setScopeMode', dimension, mode)
}
@@ -78,7 +75,6 @@ function toggleProtocol(protocol: AccessProtocol, event: Event): void {
: props.filters.protocols.filter((value) => value !== protocol)
emit('update:protocols', next)
}
</script>
<template>
@@ -164,11 +160,7 @@ function toggleProtocol(protocol: AccessProtocol, event: Event): void {
<i aria-hidden="true" />{{ t('accessKeys.drawer.allProtocolsAllowed') }}
</div>
<div v-else class="protocol-options">
<label
v-for="protocol in protocolOptions"
:key="protocol"
class="access-key-drawer__check"
>
<label v-for="protocol in protocolOptions" :key="protocol" class="access-key-drawer__check">
<input
type="checkbox"
:checked="filters.protocols.includes(protocol)"
@@ -1,151 +0,0 @@
<script setup lang="ts">
import { RotateCcw, Trash2 } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GroupKeyItemDto } from '@/api/control/types'
import AppButton from '@/components/ui/AppButton.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import IconButton from '@/components/ui/IconButton.vue'
import MobileRecordCard from '@/components/ui/MobileRecordCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatLocalInstant } from '@/lib/format'
import { presentKeyFailureCategory } from './key-failure-presenter'
const props = defineProps<{ item: GroupKeyItemDto; selected: boolean; busy: boolean }>()
const emit = defineEmits<{
'update:selected': [selected: boolean]
weight: [payload: { item: GroupKeyItemDto; value: string }]
toggle: [item: GroupKeyItemDto]
restore: [item: GroupKeyItemDto]
remove: [item: GroupKeyItemDto]
}>()
const { locale, n, t } = useI18n()
const weights = Array.from({ length: 100 }, (_, index) => index + 1)
const problem = computed(
() => props.item.effective_status === 'cooldown' || props.item.effective_status === 'blacklisted',
)
const weightValue = computed(() =>
props.item.weight_mode === 'manual' && props.item.weight !== null
? String(props.item.weight)
: 'auto',
)
const weight = computed(() =>
props.item.weight === null
? t('group.keys.none')
: t(`group.keys.weight.${props.item.weight_mode}`, { weight: n(props.item.weight) }),
)
const recent = computed(() =>
props.item.recent_failure_count === 0
? t('group.keys.recentSuccessOnly', { success: n(props.item.recent_success_count) })
: t('group.keys.recent', {
success: n(props.item.recent_success_count),
failure: n(props.item.recent_failure_count),
}),
)
const recovery = computed(() =>
props.item.recovery.at_ms === null
? t(`group.keys.recovery.${props.item.recovery.mode}`)
: t('group.keys.recovery.at', {
time: formatLocalInstant(props.item.recovery.at_ms, locale.value),
}),
)
const failure = computed(() =>
props.item.recent_failure_count === 0
? t('group.keys.none')
: `${presentKeyFailureCategory(t, props.item.last_failure_category)}${
props.item.last_status_code === null ? '' : ` · ${props.item.last_status_code}`
}`,
)
</script>
<template>
<MobileRecordCard :label="t('group.keys.cardLabel', { mask: item.mask })">
<template #header
><div>
<strong class="group-key-card__mask">{{ item.mask }}</strong
><StatusBadge :status="item.effective_status">{{
t(`group.keys.effective.${item.effective_status}`)
}}</StatusBadge>
</div>
<input
type="checkbox"
:checked="selected"
:disabled="busy"
:aria-label="t('group.keys.selectKey', { mask: item.mask })"
@change="emit('update:selected', ($event.target as HTMLInputElement).checked)"
/></template>
<dl>
<dt>{{ t('group.keys.columns.weight') }}</dt>
<dd>
<AppTooltip v-if="item.weight === null" :content="t('group.keys.notScheduledHelp')"
><span tabindex="0">{{ weight }}</span></AppTooltip
><span v-else>{{ weight }}</span>
</dd>
<dt>{{ t('group.keys.columns.recent') }}</dt>
<dd>{{ recent }}</dd>
<dt>{{ t('group.keys.columns.failure') }}</dt>
<dd>
{{ failure }}
</dd>
<dt>{{ t('group.keys.columns.recovery') }}</dt>
<dd>{{ recovery }}</dd>
</dl>
<template #actions
><label class="sr-only" :for="`key-mobile-weight-${item.id}`">{{
t('group.keys.weightFor', { mask: item.mask })
}}</label
><select
:id="`key-mobile-weight-${item.id}`"
:value="weightValue"
:disabled="busy || item.configured_status === 'disabled'"
@change="emit('weight', { item, value: ($event.target as HTMLSelectElement).value })"
>
<option value="auto">{{ t('group.keys.auto') }}</option>
<option v-for="value in weights" :key="value" :value="String(value)">
{{ value }}
</option></select
><AppButton
variant="secondary"
size="compact"
:disabled="busy"
@click="emit('toggle', item)"
>{{
item.configured_status === 'active' ? t('group.keys.disable') : t('group.keys.enable')
}}</AppButton
><IconButton
v-if="problem"
variant="ghost"
size="compact"
:label="t('group.keys.restore')"
:disabled="busy"
@click="emit('restore', item)"
><RotateCcw :size="16" aria-hidden="true" /></IconButton
><IconButton
variant="ghost"
size="compact"
:label="t('group.keys.delete')"
:disabled="busy"
@click="emit('remove', item)"
><Trash2 :size="16" aria-hidden="true" /></IconButton
></template>
</MobileRecordCard>
</template>
<style scoped>
.group-key-card__mask {
display: block;
margin-bottom: var(--space-2);
font-family: var(--font-mono);
}
.mobile-record-card :deep(select) {
min-height: var(--touch-target);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding-inline: var(--space-2);
font: inherit;
}
</style>
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { RotateCcw, Trash2 } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GroupKeyItemDto } from '@/api/control/types'
import AppButton from '@/components/ui/AppButton.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import DisclosurePanel from '@/components/ui/DisclosurePanel.vue'
import IconButton from '@/components/ui/IconButton.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatLocalInstant } from '@/lib/format'
import { presentKeyFailureCategory } from './key-failure-presenter'
const props = defineProps<{
item: GroupKeyItemDto
rowIndex: number
selected: boolean
busy: boolean
}>()
const emit = defineEmits<{
'update:selected': [selected: boolean]
weight: [payload: { item: GroupKeyItemDto; value: string }]
toggle: [item: GroupKeyItemDto]
restore: [item: GroupKeyItemDto]
remove: [item: GroupKeyItemDto]
}>()
const { locale, n, t } = useI18n()
const weights = Array.from({ length: 100 }, (_, index) => index + 1)
const isProblem = computed(
() => props.item.effective_status === 'cooldown' || props.item.effective_status === 'blacklisted',
)
const weightValue = computed(() =>
props.item.weight_mode === 'manual' && props.item.weight !== null
? String(props.item.weight)
: 'auto',
)
const weightLabel = computed(() =>
props.item.weight === null
? t('group.keys.none')
: t(`group.keys.weight.${props.item.weight_mode}`, { weight: n(props.item.weight) }),
)
const recentLabel = computed(() =>
props.item.recent_failure_count === 0
? t('group.keys.recentSuccessOnly', { success: n(props.item.recent_success_count) })
: t('group.keys.recent', {
success: n(props.item.recent_success_count),
failure: n(props.item.recent_failure_count),
}),
)
const recoveryLabel = computed(() => {
if (props.item.recovery.mode === 'none') return t('group.keys.recovery.none')
if (props.item.recovery.at_ms !== null)
return t('group.keys.recovery.at', {
time: formatLocalInstant(props.item.recovery.at_ms, locale.value),
})
return t(`group.keys.recovery.${props.item.recovery.mode}`)
})
const failureLabel = computed(() =>
props.item.recent_failure_count === 0
? t('group.keys.none')
: `${presentKeyFailureCategory(t, props.item.last_failure_category)}${
props.item.last_status_code === null ? '' : ` · ${props.item.last_status_code}`
}`,
)
function updateWeight(event: Event): void {
emit('weight', { item: props.item, value: (event.target as HTMLSelectElement).value })
}
</script>
<template>
<article class="ledger-record-list__record group-key-record" role="row" :aria-rowindex="rowIndex">
<div class="ledger-record-list__cell group-key-record__select" role="cell">
<label>
<span class="sr-only">{{ t('group.keys.selectKey', { mask: item.mask }) }}</span>
<input
type="checkbox"
:checked="selected"
:disabled="busy"
@change="emit('update:selected', ($event.target as HTMLInputElement).checked)"
/>
</label>
</div>
<div class="ledger-record-list__cell group-key-record__mask" role="cell">
<span class="group-key-record__mobile-label">{{ t('group.keys.columns.key') }}</span>
<strong>{{ item.mask }}</strong>
</div>
<div class="ledger-record-list__cell group-key-record__status" role="cell">
<span class="group-key-record__mobile-label">{{ t('group.keys.columns.status') }}</span>
<StatusBadge :status="item.effective_status">
{{ t(`group.keys.effective.${item.effective_status}`) }}
</StatusBadge>
</div>
<div class="ledger-record-list__cell group-key-record__weight" role="cell">
<span class="group-key-record__mobile-label">{{ t('group.keys.columns.weight') }}</span>
<AppTooltip v-if="item.weight === null" :content="t('group.keys.notScheduledHelp')">
<span class="group-key-record__weight-none" tabindex="0">{{ weightLabel }}</span>
</AppTooltip>
<span v-else>{{ weightLabel }}</span>
</div>
<div class="ledger-record-list__cell group-key-record__recent" role="cell">
<span class="group-key-record__mobile-label">{{ t('group.keys.columns.recent') }}</span>
<span>{{ recentLabel }}</span>
</div>
<div class="ledger-record-list__cell group-key-record__actions" role="cell">
<label class="sr-only" :for="`key-weight-${item.id}`">
{{ t('group.keys.weightFor', { mask: item.mask }) }}
</label>
<select
:id="`key-weight-${item.id}`"
:value="weightValue"
:disabled="busy || item.configured_status === 'disabled'"
@change="updateWeight"
>
<option value="auto">{{ t('group.keys.auto') }}</option>
<option v-for="weight in weights" :key="weight" :value="String(weight)">
{{ weight }}
</option>
</select>
<AppButton variant="ghost" size="compact" :disabled="busy" @click="emit('toggle', item)">
{{ item.configured_status === 'active' ? t('group.keys.disable') : t('group.keys.enable') }}
</AppButton>
<IconButton
v-if="isProblem"
variant="ghost"
size="compact"
:label="t('group.keys.restore')"
:disabled="busy"
@click="emit('restore', item)"
>
<RotateCcw :size="15" aria-hidden="true" />
</IconButton>
<IconButton
variant="ghost"
size="compact"
:label="t('group.keys.delete')"
:disabled="busy"
@click="emit('remove', item)"
>
<Trash2 :size="15" aria-hidden="true" />
</IconButton>
</div>
<div class="ledger-record-list__cell group-key-record__details" role="cell">
<DisclosurePanel :summary="t('group.keys.details')">
<dl>
<div>
<dt>{{ t('group.keys.detailsFailure') }}</dt>
<dd>{{ failureLabel }}</dd>
</div>
<div>
<dt>{{ t('group.keys.detailsRecovery') }}</dt>
<dd>{{ recoveryLabel }}</dd>
</div>
<div>
<dt>{{ t('group.keys.detailsConsecutive') }}</dt>
<dd>{{ n(item.consecutive_failure_count) }}</dd>
</div>
</dl>
</DisclosurePanel>
</div>
</article>
</template>
<style scoped>
.group-key-record__select {
display: flex;
justify-content: center;
}
.group-key-record__select label {
display: grid;
width: var(--touch-target);
height: var(--touch-target);
place-items: center;
cursor: pointer;
}
.group-key-record__mask,
.group-key-record__weight,
.group-key-record__recent {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.group-key-record__mask strong {
overflow-wrap: anywhere;
}
.group-key-record__weight-none {
color: var(--color-text-faint);
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.group-key-record__actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-1);
}
.group-key-record__actions select {
min-height: var(--control-compact);
max-width: 104px;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding-inline: var(--space-1);
font: inherit;
}
.group-key-record__details {
grid-column: 1 / -1;
min-width: 0;
padding-top: var(--space-1);
}
.group-key-record__details dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
}
.group-key-record__details dl div {
min-width: 0;
}
.group-key-record__details dt {
color: var(--color-text-faint);
font-size: var(--text-sm);
}
.group-key-record__details dd {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
}
.group-key-record__mobile-label {
display: none;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
font-weight: 560;
}
@media (max-width: 860px) {
.group-key-record {
padding-right: 58px;
}
.group-key-record__select {
position: absolute;
top: 2px;
right: 4px;
}
.group-key-record__mask,
.group-key-record__recent,
.group-key-record__actions,
.group-key-record__details {
grid-column: 1 / -1;
}
.group-key-record__mask,
.group-key-record__status,
.group-key-record__weight,
.group-key-record__recent {
display: grid;
align-content: start;
gap: 5px;
}
.group-key-record__recent,
.group-key-record__actions,
.group-key-record__details {
border-top: 1px solid var(--color-border-subtle);
padding-top: 11px;
}
.group-key-record__actions {
justify-content: flex-start;
}
.group-key-record__actions select,
.group-key-record__actions :deep(.app-button),
.group-key-record__actions :deep(.icon-button) {
min-height: var(--touch-target);
}
.group-key-record__details dl {
grid-template-columns: 1fr;
}
.group-key-record__mobile-label {
display: inline;
}
}
</style>
@@ -1,210 +0,0 @@
<script setup lang="ts">
import { RotateCcw, Trash2 } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GroupKeyItemDto } from '@/api/control/types'
import AppButton from '@/components/ui/AppButton.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import DisclosurePanel from '@/components/ui/DisclosurePanel.vue'
import IconButton from '@/components/ui/IconButton.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatLocalInstant } from '@/lib/format'
import { presentKeyFailureCategory } from './key-failure-presenter'
const props = defineProps<{ item: GroupKeyItemDto; selected: boolean; busy: boolean }>()
const emit = defineEmits<{
'update:selected': [selected: boolean]
weight: [payload: { item: GroupKeyItemDto; value: string }]
toggle: [item: GroupKeyItemDto]
restore: [item: GroupKeyItemDto]
remove: [item: GroupKeyItemDto]
}>()
const { locale, n, t } = useI18n()
const weights = Array.from({ length: 100 }, (_, index) => index + 1)
const isProblem = computed(
() => props.item.effective_status === 'cooldown' || props.item.effective_status === 'blacklisted',
)
const weightValue = computed(() =>
props.item.weight_mode === 'manual' && props.item.weight !== null
? String(props.item.weight)
: 'auto',
)
const weightLabel = computed(() =>
props.item.weight === null
? t('group.keys.notScheduled')
: t(`group.keys.weight.${props.item.weight_mode}`, { weight: n(props.item.weight) }),
)
const recentLabel = computed(() =>
props.item.recent_failure_count === 0
? t('group.keys.recentSuccessOnly', { success: n(props.item.recent_success_count) })
: t('group.keys.recent', {
success: n(props.item.recent_success_count),
failure: n(props.item.recent_failure_count),
}),
)
const recoveryLabel = computed(() => {
if (props.item.recovery.mode === 'none') return t('group.keys.recovery.none')
if (props.item.recovery.at_ms !== null)
return t('group.keys.recovery.at', {
time: formatLocalInstant(props.item.recovery.at_ms, locale.value),
})
return t(`group.keys.recovery.${props.item.recovery.mode}`)
})
const failureLabel = computed(() =>
props.item.recent_failure_count === 0
? t('group.keys.none')
: `${presentKeyFailureCategory(t, props.item.last_failure_category)}${
props.item.last_status_code === null ? '' : ` · ${props.item.last_status_code}`
}`,
)
function updateWeight(event: Event): void {
emit('weight', { item: props.item, value: (event.target as HTMLSelectElement).value })
}
</script>
<template>
<tr>
<td>
<input
type="checkbox"
:checked="selected"
:disabled="busy"
:aria-label="t('group.keys.selectKey', { mask: item.mask })"
@change="emit('update:selected', ($event.target as HTMLInputElement).checked)"
/>
</td>
<td class="group-key-row__mask">{{ item.mask }}</td>
<td>
<StatusBadge :status="item.effective_status">{{
t(`group.keys.effective.${item.effective_status}`)
}}</StatusBadge>
</td>
<td>
<AppTooltip v-if="item.weight === null" :content="t('group.keys.notScheduledHelp')"
><span class="group-key-row__weight group-key-row__weight--none" tabindex="0">{{
t('group.keys.none')
}}</span></AppTooltip
>
<span v-else class="group-key-row__weight">{{ weightLabel }}</span>
</td>
<td class="group-key-row__recent">{{ recentLabel }}</td>
<td>
<div class="group-key-row__actions">
<label class="sr-only" :for="`key-weight-${item.id}`">{{
t('group.keys.weightFor', { mask: item.mask })
}}</label>
<select
:id="`key-weight-${item.id}`"
:value="weightValue"
:disabled="busy || item.configured_status === 'disabled'"
@change="updateWeight"
>
<option value="auto">{{ t('group.keys.auto') }}</option>
<option v-for="weight in weights" :key="weight" :value="String(weight)">
{{ weight }}
</option>
</select>
<AppButton variant="ghost" size="compact" :disabled="busy" @click="emit('toggle', item)">{{
item.configured_status === 'active' ? t('group.keys.disable') : t('group.keys.enable')
}}</AppButton>
<IconButton
v-if="isProblem"
variant="ghost"
size="compact"
:label="t('group.keys.restore')"
:disabled="busy"
@click="emit('restore', item)"
><RotateCcw :size="15" aria-hidden="true"
/></IconButton>
<IconButton
variant="ghost"
size="compact"
:label="t('group.keys.delete')"
:disabled="busy"
@click="emit('remove', item)"
><Trash2 :size="15" aria-hidden="true"
/></IconButton>
</div>
</td>
</tr>
<tr class="group-key-row__detail">
<td colspan="6">
<DisclosurePanel :summary="t('group.keys.details')"
><dl>
<div>
<dt>{{ t('group.keys.detailsFailure') }}</dt>
<dd>
{{ failureLabel }}
</dd>
</div>
<div>
<dt>{{ t('group.keys.detailsRecovery') }}</dt>
<dd>{{ recoveryLabel }}</dd>
</div>
<div>
<dt>{{ t('group.keys.detailsConsecutive') }}</dt>
<dd>{{ n(item.consecutive_failure_count) }}</dd>
</div>
</dl></DisclosurePanel
>
</td>
</tr>
</template>
<style scoped>
.group-key-row__mask,
.group-key-row__recent,
.group-key-row__weight {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.group-key-row__weight--none {
color: var(--color-text-faint);
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.group-key-row__actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.group-key-row__actions select {
min-height: var(--control-compact);
max-width: 104px;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding-inline: var(--space-1);
font: inherit;
}
.group-key-row__detail td {
padding-top: 0;
}
.group-key-row__detail dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
}
.group-key-row__detail dl div {
min-width: 0;
}
.group-key-row__detail dt {
color: var(--color-text-faint);
font-size: var(--text-sm);
}
.group-key-row__detail dd {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
}
@media (max-width: 1023px) {
.group-key-row__detail dl {
grid-template-columns: 1fr;
}
}
</style>
+147 -121
View File
@@ -24,20 +24,18 @@ import {
} from '@/app/resources/upstream-keys'
import { groupDetailLocation } from '@/app/route-locations'
import { controlQueryKeys } from '@/app/query-keys'
import CollectionFilterBar from '@/components/collection/CollectionFilterBar.vue'
import CollectionStatusSummary from '@/components/collection/CollectionStatusSummary.vue'
import LedgerRecordList from '@/components/collection/LedgerRecordList.vue'
import AppButton from '@/components/ui/AppButton.vue'
import AppDialog from '@/components/ui/AppDialog.vue'
import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PaginationBar from '@/components/ui/PaginationBar.vue'
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
import StatusSummaryFilter, {
type StatusSummaryFilterValue,
} from '@/components/ui/StatusSummaryFilter.vue'
import GroupKeyBatchBar from './GroupKeyBatchBar.vue'
import GroupKeyMobileCard from './GroupKeyMobileCard.vue'
import GroupKeyRow from './GroupKeyRow.vue'
import GroupKeyRecord from './GroupKeyRecord.vue'
import {
constrainGroupKeySearch,
parseGroupKeyRouteQuery,
@@ -76,6 +74,46 @@ const dialogBusy = computed(() => {
if (target === undefined) return false
return target.ids.length === 1 ? pending(target.ids[0]) : batchBusy.value
})
const hasChangedConditions = computed(
() => filters.value.q !== undefined || filters.value.status !== undefined,
)
const statusSummaryItems = computed(() => {
const summary = collection.value?.summary
if (!summary) return []
return [
{
value: undefined,
label: t('group.keys.status.all'),
count: summary.total,
tone: 'neutral' as const,
},
{
value: 'available',
label: t('group.keys.effective.available'),
count: summary.available,
tone: 'success' as const,
},
{
value: 'cooldown',
label: t('group.keys.effective.cooldown'),
count: summary.cooldown,
tone: 'warning' as const,
},
{
value: 'blacklisted',
label: t('group.keys.effective.blacklisted'),
count: summary.blacklisted,
tone: 'danger' as const,
},
{
value: 'disabled',
label: t('group.keys.effective.disabled'),
count: summary.disabled,
tone: 'neutral' as const,
},
]
})
watch(
() => route.query,
@@ -142,7 +180,14 @@ function clearSearch(): void {
setFilter({ q: undefined })
}
function setStatus(value: StatusSummaryFilterValue | undefined): void {
function resetFilters(): void {
if (searchTimer !== undefined) clearTimeout(searchTimer)
searchTimer = undefined
searchDraft.value = ''
updateRoute({ page: 1, page_size: filters.value.page_size })
}
function setStatus(value: string | undefined): void {
setFilter({ status: value as GroupKeyStatus | undefined })
}
@@ -393,45 +438,59 @@ onBeforeUnmount(() => {
@retry="keysQuery.refetch()"
/>
<p v-if="feedback" class="group-keys__feedback" role="alert">{{ feedback }}</p>
<StatusSummaryFilter
<CollectionStatusSummary
v-if="collection.summary.total > 0"
:summary="collection.summary"
:total="collection.summary.total"
:items="statusSummaryItems"
:model-value="filters.status"
:labels="{
region: t('group.keys.summary.region'),
current: t('group.keys.summary.current'),
all: t('group.keys.status.all'),
available: t('group.keys.effective.available'),
unavailable: '',
cooldown: t('group.keys.effective.cooldown'),
blacklisted: t('group.keys.effective.blacklisted'),
disabled: t('group.keys.effective.disabled'),
}"
:label="t('group.keys.summary.region')"
:total-label="t('group.keys.summary.current')"
@update:model-value="setStatus"
/>
<form
<CollectionFilterBar
v-if="collection.summary.total > 0"
class="group-keys__filters"
role="search"
:aria-label="t('group.keys.filters.region')"
@submit.prevent
single-column
:label="t('group.keys.filters.region')"
:show-result="hasChangedConditions"
>
<label
><span>{{ t('group.keys.filters.search') }}</span
><span class="group-keys__search"
><Search :size="15" aria-hidden="true" /><input
<label class="collection-filter-field collection-filter-field--search">
<span class="collection-filter-label">{{ t('group.keys.filters.search') }}</span>
<span class="collection-filter-search-control">
<Search :size="15" aria-hidden="true" />
<input
v-model="searchDraft"
class="collection-filter-control"
type="search"
:aria-label="t('group.keys.filters.search')"
:placeholder="t('group.keys.filters.placeholder')"
@input="scheduleSearch" /><IconButton
@input="scheduleSearch"
/>
<IconButton
v-if="searchDraft"
class="collection-filter-search-clear"
size="xs"
variant="ghost"
:label="t('group.keys.filters.clear')"
@click="clearSearch"
><X :size="14" aria-hidden="true" /></IconButton></span
></label>
</form>
>
<X :size="14" aria-hidden="true" />
</IconButton>
</span>
</label>
<template #result>
<span aria-live="polite">
{{
t('group.keys.filters.result', {
shown: n(collection.items.length),
total: n(collection.pagination.total_items),
})
}}
</span>
<AppButton variant="link" size="inline" @click="resetFilters">
{{ t('group.keys.filters.reset') }}
</AppButton>
</template>
</CollectionFilterBar>
<EmptyState
v-if="collection.summary.total === 0"
:title="t('group.keys.emptyTitle')"
@@ -442,55 +501,43 @@ onBeforeUnmount(() => {
:title="t('group.keys.emptyFilterTitle')"
:description="t('group.keys.emptyFilterDescription')"
><template #actions
><AppButton
variant="secondary"
size="compact"
@click="updateRoute({ page: 1, page_size: filters.page_size })"
>{{ t('group.keys.filters.reset') }}</AppButton
></template
><AppButton variant="secondary" size="compact" @click="resetFilters">{{
t('group.keys.filters.reset')
}}</AppButton></template
></EmptyState
>
<template v-else>
<div class="group-keys__desktop">
<DataTable :caption="t('group.keys.caption')" dense
><thead>
<tr>
<th scope="col">
<input
type="checkbox"
:checked="allVisibleSelected"
:disabled="batchBusy"
:aria-label="t('group.keys.selectVisible')"
@change="setAllVisible(($event.target as HTMLInputElement).checked)"
/>
</th>
<th scope="col">{{ t('group.keys.columns.key') }}</th>
<th scope="col">{{ t('group.keys.columns.status') }}</th>
<th scope="col">{{ t('group.keys.columns.weight') }}</th>
<th scope="col">{{ t('group.keys.columns.recent') }}</th>
<th scope="col">{{ t('group.keys.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<GroupKeyRow
v-for="item in collection.items"
:key="item.id"
:item="item"
:selected="selectedIds.has(item.id)"
:busy="rowBusy(item.id)"
@update:selected="setSelected(item.id, $event)"
@weight="mutateItem($event.item, 'weight', $event.value)"
@toggle="mutateItem($event, 'toggle')"
@restore="mutateItem($event, 'restore')"
@remove="deleteTarget = { ids: [$event.id], mask: $event.mask }"
/></tbody
></DataTable>
</div>
<div class="group-keys__mobile">
<GroupKeyMobileCard
v-for="item in collection.items"
<LedgerRecordList
:label="t('group.keys.caption')"
:row-count="collection.pagination.total_items + 1"
grid-class="group-key-record-grid"
>
<template #header>
<span class="group-keys__select-all" role="columnheader">
<label>
<span class="sr-only">{{ t('group.keys.selectVisible') }}</span>
<input
type="checkbox"
:checked="allVisibleSelected"
:disabled="batchBusy"
@change="setAllVisible(($event.target as HTMLInputElement).checked)"
/>
</label>
</span>
<span role="columnheader">{{ t('group.keys.columns.key') }}</span>
<span role="columnheader">{{ t('group.keys.columns.status') }}</span>
<span role="columnheader">{{ t('group.keys.columns.weight') }}</span>
<span role="columnheader">{{ t('group.keys.columns.recent') }}</span>
<span role="columnheader">{{ t('group.keys.columns.actions') }}</span>
</template>
<GroupKeyRecord
v-for="(item, index) in collection.items"
:key="item.id"
:item="item"
:row-index="
(collection.pagination.page - 1) * collection.pagination.page_size + index + 2
"
:selected="selectedIds.has(item.id)"
:busy="rowBusy(item.id)"
@update:selected="setSelected(item.id, $event)"
@@ -499,7 +546,7 @@ onBeforeUnmount(() => {
@restore="mutateItem($event, 'restore')"
@remove="deleteTarget = { ids: [$event.id], mask: $event.mask }"
/>
</div>
</LedgerRecordList>
<PaginationBar
:page="collection.pagination.page"
:page-size="collection.pagination.page_size"
@@ -570,58 +617,37 @@ onBeforeUnmount(() => {
color: var(--color-text);
padding: var(--space-3);
}
.group-keys__filters {
.group-key-record-grid {
--ledger-record-list-grid: 48px minmax(150px, 0.95fr) 116px minmax(118px, 0.72fr)
minmax(150px, 0.95fr) minmax(250px, 1.55fr);
--ledger-record-list-column-gap: 12px;
}
.group-keys__select-all {
display: flex;
justify-content: center;
}
.group-keys__select-all label {
display: grid;
max-width: 420px;
}
.group-keys__filters label {
display: grid;
gap: var(--space-1);
color: var(--color-text-faint);
font-size: var(--text-sm);
}
.group-keys__search {
position: relative;
display: block;
}
.group-keys__search > svg {
position: absolute;
top: 50%;
left: 10px;
transform: translateY(-50%);
color: var(--color-text-faint);
pointer-events: none;
}
.group-keys__search input {
width: 100%;
min-height: var(--control-md);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding: 0 40px 0 32px;
font: inherit;
}
.group-keys__search :deep(.icon-button) {
position: absolute;
top: 2px;
right: 3px;
}
.group-keys__mobile {
display: none;
gap: var(--space-3);
width: var(--touch-target);
height: var(--touch-target);
place-items: center;
cursor: pointer;
}
.group-keys__dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
@media (max-width: 767px) {
.group-keys__desktop {
display: none;
@media (max-width: 1120px) {
.group-key-record-grid {
--ledger-record-list-grid: 44px minmax(130px, 0.9fr) 108px minmax(108px, 0.7fr)
minmax(132px, 0.9fr) minmax(220px, 1.35fr);
--ledger-record-list-column-gap: 9px;
}
.group-keys__mobile {
display: grid;
}
@media (max-width: 860px) {
.group-key-record-grid {
--ledger-record-list-card-grid: minmax(0, 0.8fr) minmax(0, 1.2fr);
}
}
</style>
+153 -72
View File
@@ -14,10 +14,11 @@ import {
} from '@/app/resources/groups'
import { invalidateGroupSummary } from '@/app/resources/groups'
import { useUnsavedChanges } from '@/app/unsaved-changes'
import LedgerRecordList from '@/components/collection/LedgerRecordList.vue'
import AppButton from '@/components/ui/AppButton.vue'
import AppDialog from '@/components/ui/AppDialog.vue'
import AppDrawer from '@/components/ui/AppDrawer.vue'
import DataTable from '@/components/ui/DataTable.vue'
import IconButton from '@/components/ui/IconButton.vue'
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
import StickySaveBar from '@/components/ui/StickySaveBar.vue'
@@ -269,76 +270,92 @@ onBeforeUnmount(() => controller?.abort())
<span>{{ t('group.modelEditor.total', { count: draft.length }) }}</span>
<span v-if="unpriced">{{ t('group.modelEditor.unpriced', { count: unpriced }) }}</span>
</div>
<DataTable :caption="t('group.modelEditor.tableLabel')">
<thead>
<tr>
<th>{{ t('group.modelEditor.id') }}</th>
<th>{{ t('group.modelEditor.alias') }}</th>
<th>{{ t('group.modelEditor.pricing') }}</th>
<th>
<span class="sr-only">{{ t('group.modelEditor.actions') }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in draft"
:key="item.key"
:class="{ 'group-models__row--conflict': conflictIndexes.has(index) }"
>
<td>
<code>{{ item.id }}</code>
</td>
<td>
<div class="group-models__alias">
<label
><span class="sr-only">{{
t('group.modelEditor.aliasEnabledFor', { id: item.id })
}}</span
><input
type="checkbox"
:checked="item.alias_enabled"
:disabled="pending !== null"
@change="
updateRow(index, {
alias_enabled: ($event.target as HTMLInputElement).checked,
})
"
/></label>
<LedgerRecordList
:label="t('group.modelEditor.tableLabel')"
:row-count="draft.length + 1"
grid-class="group-model-record-grid"
>
<template #header>
<span role="columnheader">{{ t('group.modelEditor.id') }}</span>
<span role="columnheader">{{ t('group.modelEditor.alias') }}</span>
<span role="columnheader">{{ t('group.modelEditor.pricing') }}</span>
<span role="columnheader">
<span class="sr-only">{{ t('group.modelEditor.actions') }}</span>
</span>
</template>
<article
v-for="(item, index) in draft"
:key="item.key"
class="ledger-record-list__record group-model-record"
:class="{ 'group-model-record--conflict': conflictIndexes.has(index) }"
role="row"
:aria-rowindex="index + 2"
>
<div class="ledger-record-list__cell group-model-record__id" role="cell">
<span class="group-model-record__mobile-label">{{ t('group.modelEditor.id') }}</span>
<code>{{ item.id }}</code>
</div>
<div class="ledger-record-list__cell group-model-record__alias-cell" role="cell">
<span class="group-model-record__mobile-label">{{ t('group.modelEditor.alias') }}</span>
<div class="group-models__alias">
<label class="group-models__alias-toggle">
<span class="sr-only">
{{ t('group.modelEditor.aliasEnabledFor', { id: item.id }) }}
</span>
<input
:value="item.alias"
:disabled="pending !== null || !item.alias_enabled"
:placeholder="t('group.modelEditor.aliasPlaceholder')"
:aria-invalid="conflictIndexes.has(index) || undefined"
@input="updateRow(index, { alias: ($event.target as HTMLInputElement).value })"
type="checkbox"
:checked="item.alias_enabled"
:disabled="pending !== null"
@change="
updateRow(index, {
alias_enabled: ($event.target as HTMLInputElement).checked,
})
"
/>
</div>
<small v-if="conflictIndexes.has(index)" class="group-models__error">{{
conflictMessage(index)
}}</small>
<small v-else-if="emptyAliasIndexes.has(index)" class="group-models__error">{{
t('group.modelEditor.aliasRequired')
}}</small>
</td>
<td>
<span
:class="['group-models__pricing', `group-models__pricing--${item.pricing_status}`]"
>{{ t(`group.modelEditor.pricingStatus.${item.pricing_status}`) }}</span
>
</td>
<td>
<AppButton
variant="ghost"
size="compact"
:disabled="pending !== null"
:aria-label="t('group.modelEditor.removeFor', { id: item.id })"
@click="removeRow(index)"
><Trash2 :size="16" aria-hidden="true"
/></AppButton>
</td>
</tr>
</tbody>
</DataTable>
</label>
<input
type="text"
:value="item.alias"
:disabled="pending !== null || !item.alias_enabled"
:placeholder="t('group.modelEditor.aliasPlaceholder')"
:aria-invalid="conflictIndexes.has(index) || undefined"
@input="updateRow(index, { alias: ($event.target as HTMLInputElement).value })"
/>
</div>
<small v-if="conflictIndexes.has(index)" class="group-models__error">
{{ conflictMessage(index) }}
</small>
<small v-else-if="emptyAliasIndexes.has(index)" class="group-models__error">
{{ t('group.modelEditor.aliasRequired') }}
</small>
</div>
<div class="ledger-record-list__cell group-model-record__pricing-cell" role="cell">
<span class="group-model-record__mobile-label">{{
t('group.modelEditor.pricing')
}}</span>
<span
:class="['group-models__pricing', `group-models__pricing--${item.pricing_status}`]"
>
{{ t(`group.modelEditor.pricingStatus.${item.pricing_status}`) }}
</span>
</div>
<div class="ledger-record-list__cell group-model-record__actions" role="cell">
<IconButton
variant="ghost"
size="compact"
:disabled="pending !== null"
:label="t('group.modelEditor.removeFor', { id: item.id })"
@click="removeRow(index)"
>
<Trash2 :size="16" aria-hidden="true" />
</IconButton>
</div>
</article>
</LedgerRecordList>
<div class="group-models__add">
<input
v-model="manualID"
@@ -455,17 +472,37 @@ onBeforeUnmount(() => controller?.abort())
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.group-models :deep(.data-table) {
min-width: 620px;
.group-model-record-grid {
--ledger-record-list-grid: minmax(180px, 1.05fr) minmax(280px, 1.6fr) 140px 48px;
--ledger-record-list-column-gap: 16px;
}
.group-models__row--conflict {
.group-model-record--conflict {
background: var(--color-danger-bg);
}
.group-model-record__id,
.group-model-record__pricing-cell {
min-width: 0;
}
.group-model-record__id code {
overflow-wrap: anywhere;
}
.group-model-record__alias-cell {
display: grid;
gap: var(--space-1);
}
.group-models__alias {
display: flex;
align-items: center;
gap: var(--space-2);
}
.group-models__alias-toggle {
display: grid;
width: var(--touch-target);
height: var(--touch-target);
flex: 0 0 var(--touch-target);
place-items: center;
cursor: pointer;
}
.group-models__alias input[type='text'],
.group-models__add input {
width: 100%;
@@ -486,6 +523,16 @@ onBeforeUnmount(() => controller?.abort())
.group-models__pricing--unpriced {
color: var(--color-warning);
}
.group-model-record__actions {
display: flex;
justify-content: flex-end;
}
.group-model-record__mobile-label {
display: none;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
font-weight: 560;
}
.group-models__add {
justify-content: flex-start;
}
@@ -503,6 +550,40 @@ onBeforeUnmount(() => controller?.abort())
margin-top: var(--space-4);
justify-content: flex-end;
}
@media (max-width: 860px) {
.group-model-record-grid {
--ledger-record-list-card-grid: minmax(0, 0.7fr) minmax(0, 1.3fr);
}
.group-model-record {
padding-right: 58px;
}
.group-model-record__id,
.group-model-record__alias-cell {
grid-column: 1 / -1;
}
.group-model-record__id,
.group-model-record__alias-cell,
.group-model-record__pricing-cell {
display: grid;
align-content: start;
gap: 5px;
}
.group-model-record__alias-cell {
border-top: 1px solid var(--color-border-subtle);
padding-top: 11px;
}
.group-model-record__actions {
position: absolute;
top: 4px;
right: 4px;
}
.group-model-record__actions :deep(.icon-button) {
min-height: var(--touch-target);
}
.group-model-record__mobile-label {
display: inline;
}
}
@media (max-width: 640px) {
.group-models__header,
.group-models__add {
@@ -57,10 +57,7 @@ async function setDrawerOpen(open: boolean): Promise<void> {
<ArrowLeft :size="16" aria-hidden="true" />{{ t('modelPrices.back') }}
</RouterLink>
<PageHeader
id="model-prices-title"
:title="t('modelPrices.title')"
>
<PageHeader id="model-prices-title" :title="t('modelPrices.title')">
<template #actions>
<AppButton @click="addOverride">
<Plus :size="16" aria-hidden="true" />{{ t('modelPrices.add') }}
+2 -1
View File
@@ -92,7 +92,8 @@ export default {
'Edit routing filters and the RPM limit. Plaintext is masked unless explicitly revealed.',
close: 'Close AccessKey editor',
basicInformation: 'Basic information',
basicInformationDescription: 'The name identifies the client and is not used for authentication.',
basicInformationDescription:
'The name identifies the client and is not used for authentication.',
permissionScope: 'Permission scope',
permissionScopeDescription: 'Values within a dimension use OR; all three dimensions use AND.',
scopeLogic: 'Permission logic',
+1
View File
@@ -221,6 +221,7 @@ export default {
placeholder: 'Mask',
clear: 'Clear search',
reset: 'Reset filters',
result: 'Showing {shown} / {total} keys',
},
loading: 'Loading upstream keys…',
loadFailed: 'Unable to load upstream keys.',
+1
View File
@@ -220,6 +220,7 @@ export default {
placeholder: 'マスク',
clear: '検索をクリア',
reset: '条件をリセット',
result: 'キーを {shown} / {total} 件表示',
},
loading: 'アップストリームキーを読み込み中…',
loadFailed: 'アップストリームキーを読み込めません。',
+1
View File
@@ -213,6 +213,7 @@ export default {
placeholder: '掩码',
clear: '清空搜索',
reset: '重置条件',
result: '显示 {shown} / {total} 把密钥',
},
loading: '正在加载上游密钥…',
loadFailed: '无法加载上游密钥。',