mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user