feat(access-key): 优化权限联动编辑

This commit is contained in:
tbphp committed 2026-08-01 13:00:42 +08:00
1 parent dee2ebd83c
commit bcda94bf21
7 files changed
+555 -103

No files matched your search

@@ -0,0 +1,276 @@
<script setup lang="ts">
import { Search, X } from '@lucide/vue'
import { computed, ref } from 'vue'
export type SearchableMultiSelectValue = string | number
export interface SearchableMultiSelectOption {
value: SearchableMultiSelectValue
label: string
description?: string
disabled?: boolean
}
const props = withDefaults(
defineProps<{
id: string
label: string
searchLabel: string
searchPlaceholder: string
emptyLabel: string
loadingLabel: string
selectedLabel: string
clearLabel: string
removeLabel: (label: string) => string
options: SearchableMultiSelectOption[]
modelValue: SearchableMultiSelectValue[]
disabled?: boolean
loading?: boolean
}>(),
{ disabled: false, loading: false },
)
const emit = defineEmits<{ 'update:modelValue': [values: SearchableMultiSelectValue[]] }>()
const query = ref('')
const selected = computed(() => new Set(props.modelValue))
const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase())
const filteredOptions = computed(() => {
if (!normalizedQuery.value) return props.options
return props.options.filter((option) =>
`${option.label} ${option.description ?? ''}`
.toLocaleLowerCase()
.includes(normalizedQuery.value),
)
})
const selectedOptions = computed(() =>
props.modelValue.map((value) => {
const option = props.options.find((candidate) => candidate.value === value)
return { value, label: option?.label ?? String(value) }
}),
)
const selectedCountText = computed(() =>
props.selectedLabel.replace('{count}', String(props.modelValue.length)),
)
function toggle(value: SearchableMultiSelectValue, checked: boolean): void {
if (props.disabled || props.loading) return
const next = checked
? [...new Set([...props.modelValue, value])]
: props.modelValue.filter((current) => current !== value)
emit('update:modelValue', next)
}
function remove(value: SearchableMultiSelectValue): void {
if (props.disabled || props.loading) return
emit(
'update:modelValue',
props.modelValue.filter((current) => current !== value),
)
}
function clear(): void {
if (props.disabled || props.loading) return
emit('update:modelValue', [])
}
</script>
<template>
<section class="searchable-multi-select" :aria-labelledby="`${id}-label`">
<div class="searchable-multi-select__head">
<span :id="`${id}-label`" class="searchable-multi-select__label">{{ label }}</span>
<span class="searchable-multi-select__count" aria-live="polite">
{{ selectedCountText }}
</span>
</div>
<label class="searchable-multi-select__search" :for="`${id}-search`">
<span class="sr-only">{{ searchLabel }}</span>
<Search :size="16" aria-hidden="true" />
<input
:id="`${id}-search`"
v-model="query"
type="search"
:placeholder="searchPlaceholder"
:disabled="disabled || loading"
/>
</label>
<div
v-if="modelValue.length"
class="searchable-multi-select__chips"
:aria-label="selectedCountText"
>
<span
v-for="option in selectedOptions"
:key="String(option.value)"
class="searchable-multi-select__chip"
>
<span>{{ option.label }}</span>
<button
type="button"
:aria-label="removeLabel(option.label)"
:disabled="disabled || loading"
@click="remove(option.value)"
>
<X :size="15" aria-hidden="true" />
</button>
</span>
<button
type="button"
class="searchable-multi-select__clear"
:disabled="disabled || loading"
@click="clear"
>
{{ clearLabel }}
</button>
</div>
<p v-if="loading" class="searchable-multi-select__feedback" role="status">{{ loadingLabel }}</p>
<p v-else-if="filteredOptions.length === 0" class="searchable-multi-select__feedback">
{{ emptyLabel }}
</p>
<div v-else class="searchable-multi-select__options" role="group" :aria-label="label">
<label
v-for="option in filteredOptions"
:key="String(option.value)"
class="searchable-multi-select__option"
:class="{ 'searchable-multi-select__option--disabled': option.disabled }"
>
<input
type="checkbox"
:checked="selected.has(option.value)"
:disabled="disabled || loading || Boolean(option.disabled)"
@change="toggle(option.value, ($event.target as HTMLInputElement).checked)"
/>
<span class="searchable-multi-select__option-content">
<span>{{ option.label }}</span>
<small v-if="option.description">{{ option.description }}</small>
</span>
</label>
</div>
</section>
</template>
<style scoped>
.searchable-multi-select {
display: grid;
gap: var(--space-2);
}
.searchable-multi-select__head,
.searchable-multi-select__chips,
.searchable-multi-select__search,
.searchable-multi-select__option {
display: flex;
align-items: center;
}
.searchable-multi-select__head {
justify-content: space-between;
gap: var(--space-2);
}
.searchable-multi-select__label {
font-weight: 700;
}
.searchable-multi-select__count,
.searchable-multi-select__feedback,
.searchable-multi-select__option small {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.searchable-multi-select__search {
min-height: var(--touch-target);
gap: var(--space-2);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface-sunken);
padding: 0 var(--space-3);
color: var(--color-text-muted);
}
.searchable-multi-select__search:focus-within {
border-color: var(--color-action);
box-shadow: var(--focus-ring);
}
.searchable-multi-select__search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--color-text);
font: inherit;
}
.searchable-multi-select__chips {
flex-wrap: wrap;
gap: var(--space-2);
}
.searchable-multi-select__chip {
display: inline-flex;
min-height: var(--touch-target);
align-items: center;
gap: var(--space-1);
border-radius: var(--radius-tag);
background: var(--color-tag);
padding-left: var(--space-2);
font-size: var(--text-sm);
}
.searchable-multi-select__chip button,
.searchable-multi-select__clear {
min-width: var(--touch-target);
min-height: var(--touch-target);
border: 0;
background: transparent;
color: var(--color-text-muted);
font: inherit;
cursor: pointer;
}
.searchable-multi-select__chip button {
display: inline-flex;
align-items: center;
justify-content: center;
}
.searchable-multi-select__clear {
color: var(--color-action);
text-decoration: underline;
}
.searchable-multi-select__options {
display: grid;
max-height: 252px;
overflow-y: auto;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-control);
}
.searchable-multi-select__option {
min-height: var(--touch-target);
gap: var(--space-2);
padding: 0 var(--space-3);
}
.searchable-multi-select__option + .searchable-multi-select__option {
border-top: 1px solid var(--color-border-subtle);
}
.searchable-multi-select__option input {
width: 20px;
height: 20px;
flex: none;
}
.searchable-multi-select__option-content {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.searchable-multi-select__option--disabled {
cursor: not-allowed;
opacity: 0.55;
}
.searchable-multi-select__feedback {
margin: 0;
min-height: var(--touch-target);
display: flex;
align-items: center;
padding: 0 var(--space-3);
border: 1px dashed var(--color-border-subtle);
border-radius: var(--radius-control);
}
.searchable-multi-select button:disabled,
.searchable-multi-select input:disabled {
cursor: not-allowed;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Trash2 } from '@lucide/vue'
import { onBeforeUnmount, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useApiClient } from '@/api/client-context'
@@ -16,22 +16,34 @@ const emit = defineEmits<{ deleted: [name: string] }>()
const client = useApiClient()
const { t } = useI18n()
const open = ref(false)
const typedName = ref('')
const nameInput = ref<HTMLInputElement>()
const pending = ref(false)
const failed = ref(false)
let controller: AbortController | undefined
const confirmed = computed(() => typedName.value === props.accessKey.name)
async function focusNameInput(): Promise<void> {
await nextTick()
await nextTick()
nameInput.value?.focus()
}
function setOpen(value: boolean): void {
if (!value && pending.value) return
if (!value) {
controller?.abort()
controller = undefined
failed.value = false
typedName.value = ''
}
open.value = value
if (value) void focusNameInput()
}
async function confirmDelete(): Promise<void> {
if (pending.value) return
if (!confirmed.value || pending.value) return
pending.value = true
failed.value = false
controller = new AbortController()
@@ -39,6 +51,7 @@ async function confirmDelete(): Promise<void> {
try {
await deleteAccessKey(client, props.accessKey.id, activeController.signal)
open.value = false
typedName.value = ''
emit('deleted', props.accessKey.name)
} catch (error: unknown) {
if (!(error instanceof RequestCancelledError)) failed.value = true
@@ -70,6 +83,18 @@ onBeforeUnmount(() => controller?.abort())
<InlineFeedback v-if="total === 1" tone="warning">
{{ t('accessKeys.delete.lastWarning') }}
</InlineFeedback>
<label class="access-key-delete__label" for="access-key-delete-name">{{
t('accessKeys.delete.typeName', { name: accessKey.name })
}}</label>
<input
id="access-key-delete-name"
ref="nameInput"
v-model="typedName"
type="text"
autocomplete="off"
spellcheck="false"
:disabled="pending"
/>
<InlineFeedback v-if="failed" tone="danger">{{
t('accessKeys.delete.failed')
}}</InlineFeedback>
@@ -81,6 +106,7 @@ onBeforeUnmount(() => controller?.abort())
class="access-key-delete__confirm"
variant="danger"
:busy="pending"
:disabled="!confirmed"
@click="confirmDelete"
>
{{ t('accessKeys.delete.confirm') }}
@@ -107,6 +133,20 @@ onBeforeUnmount(() => controller?.abort())
display: grid;
gap: var(--space-4);
}
.access-key-delete__label {
margin-bottom: calc(var(--space-3) * -1);
font-weight: 650;
}
input {
width: 100%;
min-height: var(--touch-target);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface-sunken);
color: var(--color-text);
padding: var(--space-2) var(--space-3);
font: inherit;
}
.access-key-delete__actions {
display: flex;
flex-wrap: wrap;
@@ -20,7 +20,11 @@ import { useUnsavedChanges } from '@/app/unsaved-changes'
import AppButton from '@/components/ui/AppButton.vue'
import AppDrawer from '@/components/ui/AppDrawer.vue'
import { accessKeyProtocolOptions, buildAccessKeyModelOptions } from './access-key-options'
import {
accessKeyProtocolOptions,
buildAccessKeyModelOptions,
buildAccessKeyProtocolCandidates,
} from './access-key-options'
import type { PendingAccessKeyCreateOperation } from './access-key-create-operation'
import type { PendingAccessKeyEditOperation } from './access-key-edit-operation'
import AccessKeyFormFields from './AccessKeyFormFields.vue'
@@ -101,8 +105,32 @@ const resultSecret = computed(() =>
result.value ? ephemeralSecret.read(`access-key:${result.value.id}`) : null,
)
const protocolOptions = computed(() => accessKeyProtocolOptions())
const selectedGroupIDs = computed(() =>
draft.value.scopeModes.groups === 'restricted' ? draft.value.filters.groups : [],
)
const supportedProtocolOptions = computed(() =>
buildAccessKeyProtocolCandidates(props.groups, selectedGroupIDs.value),
)
const modelOptions = computed(() =>
buildAccessKeyModelOptions(props.groups, draft.value.filters.models),
buildAccessKeyModelOptions(props.groups, draft.value.filters.models, selectedGroupIDs.value),
)
const catalogModelOptions = computed(() =>
buildAccessKeyModelOptions(props.groups, [], selectedGroupIDs.value),
)
const groupProtocolMismatch = computed(
() =>
props.groupCatalogState !== 'loading' &&
props.groupCatalogState !== 'error' &&
draft.value.scopeModes.groups === 'restricted' &&
draft.value.scopeModes.protocols === 'restricted' &&
!draft.value.filters.protocols.some((protocol) =>
supportedProtocolOptions.value.includes(protocol),
),
)
const modelMismatch = computed(
() =>
draft.value.scopeModes.models === 'restricted' &&
draft.value.filters.models.some((model) => !catalogModelOptions.value.includes(model)),
)
const dirty = computed(() => isAccessKeyDraftDirty(draft.value, base.value))
const unsavedDirty = computed(
@@ -124,7 +152,11 @@ const scopeValid = computed(() =>
groupCatalog: groupCatalog.value,
}),
)
const valid = computed(() => isAccessKeyDraftValid(draft.value, base.value, groupCatalog.value))
const valid = computed(
() =>
isAccessKeyDraftValid(draft.value, base.value, groupCatalog.value) &&
!groupProtocolMismatch.value,
)
const mutationFeedbackKey = computed(() => {
if (mutationState.value === 'idle') return ''
if (editReconciliation.value) {
@@ -137,6 +169,7 @@ const mutationFeedbackKey = computed(() => {
: 'accessKeys.drawer.saveIndeterminate'
})
const scopeFeedbackKey = computed(() => {
if (groupProtocolMismatch.value) return 'accessKeys.drawer.groupProtocolMismatch'
if (scopeValid.value) return ''
const effective = materializeAccessKeyFilters(draft.value.filters, draft.value.scopeModes)
if (
@@ -156,18 +189,27 @@ const scopeFeedbackKey = computed(() => {
return 'accessKeys.drawer.scopeIncomplete'
})
const groupOptions = computed(() => {
const baseGroupIDs = new Set(base.value?.filters.groups ?? [])
const options = props.groups.map((group) => ({
id: group.id,
label: group.name,
dangling: false,
label: `${group.name} · #${group.id}`,
description: group.enabled
? t('accessKeys.drawer.groupEnabled')
: t('accessKeys.drawer.groupDisabled'),
disabled: props.groupCatalogState === 'stale' && !baseGroupIDs.has(group.id),
}))
const known = new Set(options.map(({ id }) => id))
for (const id of base.value?.filters.groups ?? []) {
for (const id of draft.value.filters.groups) {
if (!known.has(id)) {
options.push({ id, label: t('accessKeys.drawer.unknownGroup', { id }), dangling: true })
options.push({
id,
label: t('accessKeys.drawer.unknownGroup', { id }),
description: t('accessKeys.drawer.groupDangling'),
disabled: false,
})
}
}
return options
return options.map(({ id, ...option }) => ({ value: id, ...option }))
})
const unsavedChanges = useUnsavedChanges(unsavedDirty, { blocked: closeBlocked })
@@ -257,18 +299,28 @@ watch(
{ immediate: true },
)
function toggleGroup(groupId: number, checked: boolean): void {
if (!canChangeScopeValue('groups', groupId, checked)) return
draft.value.filters.groups = checked
? [...new Set([...draft.value.filters.groups, groupId])]
: draft.value.filters.groups.filter((id) => id !== groupId)
function setGroups(groupIDs: number[]): void {
const current = new Set(draft.value.filters.groups)
const next = new Set(groupIDs)
for (const groupID of next) {
if (!current.has(groupID) && !canChangeScopeValue('groups', groupID, true)) return
}
for (const groupID of current) {
if (!next.has(groupID) && !canChangeScopeValue('groups', groupID, false)) return
}
draft.value.filters.groups = [...next]
}
function toggleProtocol(protocol: AccessProtocol, checked: boolean): void {
if (!canChangeScopeValue('protocols', protocol, checked)) return
draft.value.filters.protocols = checked
? [...new Set([...draft.value.filters.protocols, protocol])]
: draft.value.filters.protocols.filter((value) => value !== protocol)
function setProtocols(protocols: AccessProtocol[]): void {
const current = new Set(draft.value.filters.protocols)
const next = new Set(protocols)
for (const protocol of next) {
if (!current.has(protocol) && !canChangeScopeValue('protocols', protocol, true)) return
}
for (const protocol of current) {
if (!next.has(protocol) && !canChangeScopeValue('protocols', protocol, false)) return
}
draft.value.filters.protocols = [...next]
}
function addModel(): void {
@@ -284,9 +336,16 @@ function addModel(): void {
modelInput.value = ''
}
function removeModel(model: string): void {
if (!canChangeScopeValue('models', model, false)) return
draft.value.filters.models = draft.value.filters.models.filter((value) => value !== model)
function setModels(models: string[]): void {
const current = new Set(draft.value.filters.models)
const next = new Set(models.map((model) => model.trim()).filter(Boolean))
for (const model of next) {
if (!current.has(model) && !canChangeScopeValue('models', model, true)) return
}
for (const model of current) {
if (!next.has(model) && !canChangeScopeValue('models', model, false)) return
}
draft.value.filters.models = [...next]
}
function canChangeScopeValue(
@@ -677,14 +736,15 @@ onBeforeUnmount(clearLocalState)
:group-options="groupOptions"
:group-catalog-state="groupCatalogState"
:protocol-options="protocolOptions"
:supported-protocols="supportedProtocolOptions"
:model-options="modelOptions"
:base-group-ids="base?.filters.groups ?? []"
:disabled="formLocked"
:model-mismatch="modelMismatch"
@set-scope-mode="setScopeMode"
@toggle-group="toggleGroup"
@toggle-protocol="toggleProtocol"
@update:groups="setGroups"
@update:protocols="setProtocols"
@update:models="setModels"
@add-model="addModel"
@remove-model="removeModel"
/>
</template>
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { Plus, X } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import type { AccessKeyFiltersDto, AccessProtocol } from '@/api/control/types'
import AppButton from '@/components/ui/AppButton.vue'
import SearchableMultiSelect, {
type SearchableMultiSelectOption,
type SearchableMultiSelectValue,
} from '@/components/ui/SearchableMultiSelect.vue'
import type {
AccessKeyScopeDimension,
@@ -12,30 +16,25 @@ import type {
GroupCatalogState,
} from './access-key-scope'
interface GroupOption {
id: number
label: string
dangling: boolean
}
const props = defineProps<{
modes: AccessKeyScopeModes
filters: AccessKeyFiltersDto
groupOptions: GroupOption[]
groupOptions: SearchableMultiSelectOption[]
groupCatalogState: GroupCatalogState
protocolOptions: readonly AccessProtocol[]
supportedProtocols: readonly AccessProtocol[]
modelOptions: string[]
modelInput: string
baseGroupIds: number[]
disabled: boolean
modelMismatch: boolean
}>()
const emit = defineEmits<{
setScopeMode: [dimension: AccessKeyScopeDimension, mode: AccessKeyScopeMode]
toggleGroup: [groupID: number, checked: boolean]
toggleProtocol: [protocol: AccessProtocol, checked: boolean]
'update:groups': [groupIDs: number[]]
'update:protocols': [protocols: AccessProtocol[]]
'update:models': [models: string[]]
'update:modelInput': [value: string]
addModel: []
removeModel: [model: string]
}>()
const { t } = useI18n()
@@ -43,15 +42,6 @@ function catalogUnavailable(): boolean {
return props.groupCatalogState === 'loading' || props.groupCatalogState === 'error'
}
function groupDisabled(groupID: number): boolean {
return (
props.disabled ||
props.modes.groups !== 'restricted' ||
catalogUnavailable() ||
(props.groupCatalogState === 'stale' && !props.baseGroupIds.includes(groupID))
)
}
function optionDisabled(dimension: 'protocols' | 'models'): boolean {
return props.disabled || props.modes[dimension] !== 'restricted' || catalogUnavailable()
}
@@ -61,6 +51,36 @@ function requestScopeMode(dimension: AccessKeyScopeDimension, event: Event): voi
emit('setScopeMode', dimension, target.value as AccessKeyScopeMode)
target.value = props.modes[dimension]
}
function updateGroups(values: SearchableMultiSelectValue[]): void {
emit(
'update:groups',
values.filter((value): value is number => typeof value === 'number'),
)
}
function updateModels(values: SearchableMultiSelectValue[]): void {
emit(
'update:models',
values.filter((value): value is string => typeof value === 'string'),
)
}
function toggleProtocol(protocol: AccessProtocol, event: Event): void {
const checked = (event.target as HTMLInputElement).checked
const next = checked
? [...new Set([...props.filters.protocols, protocol])]
: props.filters.protocols.filter((value) => value !== protocol)
emit('update:protocols', next)
}
function protocolUnsupported(protocol: AccessProtocol): boolean {
return (
props.modes.groups === 'restricted' &&
props.filters.groups.length > 0 &&
!props.supportedProtocols.includes(protocol)
)
}
</script>
<template>
@@ -77,15 +97,22 @@ function requestScopeMode(dimension: AccessKeyScopeDimension, event: Event): voi
<option value="restricted">{{ t('accessKeys.drawer.scopeRestricted') }}</option>
</select>
</label>
<label v-for="group in groupOptions" :key="group.id" class="access-key-drawer__check">
<input
type="checkbox"
:checked="filters.groups.includes(group.id)"
:disabled="groupDisabled(group.id)"
@change="emit('toggleGroup', group.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ group.label }}</span>
</label>
<SearchableMultiSelect
id="access-key-groups"
:label="t('accessKeys.drawer.groupSelectorLabel')"
:search-label="t('accessKeys.drawer.searchGroups')"
:search-placeholder="t('accessKeys.drawer.searchGroupsPlaceholder')"
:empty-label="t('accessKeys.drawer.noGroupOptions')"
:loading-label="t('accessKeys.drawer.groupOptionsLoading')"
:selected-label="t('accessKeys.drawer.selectedCount', { count: '{count}' })"
:clear-label="t('accessKeys.drawer.clearSelected')"
:remove-label="(label) => t('accessKeys.drawer.removeSelection', { label })"
:options="groupOptions"
:model-value="filters.groups"
:disabled="disabled || modes.groups !== 'restricted' || catalogUnavailable()"
:loading="groupCatalogState === 'loading'"
@update:model-value="updateGroups"
/>
</fieldset>
<fieldset>
@@ -106,11 +133,14 @@ function requestScopeMode(dimension: AccessKeyScopeDimension, event: Event): voi
type="checkbox"
:checked="filters.protocols.includes(protocol)"
:disabled="optionDisabled('protocols')"
@change="emit('toggleProtocol', protocol, ($event.target as HTMLInputElement).checked)"
@change="toggleProtocol(protocol, $event)"
/>
<span class="access-key-drawer__check-content">
<span>{{ t(`common.protocols.${protocol}`) }}</span>
<small v-if="protocol === 'openai-responses'">{{
<small v-if="protocolUnsupported(protocol)" class="access-key-drawer__unsupported">{{
t('accessKeys.drawer.protocolUnsupported')
}}</small>
<small v-else-if="protocol === 'openai-responses'">{{
t('accessKeys.drawer.responsesAffinityHint')
}}</small>
</span>
@@ -131,20 +161,32 @@ function requestScopeMode(dimension: AccessKeyScopeDimension, event: Event): voi
</select>
</label>
<p>{{ t('accessKeys.drawer.modelsDescription') }}</p>
<SearchableMultiSelect
id="access-key-models"
:label="t('accessKeys.drawer.modelSelectorLabel')"
:search-label="t('accessKeys.drawer.searchModels')"
:search-placeholder="t('accessKeys.drawer.searchModelsPlaceholder')"
:empty-label="t('accessKeys.drawer.noModelOptions')"
:loading-label="t('accessKeys.drawer.groupOptionsLoading')"
:selected-label="t('accessKeys.drawer.selectedCount', { count: '{count}' })"
:clear-label="t('accessKeys.drawer.clearSelected')"
:remove-label="(label) => t('accessKeys.drawer.removeSelection', { label })"
:options="modelOptions.map((model) => ({ value: model, label: model }))"
:model-value="filters.models"
:disabled="optionDisabled('models')"
:loading="groupCatalogState === 'loading'"
@update:model-value="updateModels"
/>
<div class="access-key-drawer__model-entry">
<input
:value="modelInput"
type="text"
list="access-key-model-options"
autocomplete="off"
:placeholder="t('accessKeys.drawer.modelPlaceholder')"
:disabled="optionDisabled('models')"
@input="emit('update:modelInput', ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="emit('addModel')"
/>
<datalist id="access-key-model-options">
<option v-for="model in modelOptions" :key="model" :value="model" />
</datalist>
<AppButton
variant="secondary"
:disabled="optionDisabled('models') || !modelInput.trim()"
@@ -153,19 +195,9 @@ function requestScopeMode(dimension: AccessKeyScopeDimension, event: Event): voi
<Plus :size="16" aria-hidden="true" />{{ t('accessKeys.drawer.addModel') }}
</AppButton>
</div>
<div v-if="filters.models.length" class="access-key-drawer__models">
<span v-for="model in filters.models" :key="model" class="access-key-drawer__model">
<code>{{ model }}</code>
<button
type="button"
:aria-label="t('accessKeys.drawer.removeModel', { model })"
:disabled="catalogUnavailable() || disabled"
@click="emit('removeModel', model)"
>
<X :size="15" aria-hidden="true" />
</button>
</span>
</div>
<p v-if="modelMismatch" class="access-key-drawer__model-risk" role="status">
{{ t('accessKeys.drawer.modelRouteRisk') }}
</p>
</fieldset>
</template>
@@ -194,7 +226,7 @@ small {
input,
select {
width: 100%;
min-height: 44px;
min-height: var(--touch-target);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface-sunken);
@@ -204,7 +236,7 @@ select {
}
.access-key-drawer__check {
display: flex;
min-height: 44px;
min-height: var(--touch-target);
align-items: center;
gap: var(--space-2);
}
@@ -216,6 +248,16 @@ select {
display: grid;
gap: var(--space-1);
}
.access-key-drawer__unsupported,
.access-key-drawer__model-risk {
color: var(--color-warning);
}
.access-key-drawer__model-risk {
border: 1px solid var(--color-warning);
border-radius: var(--radius-control);
background: var(--color-warning-bg);
padding: var(--space-2) var(--space-3);
}
.access-key-drawer__model-entry {
display: flex;
flex-wrap: wrap;
@@ -225,29 +267,4 @@ select {
.access-key-drawer__model-entry input {
flex: 1 1 220px;
}
.access-key-drawer__models {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.access-key-drawer__model {
display: inline-flex;
min-height: 44px;
align-items: center;
gap: var(--space-1);
border-radius: var(--radius-tag);
background: var(--color-tag);
padding-left: var(--space-2);
}
.access-key-drawer__model button {
display: inline-flex;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
</style>
+20
View File
@@ -98,6 +98,25 @@ export default {
scopeRestricted: 'Restricted',
expandScopeConfirmation: 'Switching to All expands this AccessKey permission. Continue?',
unknownGroup: 'Unknown Group #{id}',
groupEnabled: 'Active',
groupDisabled: 'Disabled',
groupDangling: 'No longer in the current Group catalog',
groupSelectorLabel: 'Select Groups',
modelSelectorLabel: 'Select models',
searchGroups: 'Search Groups',
searchGroupsPlaceholder: 'Search by name or ID',
searchModels: 'Search models',
searchModelsPlaceholder: 'Search by model ID or alias',
noGroupOptions: 'No matching Groups.',
noModelOptions: 'No matching models.',
groupOptionsLoading: 'Loading Group catalog…',
selectedCount: '{count} selected',
clearSelected: 'Clear selected',
removeSelection: 'Remove {label}',
protocolUnsupported: 'Unsupported by the selected Groups',
groupProtocolMismatch: 'The selected Groups and protocols have no routable overlap.',
modelRouteRisk:
'A selected model is not in the current Group catalog, so requests may fail to route.',
emptyMeansAll: 'No selection means all.',
modelsDescription: 'Choose known models or enter any valid model string.',
modelPlaceholder: 'Enter a model ID or alias',
@@ -132,6 +151,7 @@ export default {
close: 'Close AccessKey deletion confirmation',
lastWarning:
'This is the last AccessKey. Deleting it leaves data-plane clients without an issued credential.',
typeName: 'Type {name} to confirm',
confirm: 'Delete AccessKey',
failed: 'Unable to delete the AccessKey.',
deletedAnnouncement: 'Deleted AccessKey “{name}”.',
+20
View File
@@ -99,6 +99,25 @@ export default {
expandScopeConfirmation:
'「すべて」に切り替えると、この AccessKey の権限が拡大します。続行しますか?',
unknownGroup: '不明な Group #{id}',
groupEnabled: '有効',
groupDisabled: '無効',
groupDangling: '現在の Group カタログにはありません',
groupSelectorLabel: 'Group を選択',
modelSelectorLabel: 'モデルを選択',
searchGroups: 'Group を検索',
searchGroupsPlaceholder: '名前または ID で検索',
searchModels: 'モデルを検索',
searchModelsPlaceholder: 'モデル ID またはエイリアスで検索',
noGroupOptions: '一致する Group はありません。',
noModelOptions: '一致するモデルはありません。',
groupOptionsLoading: 'Group カタログを読み込み中…',
selectedCount: '{count} 件を選択',
clearSelected: '選択を解除',
removeSelection: '{label} を削除',
protocolUnsupported: '選択した Group はこのプロトコルをサポートしていません',
groupProtocolMismatch: '選択した Group とプロトコルにルーティング可能な重複がありません。',
modelRouteRisk:
'選択したモデルは現在の Group カタログにないため、リクエストのルーティングに失敗する可能性があります。',
emptyMeansAll: '未選択はすべてを意味します。',
modelsDescription: '既知のモデルを選ぶか、有効なモデル文字列を入力できます。',
modelPlaceholder: 'モデル ID またはエイリアスを入力',
@@ -132,6 +151,7 @@ export default {
close: 'AccessKey の削除確認を閉じる',
lastWarning:
'これは最後の AccessKey です。削除すると、データプレーンクライアントに発行済み資格情報がなくなります。',
typeName: '確認のため {name} と入力',
confirm: 'AccessKey を削除',
failed: 'AccessKey を削除できません。',
deletedAnnouncement: 'AccessKey「{name}」を削除しました。',
+19
View File
@@ -94,6 +94,24 @@ export default {
scopeRestricted: '限制',
expandScopeConfirmation: '切换为“全部”会扩大此 AccessKey 的权限,是否继续?',
unknownGroup: '未知 Group #{id}',
groupEnabled: '已启用',
groupDisabled: '已停用',
groupDangling: '已不在当前 Group 目录中',
groupSelectorLabel: '选择 Group',
modelSelectorLabel: '选择模型',
searchGroups: '搜索 Group',
searchGroupsPlaceholder: '按名称或 ID 搜索',
searchModels: '搜索模型',
searchModelsPlaceholder: '按模型 ID 或别名搜索',
noGroupOptions: '没有匹配的 Group。',
noModelOptions: '没有匹配的模型。',
groupOptionsLoading: '正在加载 Group 目录…',
selectedCount: '已选择 {count} 项',
clearSelected: '清除所选',
removeSelection: '移除 {label}',
protocolUnsupported: '所选 Group 不支持此协议',
groupProtocolMismatch: '所选 Group 与协议没有可路由的交集。',
modelRouteRisk: '所选模型不在当前 Group 的模型目录中,路由请求可能失败。',
emptyMeansAll: '不选择表示全部。',
modelsDescription: '可选择已知模型,也可输入任意有效模型字符串。',
modelPlaceholder: '输入模型 ID 或别名',
@@ -121,6 +139,7 @@ export default {
description: '删除“{name}”。此操作无法撤销。',
close: '关闭 AccessKey 删除确认',
lastWarning: '这是最后一个 AccessKey。删除后,数据面客户端将没有已签发凭据。',
typeName: '输入 {name} 以确认',
confirm: '删除 AccessKey',
failed: '无法删除 AccessKey。',
deletedAnnouncement: '已删除 AccessKey“{name}”。',