mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
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:
co-authored by
Claude Opus 5.5
parent
a3eb7ef302
commit
0da18e4a5e
@@ -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: '名称',
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user