fix(plugins): clarify release asset selection

Show release assets with filename tooltips, size tags, and a compact selection state. Remove duplicate release version labels.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-25 06:53:10 +08:00
co-authored by Codex
parent f2ccf6b42c
commit 00a1956df7
3 changed files with 136 additions and 23 deletions
@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { PluginReleaseAsset } from '@/api'
import { BasePopover, BaseScrollbar, BaseTag } from '@codex-proxy/ui'
import { ChevronDown, FileArchive } from '@lucide/vue'
import { useElementSize, useEventListener } from '@vueuse/core'
import { computed, nextTick, shallowRef, useId, useTemplateRef, watch } from 'vue'
import { formatPluginFileSize } from '../utils/model'
const props = defineProps<{ assets: PluginReleaseAsset[], disabled?: boolean }>()
const selected = defineModel<string>({ required: true })
const open = shallowRef(false)
const trigger = useTemplateRef<HTMLButtonElement>('trigger')
const panel = useTemplateRef<HTMLDivElement>('panel')
const panelId = useId()
const { width } = useElementSize(trigger, undefined, { box: 'border-box' })
const selectedAsset = computed(() => props.assets.find(asset => asset.name === selected.value))
async function close() {
open.value = false
await nextTick()
trigger.value?.focus()
}
function select(name: string) {
if (props.disabled)
return
selected.value = name
void close()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !open.value)
return
event.preventDefault()
event.stopPropagation()
void close()
}
useEventListener(panel, 'keydown', handleKeydown)
useEventListener(panel, 'keydown', (event) => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key))
return
const options = [...(panel.value?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])]
if (!options.length)
return
event.preventDefault()
event.stopPropagation()
const current = options.indexOf(event.target as HTMLButtonElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? options.length - 1 : (current + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length
options[next]?.focus()
})
watch(open, async (value) => {
if (!value)
return
await nextTick()
if (open.value)
(panel.value?.querySelector<HTMLButtonElement>('[aria-selected="true"]') ?? panel.value?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus()
})
watch(() => props.disabled, (value) => {
if (value)
open.value = false
})
</script>
<template>
<BasePopover v-model="open" placement="bottom-start" :disabled="disabled" class="w-full min-w-0">
<template #trigger>
<button
ref="trigger"
type="button"
:disabled="disabled"
:title="selectedAsset?.name"
:aria-label="selectedAsset ? `插件包:${selectedAsset.name}` : '选择插件包'"
aria-haspopup="listbox"
:aria-expanded="open"
:aria-controls="open ? panelId : undefined"
class="flex min-h-cp-control w-full min-w-0 items-center gap-3 rounded-cp border-0 px-3.5 py-2 text-left text-cp shadow-cp-input outline-none transition-[background-color,box-shadow] duration-160 motion-reduce:transition-none"
:class="disabled
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
: open
? 'cursor-pointer bg-(--cp-input-active-bg) text-cp-text shadow-cp-input-active'
: 'cursor-pointer bg-(--cp-input-bg) text-cp-text hover:bg-(--cp-input-hover-bg) hover:shadow-cp-input-hover focus-visible:bg-(--cp-input-active-bg) focus-visible:shadow-cp-input-active'"
@keydown="handleKeydown"
@keydown.down.prevent="open = !disabled"
>
<FileArchive v-if="selectedAsset" class="size-4 shrink-0 text-cp-text-tertiary" aria-hidden="true" />
<span class="min-w-0 flex-1 truncate text-cp-sm" :class="{ 'text-cp-text-quaternary': !selectedAsset }">
{{ selectedAsset?.name ?? '选择插件包' }}
</span>
<ChevronDown class="size-4 shrink-0 text-cp-text-tertiary transition-transform motion-reduce:transition-none" :class="{ 'rotate-180': open }" aria-hidden="true" />
</button>
</template>
<div :id="panelId" ref="panel" role="listbox" aria-label="插件包" aria-required="true" :style="{ width: `${width}px` }" class="max-w-[calc(100vw-2rem)] p-1.5">
<BaseScrollbar max-height="min(18rem, 45dvh)">
<div class="grid gap-0.5 p-0.5">
<button
v-for="asset in assets"
:key="asset.name"
type="button"
role="option"
tabindex="-1"
:title="asset.name"
:aria-selected="selected === asset.name"
:disabled="disabled"
class="flex w-full min-w-0 items-center gap-3 rounded-cp-sm border-0 px-3 py-2.5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-inset"
:class="disabled ? 'cursor-not-allowed opacity-55' : selected === asset.name ? 'cursor-pointer bg-cp-control-item-bg-active hover:bg-cp-control-item-bg-active-hover' : 'cursor-pointer bg-transparent hover:bg-cp-fill-tertiary'"
@click="select(asset.name)"
>
<span class="min-w-0 flex-1 truncate text-cp-sm" :class="selected === asset.name ? 'text-cp-primary-text' : 'text-cp-text'">{{ asset.name }}</span>
<BaseTag class="shrink-0 tabular-nums">
{{ formatPluginFileSize(asset.size) }}
</BaseTag>
</button>
</div>
</BaseScrollbar>
</div>
</BasePopover>
</template>
@@ -13,14 +13,15 @@ import type {
} from '@/api'
import { Github } from '@boxicons/vue'
import { BaseButton, BaseCheckbox, BaseForm, BaseFormItem, BaseInput, BaseModal, BaseSegmented, BaseSelect, BaseTag, toast } from '@codex-proxy/ui'
import { BaseButton, BaseCheckbox, BaseForm, BaseFormItem, BaseInput, BaseModal, BaseSegmented, BaseTag, toast } from '@codex-proxy/ui'
import { CheckCircle2, Download, FileArchive, Link, PackageOpen } from '@lucide/vue'
import { useEventListener, useSessionStorage } from '@vueuse/core'
import { isEqual } from 'es-toolkit'
import { computed, nextTick, reactive, shallowRef, watch } from 'vue'
import { formatDateTime } from '@/utils/date'
import { normalizePluginRepository, pluginInstallSelectionKey } from '../utils/model'
import { formatPluginFileSize, normalizePluginRepository, pluginInstallSelectionKey } from '../utils/model'
import PluginAssetPicker from './PluginAssetPicker.vue'
import PluginDownloadAuthentication from './PluginDownloadAuthentication.vue'
import PluginHelpPopover from './PluginHelpPopover.vue'
import PluginPermissionSummary from './PluginPermissionSummary.vue'
@@ -99,11 +100,7 @@ function changeMode(value: string) {
return
emit('changeMode', value)
}
const assetOptions = computed(() => (props.release?.assets ?? []).filter(asset => /\.(?:tar\.gz|tgz)$/i.test(asset.name)).map(asset => ({
label: `${asset.name} · ${formatFileSize(asset.size)}`,
value: asset.name,
description: asset.sha256 ? `SHA-256 ${asset.sha256}` : undefined,
})))
const releaseAssets = computed(() => (props.release?.assets ?? []).filter(asset => /\.(?:tar\.gz|tgz)$/i.test(asset.name)))
const digestPattern = /^[a-f0-9]{64}$/
const digestError = computed(() => {
const digest = connectionForm.value.sha256.trim().toLowerCase()
@@ -381,14 +378,6 @@ function githubQueryKey() {
})
}
function formatFileSize(bytes: number) {
if (bytes < 1024)
return `${bytes} B`
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KiB`
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
}
watch(open, (isOpen) => {
if (isOpen) {
reset()
@@ -422,7 +411,7 @@ watch(
() => props.release,
async (release) => {
// 唯一归档可直接解析,多个归档交给用户选择,平台兼容性仍由包检查器确认。
githubForm.asset = release && assetOptions.value.length === 1 ? assetOptions.value[0]!.value : ''
githubForm.asset = release && releaseAssets.value.length === 1 ? releaseAssets.value[0]!.name : ''
if (githubForm.asset) {
await nextTick()
await verifySelected()
@@ -458,7 +447,7 @@ watch(
<FileArchive class="size-5 shrink-0 text-cp-text-secondary" aria-hidden="true" />
<span class="grid min-w-0 flex-1 gap-1">
<span class="truncate text-cp-sm font-emphasis text-cp-text">{{ uploadFile?.name ?? '选择插件包' }}</span>
<span class="text-cp-xs text-cp-text-secondary">{{ uploadFile ? formatFileSize(uploadFile.size) : 'tar.gz / tgz,最大 32 MiB' }}</span>
<span class="text-cp-xs text-cp-text-secondary">{{ uploadFile ? formatPluginFileSize(uploadFile.size) : 'tar.gz / tgz,最大 32 MiB' }}</span>
</span>
<span class="shrink-0 text-cp-xs text-cp-primary-text">{{ uploadFile ? '更换' : '浏览文件' }}</span>
<input
@@ -531,16 +520,14 @@ watch(
<section v-if="!verified && mode === 'github' && release" class="mt-4 grid gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<strong class="min-w-0 wrap-break-word text-cp text-cp-text">{{ release.name || release.tag }}</strong>
<BaseTag type="primary">
{{ release.tag }}
</BaseTag>
<span class="text-cp-sm text-cp-text-secondary">已找到版本</span>
<strong class="min-w-0 wrap-break-word text-cp-sm font-emphasis text-cp-text">{{ release.tag }}</strong>
<BaseTag v-if="release.prerelease" type="warning">
预发行版
</BaseTag>
</div>
<BaseFormItem v-if="assetOptions.length > 1" label="插件包" required>
<BaseSelect v-model="githubForm.asset" :options="assetOptions" :disabled="busy" placeholder="选择插件包" class="w-full" />
<BaseFormItem v-if="releaseAssets.length > 1" label="插件包" required>
<PluginAssetPicker v-model="githubForm.asset" :assets="releaseAssets" :disabled="busy" />
</BaseFormItem>
<p v-else class="m-0 break-all text-cp-sm text-cp-text-secondary">
{{ githubForm.asset || '此版本没有 tar.gz 或 tgz 插件包' }}
@@ -64,6 +64,14 @@ export function normalizePluginRepository(value: string) {
return value.trim().replace(/^https:\/\/github\.com\//i, '').replace(/\/$/, '').replace(/\.git$/i, '').toLowerCase()
}
export function formatPluginFileSize(bytes: number) {
if (bytes < 1024)
return `${bytes} B`
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KiB`
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
}
export function pluginContributionForCapability(
metadata: PluginArtifactMetadata,
capability: string,