feat(access-key): 优化权限联动编辑
This commit is contained in:
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>
|
||||
@@ -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}”.',
|
||||
|
||||
@@ -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}」を削除しました。',
|
||||
|
||||
@@ -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}”。',
|
||||
|
||||
Reference in new issue
Block a user