|
1422 | 1422 | :id="getGroupSelectorPopoverId(groupSelectorKeyId)" |
1423 | 1423 | ref="dropdownRef" |
1424 | 1424 | :data-ui-skin="uiSkin" |
1425 | | - class="data-teleport-panel animate-in fade-in slide-in-from-top-2 fixed z-[100000020] flex max-h-[calc(100dvh-2rem)] min-w-0 w-[min(34rem,calc(100vw-2rem))] flex-col duration-200" |
| 1425 | + class="data-teleport-panel animate-in fade-in slide-in-from-top-2 fixed z-[100000020] flex max-h-[calc(100dvh-2rem)] min-w-0 w-[min(58rem,calc(100vw-2rem))] flex-col overflow-hidden duration-200" |
1426 | 1426 | style="pointer-events: auto !important;" |
1427 | 1427 | role="dialog" |
1428 | 1428 | :aria-label="t('keys.groupSelectorLabel')" |
|
1433 | 1433 | @click.stop |
1434 | 1434 | @keydown="handleGroupSelectorKeydown" |
1435 | 1435 | > |
1436 | | - <!-- Search box --> |
1437 | | - <div class="flex-shrink-0 border-b border-line p-2"> |
| 1436 | + <!-- Search and context header --> |
| 1437 | + <div class="flex-shrink-0 border-b border-line bg-surface/95 p-4 backdrop-blur-sm"> |
| 1438 | + <div class="mb-3 flex items-start justify-between gap-4"> |
| 1439 | + <div class="min-w-0"> |
| 1440 | + <p class="text-sm font-semibold text-content">{{ t('keys.groupSelectorLabel') }}</p> |
| 1441 | + <p class="mt-0.5 text-xs text-content-subtle"> |
| 1442 | + {{ activeGroupPlatform ? t(`admin.groups.platforms.${activeGroupPlatform}`) : t('admin.groups.platforms.all') }} |
| 1443 | + <span class="mx-1 text-content-faint">·</span> |
| 1444 | + {{ filteredGroupOptions.length }} |
| 1445 | + </p> |
| 1446 | + </div> |
| 1447 | + <span class="hidden rounded-full bg-brand-soft px-2.5 py-1 text-[11px] font-medium text-brand sm:inline-flex"> |
| 1448 | + {{ t('keys.searchGroup') }} |
| 1449 | + </span> |
| 1450 | + </div> |
1438 | 1451 | <div class="relative"> |
1439 | 1452 | <svg class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-content-subtle" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> |
1440 | 1453 | <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> |
|
1452 | 1465 | @click.stop |
1453 | 1466 | /> |
1454 | 1467 | </div> |
1455 | | - <div class="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5" role="tablist" :aria-label="t('keys.groupSelectorLabel')"> |
1456 | | - <button |
1457 | | - v-for="platform in groupPlatforms" |
1458 | | - :key="platform.value || 'all'" |
1459 | | - type="button" |
1460 | | - role="tab" |
1461 | | - :aria-selected="activeGroupPlatform === platform.value" |
1462 | | - :class="[ |
1463 | | - 'inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium transition-colors', |
1464 | | - activeGroupPlatform === platform.value |
1465 | | - ? 'bg-brand-soft text-brand' |
1466 | | - : 'text-content-muted hover:bg-surface-muted hover:text-content' |
1467 | | - ]" |
1468 | | - @click="activeGroupPlatform = platform.value" |
1469 | | - > |
1470 | | - <PlatformIcon v-if="platform.value" :platform="platform.value" size="xs" /> |
1471 | | - <span>{{ platform.label }}</span> |
1472 | | - <span class="rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] dark:bg-white/10">{{ platform.count }}</span> |
1473 | | - </button> |
1474 | | - </div> |
1475 | 1468 | </div> |
1476 | | - <!-- Group list --> |
1477 | | - <div |
1478 | | - :id="getGroupSelectorListId(groupSelectorKeyId)" |
1479 | | - class="min-h-0 flex-1 overflow-y-auto p-1.5" |
1480 | | - role="group" |
1481 | | - :aria-label="t('keys.groupLabel')" |
1482 | | - > |
1483 | | - <button |
1484 | | - v-for="option in filteredGroupOptions" |
1485 | | - :key="option.value ?? 'null'" |
1486 | | - type="button" |
1487 | | - :aria-pressed=" |
1488 | | - selectedKeyForGroup?.group_id === option.value || |
1489 | | - (!selectedKeyForGroup?.group_id && option.value === null) |
1490 | | - " |
1491 | | - @click="changeGroup(selectedKeyForGroup!, option.value)" |
1492 | | - :class="[ |
1493 | | - 'data-teleport-option', |
1494 | | - selectedKeyForGroup?.group_id === option.value || |
1495 | | - (!selectedKeyForGroup?.group_id && option.value === null) |
1496 | | - ? 'bg-brand-soft' |
1497 | | - : '' |
1498 | | - ]" |
1499 | | - :title="option.description || undefined" |
| 1469 | + <div class="flex min-h-0 flex-1 flex-col sm:flex-row"> |
| 1470 | + <!-- Platform navigation --> |
| 1471 | + <aside class="shrink-0 border-b border-line bg-surface-muted/45 p-2 sm:w-48 sm:border-b-0 sm:border-r"> |
| 1472 | + <div class="mb-2 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint"> |
| 1473 | + {{ t('admin.groups.platforms.all') }} |
| 1474 | + </div> |
| 1475 | + <div class="flex gap-1 overflow-x-auto sm:block sm:space-y-1 sm:overflow-visible" role="tablist" :aria-label="t('keys.groupSelectorLabel')"> |
| 1476 | + <button |
| 1477 | + v-for="platform in groupPlatforms" |
| 1478 | + :key="platform.value || 'all'" |
| 1479 | + type="button" |
| 1480 | + role="tab" |
| 1481 | + :aria-selected="activeGroupPlatform === platform.value" |
| 1482 | + :class="[ |
| 1483 | + 'group inline-flex min-h-10 shrink-0 items-center gap-2 rounded-xl px-3 text-left text-xs font-medium transition-all sm:flex sm:w-full', |
| 1484 | + activeGroupPlatform === platform.value |
| 1485 | + ? 'bg-brand text-white shadow-sm shadow-brand/20' |
| 1486 | + : 'text-content-muted hover:bg-surface hover:text-content' |
| 1487 | + ]" |
| 1488 | + @click="activeGroupPlatform = platform.value" |
| 1489 | + > |
| 1490 | + <span |
| 1491 | + :class="[ |
| 1492 | + 'grid h-6 w-6 shrink-0 place-items-center rounded-lg', |
| 1493 | + activeGroupPlatform === platform.value ? 'bg-white/15' : 'bg-black/5 dark:bg-white/10' |
| 1494 | + ]" |
| 1495 | + > |
| 1496 | + <PlatformIcon v-if="platform.value" :platform="platform.value" size="xs" /> |
| 1497 | + <Icon v-else name="grid" size="xs" /> |
| 1498 | + </span> |
| 1499 | + <span class="min-w-0 flex-1 truncate">{{ platform.label }}</span> |
| 1500 | + <span |
| 1501 | + :class="[ |
| 1502 | + 'rounded-full px-1.5 py-0.5 text-[10px] tabular-nums', |
| 1503 | + activeGroupPlatform === platform.value ? 'bg-white/15' : 'bg-black/5 dark:bg-white/10' |
| 1504 | + ]" |
| 1505 | + >{{ platform.count }}</span> |
| 1506 | + </button> |
| 1507 | + </div> |
| 1508 | + </aside> |
| 1509 | + <!-- Group list --> |
| 1510 | + <div |
| 1511 | + :id="getGroupSelectorListId(groupSelectorKeyId)" |
| 1512 | + class="min-h-0 flex-1 overflow-y-auto bg-surface p-3" |
| 1513 | + role="group" |
| 1514 | + :aria-label="t('keys.groupLabel')" |
1500 | 1515 | > |
1501 | | - <GroupOptionItem |
1502 | | - :name="option.label" |
1503 | | - :platform="option.platform" |
1504 | | - :scope="option.scope" |
1505 | | - :subscription-type="option.subscriptionType" |
1506 | | - :rate-multiplier="option.rate" |
1507 | | - :user-rate-multiplier="option.userRate" |
1508 | | - :effective-rate-multiplier="option.effectiveRate" |
1509 | | - :rate-multiplier-source="option.rateSource" |
1510 | | - :description="option.description" |
1511 | | - :api-key-badge-type="option.apiKeyBadgeType" |
1512 | | - :api-key-badge-text="option.apiKeyBadgeText" |
1513 | | - :selected=" |
1514 | | - selectedKeyForGroup?.group_id === option.value || |
1515 | | - (!selectedKeyForGroup?.group_id && option.value === null) |
1516 | | - " |
1517 | | - /> |
1518 | | - </button> |
1519 | | - <!-- Empty state when search has no results --> |
1520 | | - <div v-if="filteredGroupOptions.length === 0" class="py-4 text-center text-sm text-content-subtle"> |
1521 | | - {{ t('keys.noGroupFound') }} |
| 1516 | + <div v-if="filteredGroupOptions.length > 0" class="mb-2 flex items-center justify-between px-1"> |
| 1517 | + <span class="text-xs font-medium text-content-muted">{{ t('keys.groupLabel') }}</span> |
| 1518 | + <span class="text-[11px] tabular-nums text-content-faint">{{ filteredGroupOptions.length }}</span> |
| 1519 | + </div> |
| 1520 | + <div v-if="filteredGroupOptions.length > 0" class="grid gap-2 lg:grid-cols-2"> |
| 1521 | + <button |
| 1522 | + v-for="option in filteredGroupOptions" |
| 1523 | + :key="option.value ?? 'null'" |
| 1524 | + type="button" |
| 1525 | + :aria-pressed=" |
| 1526 | + selectedKeyForGroup?.group_id === option.value || |
| 1527 | + (!selectedKeyForGroup?.group_id && option.value === null) |
| 1528 | + " |
| 1529 | + @click="changeGroup(selectedKeyForGroup!, option.value)" |
| 1530 | + :class="[ |
| 1531 | + 'data-teleport-option min-w-0 rounded-xl border border-line bg-surface-raised p-0 text-left transition-all hover:-translate-y-px hover:border-brand/45 hover:shadow-sm', |
| 1532 | + selectedKeyForGroup?.group_id === option.value || |
| 1533 | + (!selectedKeyForGroup?.group_id && option.value === null) |
| 1534 | + ? 'border-brand/60 bg-brand-soft ring-1 ring-brand/20' |
| 1535 | + : '' |
| 1536 | + ]" |
| 1537 | + :title="option.description || undefined" |
| 1538 | + > |
| 1539 | + <GroupOptionItem |
| 1540 | + :name="option.label" |
| 1541 | + :platform="option.platform" |
| 1542 | + :scope="option.scope" |
| 1543 | + :subscription-type="option.subscriptionType" |
| 1544 | + :rate-multiplier="option.rate" |
| 1545 | + :user-rate-multiplier="option.userRate" |
| 1546 | + :effective-rate-multiplier="option.effectiveRate" |
| 1547 | + :rate-multiplier-source="option.rateSource" |
| 1548 | + :description="option.description" |
| 1549 | + :api-key-badge-type="option.apiKeyBadgeType" |
| 1550 | + :api-key-badge-text="option.apiKeyBadgeText" |
| 1551 | + :selected=" |
| 1552 | + selectedKeyForGroup?.group_id === option.value || |
| 1553 | + (!selectedKeyForGroup?.group_id && option.value === null) |
| 1554 | + " |
| 1555 | + /> |
| 1556 | + </button> |
| 1557 | + </div> |
| 1558 | + <!-- Empty state when search has no results --> |
| 1559 | + <div v-else class="flex min-h-56 flex-col items-center justify-center rounded-2xl border border-dashed border-line px-6 text-center"> |
| 1560 | + <span class="mb-3 grid h-10 w-10 place-items-center rounded-full bg-surface-muted text-content-faint"> |
| 1561 | + <Icon name="search" size="md" /> |
| 1562 | + </span> |
| 1563 | + <p class="text-sm font-medium text-content">{{ t('keys.noGroupFound') }}</p> |
| 1564 | + <p class="mt-1 text-xs text-content-subtle">{{ t('keys.searchGroup') }}</p> |
| 1565 | + </div> |
1522 | 1566 | </div> |
1523 | 1567 | </div> |
1524 | 1568 | </div> |
@@ -2041,7 +2085,7 @@ const groupOptions = computed<GroupOption[]>(() => |
2041 | 2085 | effectiveRate: group.effective_rate_multiplier ?? fallbackUserRate, |
2042 | 2086 | rateSource: group.effective_rate_multiplier_source ?? (fallbackUserRate != null ? 'user_group' : 'group_default'), |
2043 | 2087 | subscriptionType: group.subscription_type, |
2044 | | - platform: group.platform, |
| 2088 | + platform: group.platform.trim().toLowerCase() as GroupPlatform, |
2045 | 2089 | scope: group.scope, |
2046 | 2090 | apiKeyBadgeType: group.api_key_badge_type, |
2047 | 2091 | apiKeyBadgeText: group.api_key_badge_text |
@@ -2199,18 +2243,39 @@ const groupRoutingChanged = ( |
2199 | 2243 | // Group dropdown search |
2200 | 2244 | const groupSearchQuery = ref('') |
2201 | 2245 | const activeGroupPlatform = ref<GroupPlatform | null>(null) |
| 2246 | +
|
| 2247 | +// Keep the platform rail stable even when a platform currently has no available |
| 2248 | +// groups. This avoids the selector layout shifting as group visibility changes. |
| 2249 | +const canonicalGroupPlatforms: GroupPlatform[] = [ |
| 2250 | + 'anthropic', |
| 2251 | + 'openai', |
| 2252 | + 'gemini', |
| 2253 | + 'antigravity', |
| 2254 | + 'grok', |
| 2255 | + 'opencode', |
| 2256 | + 'kimi', |
| 2257 | + 'zhipu', |
| 2258 | + 'deepseek', |
| 2259 | + 'minimax', |
| 2260 | + 'qwen' |
| 2261 | +] |
| 2262 | +
|
2202 | 2263 | const groupPlatforms = computed(() => { |
2203 | | - const platforms = Array.from(new Set(groupOptions.value.map((option) => option.platform))) |
| 2264 | + const counts = new Map<GroupPlatform, number>() |
| 2265 | + for (const option of groupOptions.value) { |
| 2266 | + const platform = option.platform.trim().toLowerCase() as GroupPlatform |
| 2267 | + counts.set(platform, (counts.get(platform) ?? 0) + 1) |
| 2268 | + } |
2204 | 2269 | return [ |
2205 | 2270 | { |
2206 | 2271 | value: null as GroupPlatform | null, |
2207 | 2272 | label: t('admin.groups.platforms.all'), |
2208 | 2273 | count: groupOptions.value.length |
2209 | 2274 | }, |
2210 | | - ...platforms.map((platform) => ({ |
| 2275 | + ...canonicalGroupPlatforms.map((platform) => ({ |
2211 | 2276 | value: platform, |
2212 | 2277 | label: t(`admin.groups.platforms.${platform}`), |
2213 | | - count: groupOptions.value.filter((option) => option.platform === platform).length |
| 2278 | + count: counts.get(platform) ?? 0 |
2214 | 2279 | })) |
2215 | 2280 | ] |
2216 | 2281 | }) |
@@ -2408,7 +2473,8 @@ const updateGroupSelectorPosition = (): boolean => { |
2408 | 2473 | const dropdownHeight = Math.min(dropdownRef.value?.offsetHeight || 400, maxDropdownHeight) |
2409 | 2474 | const spaceBelow = window.innerHeight - rect.bottom - viewportPadding |
2410 | 2475 | const spaceAbove = rect.top - viewportPadding |
2411 | | - const dropdownWidth = Math.min(544, Math.max(0, window.innerWidth - viewportPadding * 2)) |
| 2476 | + // Keep the clamp in sync with the 58rem (928px) two-column selector. |
| 2477 | + const dropdownWidth = Math.min(928, Math.max(0, window.innerWidth - viewportPadding * 2)) |
2412 | 2478 | const maxLeft = Math.max(viewportPadding, window.innerWidth - dropdownWidth - viewportPadding) |
2413 | 2479 | const clampedLeft = Math.min(Math.max(rect.left, viewportPadding), maxLeft) |
2414 | 2480 | const preferredTop = spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove |
|
0 commit comments