feat(admin): inline quick-adjust stepper for account priority

Replace the static priority number in the accounts table with a compact
stepper: hover reveals -/+ buttons, clicking the value allows typing
(Enter to save, Esc to cancel, arrow keys to nudge). Rapid clicks are
debounced into a single priority-only PUT, failures revert the value and
surface a toast, and the row is patched in place without a full reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
William Drake
2026-09-27 11:49:25 -04:00
co-authored by Claude Opus 5.5
parent a3eb7ef302
commit 0da18e4a5e
5 changed files with 290 additions and 2 deletions
@@ -0,0 +1,182 @@
<template>
<div
class="group/priority inline-flex h-7 items-center rounded-lg border transition-colors duration-150"
:class="[
editing || dirty
? 'border-primary-300 bg-primary-50/70 dark:border-primary-700 dark:bg-primary-900/20'
: 'border-transparent hover:border-gray-200 hover:bg-gray-50 dark:hover:border-dark-600 dark:hover:bg-dark-700/60'
]"
data-testid="account-priority-cell"
>
<button
type="button"
class="flex h-full w-6 items-center justify-center rounded-l-lg text-gray-400 opacity-0 transition hover:bg-gray-200/70 hover:text-primary-600 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-30 group-hover/priority:opacity-100 group-focus-within/priority:opacity-100 dark:text-dark-400 dark:hover:bg-dark-600 dark:hover:text-primary-400 [@media(hover:none)]:opacity-100"
:class="{ '!opacity-100': editing || dirty }"
:disabled="saving || draft <= MIN_PRIORITY"
:title="t('admin.accounts.priorityQuick.raise')"
:aria-label="t('admin.accounts.priorityQuick.raise')"
data-testid="account-priority-decrement"
@click="step(-1)"
>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round">
<path d="M2.5 6h7" />
</svg>
</button>
<input
v-if="editing"
ref="inputRef"
v-model="inputValue"
type="text"
inputmode="numeric"
class="h-full w-10 border-0 bg-transparent p-0 text-center font-mono text-sm tabular-nums text-gray-900 focus:outline-none focus:ring-0 dark:text-white"
:aria-label="t('admin.accounts.columns.priority')"
data-testid="account-priority-input"
@keydown.enter.prevent="commitInput"
@keydown.esc.prevent="cancelInput"
@keydown.up.prevent="nudgeInput(1)"
@keydown.down.prevent="nudgeInput(-1)"
@blur="commitInput"
/>
<button
v-else
type="button"
class="relative flex h-full min-w-[2rem] items-center justify-center px-1 font-mono text-sm tabular-nums transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
:class="dirty ? 'font-semibold text-primary-600 dark:text-primary-400' : 'text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'"
:disabled="saving"
:title="t('admin.accounts.priorityQuick.editHint')"
data-testid="account-priority-value"
@click="startEditing"
>
{{ draft }}
<span
v-if="saving"
class="absolute -right-0.5 top-1 h-1.5 w-1.5 animate-pulse rounded-full bg-primary-500"
data-testid="account-priority-saving"
/>
</button>
<button
type="button"
class="flex h-full w-6 items-center justify-center rounded-r-lg text-gray-400 opacity-0 transition hover:bg-gray-200/70 hover:text-primary-600 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-30 group-hover/priority:opacity-100 group-focus-within/priority:opacity-100 dark:text-dark-400 dark:hover:bg-dark-600 dark:hover:text-primary-400 [@media(hover:none)]:opacity-100"
:class="{ '!opacity-100': editing || dirty }"
:disabled="saving || draft >= MAX_PRIORITY"
:title="t('admin.accounts.priorityQuick.lower')"
:aria-label="t('admin.accounts.priorityQuick.lower')"
data-testid="account-priority-increment"
@click="step(1)"
>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round">
<path d="M2.5 6h7M6 2.5v7" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { update as updateAccount } from '@/api/admin/accounts'
import { extractApiErrorMessage } from '@/utils/apiError'
import type { Account } from '@/types'
const MIN_PRIORITY = 1
const MAX_PRIORITY = 9999
// 连续点击时合并为一次保存,避免每次 +/- 都打一次接口
const SAVE_DEBOUNCE_MS = 450
const props = defineProps<{ account: Account }>()
const emit = defineEmits<{
(e: 'updated', account: Account): void
(e: 'error', message: string): void
}>()
const { t } = useI18n()
const draft = ref(props.account.priority)
const saving = ref(false)
const editing = ref(false)
const inputValue = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
let saveTimer: ReturnType<typeof setTimeout> | null = null
const dirty = computed(() => draft.value !== props.account.priority)
watch(
() => props.account.priority,
(next) => {
if (!saving.value && !saveTimer && !editing.value) draft.value = next
}
)
const clamp = (value: number) => Math.min(MAX_PRIORITY, Math.max(MIN_PRIORITY, Math.round(value)))
const clearTimer = () => {
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
}
const save = async () => {
clearTimer()
const target = draft.value
if (target === props.account.priority) return
saving.value = true
try {
const updated = await updateAccount(props.account.id, { priority: target })
emit('updated', updated)
} catch (error) {
draft.value = props.account.priority
emit('error', extractApiErrorMessage(error, t('admin.accounts.priorityQuick.failed')))
} finally {
saving.value = false
}
}
const scheduleSave = () => {
clearTimer()
saveTimer = setTimeout(() => void save(), SAVE_DEBOUNCE_MS)
}
const step = (delta: number) => {
if (saving.value) return
const next = clamp(draft.value + delta)
if (next === draft.value) return
draft.value = next
scheduleSave()
}
const startEditing = async () => {
if (saving.value) return
clearTimer()
inputValue.value = String(draft.value)
editing.value = true
await nextTick()
inputRef.value?.focus()
inputRef.value?.select()
}
const nudgeInput = (delta: number) => {
const current = Number.parseInt(inputValue.value, 10)
inputValue.value = String(clamp((Number.isFinite(current) ? current : draft.value) + delta))
}
const commitInput = () => {
if (!editing.value) return
editing.value = false
const parsed = Number.parseInt(inputValue.value.trim(), 10)
if (Number.isFinite(parsed)) draft.value = clamp(parsed)
void save()
}
const cancelInput = () => {
editing.value = false
if (!saving.value) draft.value = props.account.priority
}
onBeforeUnmount(() => {
// 卸载前把还没发出的改动落库,避免翻页/刷新时丢失
if (saveTimer) void save()
})
</script>
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AccountPriorityCell from '../AccountPriorityCell.vue'
import type { Account } from '@/types'
import { update } from '@/api/admin/accounts'
vi.mock('@/api/admin/accounts', () => ({ update: vi.fn() }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
const account = (overrides: Partial<Account> = {}) => ({
id: 7, name: 'Claude 1', platform: 'anthropic', type: 'oauth', priority: 3,
...overrides,
}) as Account
const mountCell = (value = account()) => mount(AccountPriorityCell, { props: { account: value } })
beforeEach(() => {
vi.useFakeTimers()
vi.mocked(update).mockReset().mockImplementation(async (id, req) => account({ id, priority: req.priority }))
})
afterEach(() => {
vi.useRealTimers()
})
describe('AccountPriorityCell', () => {
it('batches rapid +/- clicks into a single priority-only update', async () => {
const wrapper = mountCell()
await wrapper.get('[data-testid="account-priority-increment"]').trigger('click')
await wrapper.get('[data-testid="account-priority-increment"]').trigger('click')
await wrapper.get('[data-testid="account-priority-decrement"]').trigger('click')
expect(wrapper.get('[data-testid="account-priority-value"]').text()).toBe('4')
expect(update).not.toHaveBeenCalled()
await vi.runAllTimersAsync()
await flushPromises()
expect(update).toHaveBeenCalledTimes(1)
expect(update).toHaveBeenCalledWith(7, { priority: 4 })
expect(wrapper.emitted('updated')?.[0]?.[0]).toMatchObject({ id: 7, priority: 4 })
})
it('does not go below 1', async () => {
const wrapper = mountCell(account({ priority: 1 }))
const dec = wrapper.get('[data-testid="account-priority-decrement"]')
expect(dec.attributes('disabled')).toBeDefined()
await dec.trigger('click')
await vi.runAllTimersAsync()
expect(update).not.toHaveBeenCalled()
})
it('saves a typed value on Enter and ignores unchanged input', async () => {
const wrapper = mountCell()
await wrapper.get('[data-testid="account-priority-value"]').trigger('click')
const input = wrapper.get('[data-testid="account-priority-input"]')
await input.setValue('12')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(update).toHaveBeenCalledWith(7, { priority: 12 })
vi.mocked(update).mockClear()
await wrapper.setProps({ account: account({ priority: 12 }) })
await wrapper.get('[data-testid="account-priority-value"]').trigger('click')
await wrapper.get('[data-testid="account-priority-input"]').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(update).not.toHaveBeenCalled()
})
it('Escape cancels typing without saving', async () => {
const wrapper = mountCell()
await wrapper.get('[data-testid="account-priority-value"]').trigger('click')
const input = wrapper.get('[data-testid="account-priority-input"]')
await input.setValue('50')
await input.trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(update).not.toHaveBeenCalled()
expect(wrapper.get('[data-testid="account-priority-value"]').text()).toBe('3')
})
it('reverts and emits an error when the update fails', async () => {
vi.mocked(update).mockRejectedValueOnce(new Error('boom'))
const wrapper = mountCell()
await wrapper.get('[data-testid="account-priority-increment"]').trigger('click')
await vi.runAllTimersAsync()
await flushPromises()
expect(wrapper.get('[data-testid="account-priority-value"]').text()).toBe('3')
expect(wrapper.emitted('error')).toHaveLength(1)
expect(wrapper.emitted('updated')).toBeUndefined()
})
})
@@ -96,6 +96,12 @@ export default {
schedulableEnabled: 'Scheduling enabled',
schedulableDisabled: 'Scheduling disabled',
failedToToggleSchedulable: 'Failed to toggle scheduling status',
priorityQuick: {
raise: 'Raise priority (value -1)',
lower: 'Lower priority (value +1)',
editHint: 'Click to type a value; lower is used first',
failed: 'Failed to update priority'
},
groupCountTotal: '{count} groups total',
platforms: {
anthropic: 'Anthropic',
@@ -94,6 +94,12 @@ export default {
schedulableEnabled: '调度已开启',
schedulableDisabled: '调度已关闭',
failedToToggleSchedulable: '切换调度状态失败',
priorityQuick: {
raise: '提高优先级(数值 -1)',
lower: '降低优先级(数值 +1)',
editHint: '点击直接输入;数值越小越优先',
failed: '更新优先级失败'
},
groupCountTotal: '共 {count} 个分组',
columns: {
name: '名称',
+7 -2
View File
@@ -378,8 +378,12 @@
@probe="handleProbeUpstreamBilling(row)"
/>
</template>
<template #cell-priority="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
<template #cell-priority="{ row }">
<AccountPriorityCell
:account="row"
@updated="handleAccountUpdated"
@error="(message: string) => appStore.showError(message)"
/>
</template>
<template #header-scheduler_score="{ column }">
<div class="flex items-center">
@@ -520,6 +524,7 @@ import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vu
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
import UpstreamBillingRateCell from '@/components/account/UpstreamBillingRateCell.vue'
import AccountPriorityCell from '@/components/account/AccountPriorityCell.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import Icon from '@/components/icons/Icon.vue'
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'