mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
feat: 全栈功能增强 - 扩展 provider/pool 管理、完善调度与数据层、重构前端 Pool 页面
后端: - 扩展 pool_admin payloads 和 provider query models,增强 endpoint key 管理 - 完善 scheduler-core 候选排序与请求候选逻辑 - 增强 usage-runtime 写入、provider-transport 网络层与 OAuth 刷新 - 改进 AI pipeline 响应转换与流式处理 - 扩展 global_models/provider_catalog 数据层查询能力 - 增强 video-tasks-core 多 provider 支持 - 新增大量集成测试覆盖 pool/keys/provider_query/frontdoor 前端: - 重构 PoolManagement 页面,拆分状态管理/对话框逻辑到独立模块 - 新增 poolAdvancedDialog/poolSchedulingDialog/poolManagementState/poolMobilePresentation 工具函数及测试 - 改进 Dialog 组件与 provider tabs 显示 部署: - 更新 Rust CI workflow 和 Dockerfile 构建配置 Closes #275 Co-authored-by: AAEE86 <[email protected]>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPoolCooldownFieldLayout,
|
||||
buildPoolHealthToggleCards,
|
||||
buildPoolCostFieldLayout,
|
||||
buildPoolSecondarySectionLayout,
|
||||
} from '@/features/pool/utils/poolAdvancedDialog'
|
||||
|
||||
describe('poolAdvancedDialog', () => {
|
||||
it('returns health toggle cards in the desktop display order', () => {
|
||||
expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([
|
||||
'health_policy_enabled',
|
||||
'probing_enabled',
|
||||
'auto_remove_banned_keys',
|
||||
])
|
||||
})
|
||||
|
||||
it('provides tooltip copy for every desktop health toggle card', () => {
|
||||
expect(buildPoolHealthToggleCards()).toEqual([
|
||||
{
|
||||
key: 'health_policy_enabled',
|
||||
label: '健康策略',
|
||||
description: '按上游错误自动冷却并跳过异常账号。',
|
||||
},
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '主动探测',
|
||||
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
|
||||
},
|
||||
{
|
||||
key: 'auto_remove_banned_keys',
|
||||
label: '异常自动清除',
|
||||
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the four cooldown-related fields in one desktop row order', () => {
|
||||
expect(buildPoolCooldownFieldLayout()).toEqual({
|
||||
fields: [
|
||||
'rate_limit_cooldown_seconds',
|
||||
'overload_cooldown_seconds',
|
||||
'sticky_session_ttl_seconds',
|
||||
'global_priority',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-4',
|
||||
})
|
||||
})
|
||||
|
||||
it('stacks batch and cost sections as full-width rows on desktop', () => {
|
||||
expect(buildPoolSecondarySectionLayout()).toEqual({
|
||||
wrapperClass: 'space-y-4',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the three cost fields in one desktop row order', () => {
|
||||
expect(buildPoolCostFieldLayout()).toEqual({
|
||||
fields: [
|
||||
'cost_window_seconds',
|
||||
'cost_limit_per_key_tokens',
|
||||
'cost_soft_threshold_percent',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-3',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPoolManagementQueryPatch,
|
||||
readPoolManagementViewState,
|
||||
writePoolManagementViewState,
|
||||
} from '@/features/pool/utils/poolManagementState'
|
||||
|
||||
function createMemoryStorage() {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value)
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('poolManagementState', () => {
|
||||
let storage: ReturnType<typeof createMemoryStorage>
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMemoryStorage()
|
||||
})
|
||||
|
||||
it('restores provider, filters and paging from query first', () => {
|
||||
writePoolManagementViewState(
|
||||
{
|
||||
providerId: 'provider-a',
|
||||
search: 'stored search',
|
||||
status: 'cooldown',
|
||||
page: 5,
|
||||
pageSize: 20,
|
||||
},
|
||||
storage,
|
||||
)
|
||||
|
||||
const state = readPoolManagementViewState(
|
||||
{
|
||||
providerId: 'provider-b',
|
||||
search: 'query search',
|
||||
status: 'inactive',
|
||||
page: '3',
|
||||
pageSize: '100',
|
||||
},
|
||||
storage,
|
||||
)
|
||||
|
||||
expect(state).toEqual({
|
||||
providerId: 'provider-b',
|
||||
search: 'query search',
|
||||
status: 'inactive',
|
||||
page: 3,
|
||||
pageSize: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to storage when query is missing', () => {
|
||||
writePoolManagementViewState(
|
||||
{
|
||||
providerId: 'provider-c',
|
||||
search: 'stored only',
|
||||
status: 'active',
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
},
|
||||
storage,
|
||||
)
|
||||
|
||||
const state = readPoolManagementViewState({}, storage)
|
||||
|
||||
expect(state).toEqual({
|
||||
providerId: 'provider-c',
|
||||
search: 'stored only',
|
||||
status: 'active',
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits defaults when building query patch', () => {
|
||||
expect(
|
||||
buildPoolManagementQueryPatch({
|
||||
providerId: 'provider-d',
|
||||
search: ' ',
|
||||
status: 'all',
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}),
|
||||
).toEqual({
|
||||
providerId: 'provider-d',
|
||||
search: undefined,
|
||||
status: undefined,
|
||||
page: undefined,
|
||||
pageSize: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPoolMobileTagItems,
|
||||
splitPoolMobileActions,
|
||||
} from '@/features/pool/utils/poolMobilePresentation'
|
||||
|
||||
describe('poolMobilePresentation', () => {
|
||||
it('prioritizes critical mobile tags before secondary identity tags', () => {
|
||||
expect(
|
||||
buildPoolMobileTagItems({
|
||||
priorityLabel: 'P40',
|
||||
authLabel: 'OAuth',
|
||||
oauthStatusLabel: 'Token 过期',
|
||||
oauthStatusTone: 'danger',
|
||||
accountStatusLabel: '账号停用',
|
||||
accountStatusTone: 'danger',
|
||||
planLabel: 'Team',
|
||||
orgLabel: 'Org A',
|
||||
proxyLabel: '独立代理',
|
||||
}).map(item => item.label),
|
||||
).toEqual([
|
||||
'账号停用',
|
||||
'Token 过期',
|
||||
'P40',
|
||||
'OAuth',
|
||||
'Team',
|
||||
'Org A',
|
||||
'独立代理',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps all available mobile actions inline without overflow grouping', () => {
|
||||
expect(
|
||||
splitPoolMobileActions({
|
||||
canRefreshToken: true,
|
||||
canClearCooldown: true,
|
||||
canRecoverHealth: true,
|
||||
canDownloadOrCopy: true,
|
||||
hasProxy: true,
|
||||
}),
|
||||
).toEqual({
|
||||
primary: [
|
||||
'copy_or_download',
|
||||
'refresh_token',
|
||||
'clear_cooldown',
|
||||
'recover_health',
|
||||
'permissions',
|
||||
'proxy',
|
||||
'edit',
|
||||
'toggle',
|
||||
'delete',
|
||||
],
|
||||
overflow: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
|
||||
|
||||
interface TestPresetItem {
|
||||
preset: string
|
||||
mutexGroup: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function buildItems(): TestPresetItem[] {
|
||||
return [
|
||||
{ preset: 'cache_affinity', mutexGroup: 'distribution_mode', enabled: false },
|
||||
{ preset: 'lru', mutexGroup: 'distribution_mode', enabled: true },
|
||||
{ preset: 'single_account', mutexGroup: 'distribution_mode', enabled: false },
|
||||
{ preset: 'load_balance', mutexGroup: 'distribution_mode', enabled: false },
|
||||
{ preset: 'recent_refresh', mutexGroup: null, enabled: true },
|
||||
{ preset: 'quota_balanced', mutexGroup: null, enabled: false },
|
||||
{ preset: 'priority_first', mutexGroup: null, enabled: true },
|
||||
]
|
||||
}
|
||||
|
||||
describe('poolSchedulingDialog', () => {
|
||||
it('moves only strategy items upward without disturbing distribution presets', () => {
|
||||
const moved = moveStrategyItem(buildItems(), 6, -1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual([
|
||||
'cache_affinity',
|
||||
'lru',
|
||||
'single_account',
|
||||
'load_balance',
|
||||
'recent_refresh',
|
||||
'priority_first',
|
||||
'quota_balanced',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the original order when a strategy item is already at the top boundary', () => {
|
||||
const original = buildItems()
|
||||
const moved = moveStrategyItem(original, 4, -1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
|
||||
})
|
||||
|
||||
it('moves a strategy item downward within the strategy group', () => {
|
||||
const moved = moveStrategyItem(buildItems(), 4, 1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual([
|
||||
'cache_affinity',
|
||||
'lru',
|
||||
'single_account',
|
||||
'load_balance',
|
||||
'quota_balanced',
|
||||
'recent_refresh',
|
||||
'priority_first',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the original order when a strategy item is already at the bottom boundary', () => {
|
||||
const original = buildItems()
|
||||
const moved = moveStrategyItem(original, 6, 1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
|
||||
})
|
||||
|
||||
it('keeps the original order when the target item is not a strategy preset', () => {
|
||||
const original = buildItems()
|
||||
const moved = moveStrategyItem(original, 1, 1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
export type PoolHealthToggleKey =
|
||||
| 'health_policy_enabled'
|
||||
| 'probing_enabled'
|
||||
| 'auto_remove_banned_keys'
|
||||
|
||||
export interface PoolHealthToggleCard {
|
||||
key: PoolHealthToggleKey
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PoolCooldownFieldLayout {
|
||||
fields: string[]
|
||||
desktopColumnsClass: string
|
||||
}
|
||||
|
||||
export interface PoolSecondarySectionLayout {
|
||||
wrapperClass: string
|
||||
}
|
||||
|
||||
export interface PoolCostFieldLayout {
|
||||
fields: string[]
|
||||
desktopColumnsClass: string
|
||||
}
|
||||
|
||||
export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
|
||||
return [
|
||||
{
|
||||
key: 'health_policy_enabled',
|
||||
label: '健康策略',
|
||||
description: '按上游错误自动冷却并跳过异常账号。',
|
||||
},
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '主动探测',
|
||||
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
|
||||
},
|
||||
{
|
||||
key: 'auto_remove_banned_keys',
|
||||
label: '异常自动清除',
|
||||
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildPoolCooldownFieldLayout(): PoolCooldownFieldLayout {
|
||||
return {
|
||||
fields: [
|
||||
'rate_limit_cooldown_seconds',
|
||||
'overload_cooldown_seconds',
|
||||
'sticky_session_ttl_seconds',
|
||||
'global_priority',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-4',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPoolSecondarySectionLayout(): PoolSecondarySectionLayout {
|
||||
return {
|
||||
wrapperClass: 'space-y-4',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPoolCostFieldLayout(): PoolCostFieldLayout {
|
||||
return {
|
||||
fields: [
|
||||
'cost_window_seconds',
|
||||
'cost_limit_per_key_tokens',
|
||||
'cost_soft_threshold_percent',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-3',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export type PoolManagementStatus = 'all' | 'active' | 'cooldown' | 'inactive'
|
||||
|
||||
export interface PoolManagementViewState {
|
||||
providerId: string | null
|
||||
search: string
|
||||
status: PoolManagementStatus
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface PoolManagementStateSource {
|
||||
providerId?: string
|
||||
search?: string
|
||||
status?: string
|
||||
page?: string
|
||||
pageSize?: string
|
||||
}
|
||||
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null
|
||||
setItem(key: string, value: string): void
|
||||
removeItem(key: string): void
|
||||
}
|
||||
|
||||
export const POOL_MANAGEMENT_VIEW_STORAGE_KEY = 'aether:pool-management:view-state'
|
||||
|
||||
export const DEFAULT_POOL_MANAGEMENT_VIEW_STATE: PoolManagementViewState = {
|
||||
providerId: null,
|
||||
search: '',
|
||||
status: 'all',
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}
|
||||
|
||||
function normalizeProviderId(value: unknown): string | null {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function normalizeSearch(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): PoolManagementStatus {
|
||||
if (value === 'active' || value === 'cooldown' || value === 'inactive') {
|
||||
return value
|
||||
}
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
||||
const normalized = Number.parseInt(String(value ?? ''), 10)
|
||||
if (!Number.isFinite(normalized) || normalized <= 0) {
|
||||
return fallback
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeViewState(input: Partial<PoolManagementViewState>): PoolManagementViewState {
|
||||
return {
|
||||
providerId: normalizeProviderId(input.providerId),
|
||||
search: normalizeSearch(input.search),
|
||||
status: normalizeStatus(input.status),
|
||||
page: normalizePositiveInteger(input.page, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.page),
|
||||
pageSize: normalizePositiveInteger(input.pageSize, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredState(storage?: StorageLike): Partial<PoolManagementViewState> {
|
||||
if (!storage) return {}
|
||||
|
||||
try {
|
||||
const raw = storage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as Partial<PoolManagementViewState> | null
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function readPoolManagementViewState(
|
||||
source: PoolManagementStateSource,
|
||||
storage?: StorageLike,
|
||||
): PoolManagementViewState {
|
||||
const stored = normalizeViewState(readStoredState(storage))
|
||||
|
||||
return normalizeViewState({
|
||||
providerId: source.providerId ?? stored.providerId,
|
||||
search: source.search ?? stored.search,
|
||||
status: source.status ?? stored.status,
|
||||
page: source.page ?? stored.page,
|
||||
pageSize: source.pageSize ?? stored.pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
export function writePoolManagementViewState(
|
||||
state: PoolManagementViewState,
|
||||
storage?: StorageLike,
|
||||
): void {
|
||||
if (!storage) return
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
|
||||
JSON.stringify(normalizeViewState(state)),
|
||||
)
|
||||
} catch {
|
||||
// 忽略存储失败,避免影响主流程。
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPoolManagementQueryPatch(
|
||||
state: PoolManagementViewState,
|
||||
): Record<string, string | undefined> {
|
||||
const normalized = normalizeViewState(state)
|
||||
const search = normalized.search.trim()
|
||||
|
||||
return {
|
||||
providerId: normalized.providerId || undefined,
|
||||
search: search || undefined,
|
||||
status: normalized.status === 'all' ? undefined : normalized.status,
|
||||
page: normalized.page <= 1 ? undefined : String(normalized.page),
|
||||
pageSize:
|
||||
normalized.pageSize === DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize
|
||||
? undefined
|
||||
: String(normalized.pageSize),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export type PoolMobileTagTone = 'default' | 'muted' | 'warning' | 'danger' | 'accent'
|
||||
|
||||
export interface PoolMobileTagItem {
|
||||
key: string
|
||||
label: string
|
||||
tone: PoolMobileTagTone
|
||||
}
|
||||
|
||||
export interface PoolMobileTagInput {
|
||||
priorityLabel?: string | null
|
||||
authLabel?: string | null
|
||||
oauthStatusLabel?: string | null
|
||||
oauthStatusTone?: PoolMobileTagTone | null
|
||||
accountStatusLabel?: string | null
|
||||
accountStatusTone?: PoolMobileTagTone | null
|
||||
planLabel?: string | null
|
||||
orgLabel?: string | null
|
||||
proxyLabel?: string | null
|
||||
}
|
||||
|
||||
export type PoolMobileActionId =
|
||||
| 'copy_or_download'
|
||||
| 'refresh_token'
|
||||
| 'clear_cooldown'
|
||||
| 'recover_health'
|
||||
| 'permissions'
|
||||
| 'proxy'
|
||||
| 'edit'
|
||||
| 'toggle'
|
||||
| 'delete'
|
||||
|
||||
export interface PoolMobileActionInput {
|
||||
canDownloadOrCopy?: boolean
|
||||
canRefreshToken?: boolean
|
||||
canClearCooldown?: boolean
|
||||
canRecoverHealth?: boolean
|
||||
hasProxy?: boolean
|
||||
}
|
||||
|
||||
function createTagItem(
|
||||
key: string,
|
||||
label: string | null | undefined,
|
||||
tone: PoolMobileTagTone,
|
||||
): PoolMobileTagItem | null {
|
||||
if (!label) return null
|
||||
return { key, label, tone }
|
||||
}
|
||||
|
||||
export function buildPoolMobileTagItems(input: PoolMobileTagInput): PoolMobileTagItem[] {
|
||||
return [
|
||||
createTagItem('account', input.accountStatusLabel, input.accountStatusTone ?? 'warning'),
|
||||
createTagItem('oauth', input.oauthStatusLabel, input.oauthStatusTone ?? 'warning'),
|
||||
createTagItem('priority', input.priorityLabel, 'muted'),
|
||||
createTagItem('auth', input.authLabel, 'default'),
|
||||
createTagItem('plan', input.planLabel, 'accent'),
|
||||
createTagItem('org', input.orgLabel, 'accent'),
|
||||
createTagItem('proxy', input.proxyLabel, 'muted'),
|
||||
].filter((item): item is PoolMobileTagItem => item !== null)
|
||||
}
|
||||
|
||||
export function splitPoolMobileActions(input: PoolMobileActionInput): {
|
||||
primary: PoolMobileActionId[]
|
||||
overflow: PoolMobileActionId[]
|
||||
} {
|
||||
if (input.canDownloadOrCopy) {
|
||||
const primary: PoolMobileActionId[] = ['copy_or_download']
|
||||
if (input.canRefreshToken) {
|
||||
primary.push('refresh_token')
|
||||
}
|
||||
if (input.canClearCooldown) {
|
||||
primary.push('clear_cooldown')
|
||||
}
|
||||
if (input.canRecoverHealth) {
|
||||
primary.push('recover_health')
|
||||
}
|
||||
primary.push('permissions')
|
||||
if (input.hasProxy) {
|
||||
primary.push('proxy')
|
||||
}
|
||||
primary.push('edit', 'toggle', 'delete')
|
||||
|
||||
return {
|
||||
primary,
|
||||
overflow: [],
|
||||
}
|
||||
}
|
||||
|
||||
const primary: PoolMobileActionId[] = []
|
||||
if (input.canRefreshToken) {
|
||||
primary.push('refresh_token')
|
||||
}
|
||||
if (input.canClearCooldown) {
|
||||
primary.push('clear_cooldown')
|
||||
}
|
||||
if (input.canRecoverHealth) {
|
||||
primary.push('recover_health')
|
||||
}
|
||||
primary.push('permissions')
|
||||
if (input.hasProxy) {
|
||||
primary.push('proxy')
|
||||
}
|
||||
primary.push('edit', 'toggle', 'delete')
|
||||
|
||||
return {
|
||||
primary,
|
||||
overflow: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface SchedulingDialogPresetLike {
|
||||
mutexGroup: string | null
|
||||
}
|
||||
|
||||
export function moveStrategyItem<T extends SchedulingDialogPresetLike>(
|
||||
items: readonly T[],
|
||||
itemIndex: number,
|
||||
direction: -1 | 1,
|
||||
): T[] {
|
||||
const strategyIndexes: number[] = []
|
||||
|
||||
items.forEach((item, index) => {
|
||||
if (!item.mutexGroup) {
|
||||
strategyIndexes.push(index)
|
||||
}
|
||||
})
|
||||
|
||||
const currentPosition = strategyIndexes.indexOf(itemIndex)
|
||||
if (currentPosition === -1) {
|
||||
return [...items]
|
||||
}
|
||||
|
||||
const targetPosition = currentPosition + direction
|
||||
if (targetPosition < 0 || targetPosition >= strategyIndexes.length) {
|
||||
return [...items]
|
||||
}
|
||||
|
||||
const sourceIndex = strategyIndexes[currentPosition]
|
||||
const targetIndex = strategyIndexes[targetPosition]
|
||||
const nextItems = [...items]
|
||||
|
||||
;[nextItems[sourceIndex], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[sourceIndex]]
|
||||
|
||||
return nextItems
|
||||
}
|
||||
Reference in New Issue
Block a user