upd
This commit is contained in:
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"defaultMode": "bypassPermissions"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export const itemsRouter = Router()
|
|||||||
* properties:
|
* properties:
|
||||||
* id:
|
* id:
|
||||||
* type: integer
|
* type: integer
|
||||||
|
* value:
|
||||||
|
* type: string
|
||||||
* PaginatedItems:
|
* PaginatedItems:
|
||||||
* type: object
|
* type: object
|
||||||
* properties:
|
* properties:
|
||||||
|
|||||||
@@ -1,28 +1,54 @@
|
|||||||
|
export interface Item {
|
||||||
|
id: number
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedResult {
|
export interface PaginatedResult {
|
||||||
data: { id: number }[]
|
data: Item[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
limit: number
|
limit: number
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const allItems: number[] = Array.from({ length: 1_000_000 }, (_, i) => i + 1)
|
const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
|
||||||
|
function randomString(): string {
|
||||||
|
const len = 6 + Math.floor(Math.random() * 7)
|
||||||
|
let result = ''
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
result += CHARS[Math.floor(Math.random() * CHARS.length)]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsById = new Map<number, string>()
|
||||||
|
const allItemsOrder: number[] = []
|
||||||
|
|
||||||
|
for (let i = 1; i <= 1_000_000; i++) {
|
||||||
|
itemsById.set(i, randomString())
|
||||||
|
allItemsOrder.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
const selectedIds = new Set<number>()
|
const selectedIds = new Set<number>()
|
||||||
const orderedSelected: number[] = []
|
const orderedSelected: number[] = []
|
||||||
|
|
||||||
export function getItems(page: number, limit: number, search?: string): PaginatedResult {
|
export function getItems(page: number, limit: number, search?: string): PaginatedResult {
|
||||||
let filtered: number[]
|
let filtered: number[]
|
||||||
if (search) {
|
if (search) {
|
||||||
filtered = allItems.filter(id => !selectedIds.has(id) && String(id).includes(search))
|
const s = search.toLowerCase()
|
||||||
|
filtered = allItemsOrder.filter(
|
||||||
|
id => !selectedIds.has(id) && (String(id).includes(search) || itemsById.get(id)!.toLowerCase().includes(s)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
filtered = allItems.filter(id => !selectedIds.has(id))
|
filtered = allItemsOrder.filter(id => !selectedIds.has(id))
|
||||||
}
|
}
|
||||||
const total = filtered.length
|
const total = filtered.length
|
||||||
const start = (page - 1) * limit
|
const start = (page - 1) * limit
|
||||||
const slice = filtered.slice(start, start + limit)
|
const slice = filtered.slice(start, start + limit)
|
||||||
return {
|
return {
|
||||||
data: slice.map(id => ({ id })),
|
data: slice.map(id => ({ id, value: itemsById.get(id)! })),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -33,7 +59,11 @@ export function getItems(page: number, limit: number, search?: string): Paginate
|
|||||||
export function getSelectedItems(page: number, limit: number, search?: string): PaginatedResult {
|
export function getSelectedItems(page: number, limit: number, search?: string): PaginatedResult {
|
||||||
let filtered: number[]
|
let filtered: number[]
|
||||||
if (search) {
|
if (search) {
|
||||||
filtered = orderedSelected.filter(id => String(id).includes(search))
|
const s = search.toLowerCase()
|
||||||
|
filtered = orderedSelected.filter((id) => {
|
||||||
|
const value = itemsById.get(id)!
|
||||||
|
return String(id).includes(search) || value.toLowerCase().includes(s)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
filtered = [...orderedSelected]
|
filtered = [...orderedSelected]
|
||||||
@@ -42,7 +72,7 @@ export function getSelectedItems(page: number, limit: number, search?: string):
|
|||||||
const start = (page - 1) * limit
|
const start = (page - 1) * limit
|
||||||
const slice = filtered.slice(start, start + limit)
|
const slice = filtered.slice(start, start + limit)
|
||||||
return {
|
return {
|
||||||
data: slice.map(id => ({ id })),
|
data: slice.map(id => ({ id, value: itemsById.get(id)! })),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -51,13 +81,14 @@ export function getSelectedItems(page: number, limit: number, search?: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function addItem(id: number): boolean {
|
export function addItem(id: number): boolean {
|
||||||
if (allItems.includes(id) || selectedIds.has(id)) return false
|
if (itemsById.has(id)) return false
|
||||||
allItems.push(id)
|
itemsById.set(id, randomString())
|
||||||
|
allItemsOrder.push(id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectItem(id: number): boolean {
|
export function selectItem(id: number): boolean {
|
||||||
if (selectedIds.has(id)) return false
|
if (selectedIds.has(id) || !itemsById.has(id)) return false
|
||||||
selectedIds.add(id)
|
selectedIds.add(id)
|
||||||
orderedSelected.push(id)
|
orderedSelected.push(id)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<input
|
<input
|
||||||
class="search-input"
|
class="search-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by ID..."
|
placeholder="Search..."
|
||||||
:value="search"
|
:value="search"
|
||||||
@input="onSearchInput"
|
@input="onSearchInput"
|
||||||
>
|
>
|
||||||
@@ -40,7 +40,8 @@
|
|||||||
class="item-row"
|
class="item-row"
|
||||||
:style="{ '--i': Math.min(index, 14) }"
|
:style="{ '--i': Math.min(index, 14) }"
|
||||||
>
|
>
|
||||||
<span class="item-id">{{ item.id }}</span>
|
<span class="item-id">#{{ item.id }}</span>
|
||||||
|
<span class="item-value">{{ item.value }}</span>
|
||||||
<button class="action-btn" title="Move to selected" @click="emit('select', item.id!)">
|
<button class="action-btn" title="Move to selected" @click="emit('select', item.id!)">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2.5 7H11.5M8.5 3.5L12 7L8.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M2.5 7H11.5M8.5 3.5L12 7L8.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -339,7 +340,7 @@ function submitAdd() {
|
|||||||
.item-row {
|
.item-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 10px;
|
||||||
padding: 9px 12px 9px 20px;
|
padding: 9px 12px 9px 20px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
animation: item-in 350ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
animation: item-in 350ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
@@ -356,15 +357,21 @@ function submitAdd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-id {
|
.item-id {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-value {
|
||||||
|
flex: 1;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
&::before {
|
text-overflow: ellipsis;
|
||||||
content: '#';
|
white-space: nowrap;
|
||||||
color: var(--text-muted);
|
|
||||||
margin-right: 1px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
|
|||||||
@@ -17,20 +17,21 @@
|
|||||||
<input
|
<input
|
||||||
class="search-input"
|
class="search-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by ID..."
|
placeholder="Search..."
|
||||||
:value="search"
|
:value="search"
|
||||||
@input="onSearchInput"
|
@input="onSearchInput"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div ref="panelBody" class="panel-body">
|
||||||
<VueDraggableNext
|
<VueDraggableNext
|
||||||
v-model="localItems"
|
v-model="localItems"
|
||||||
tag="ul"
|
tag="ul"
|
||||||
class="item-list"
|
class="item-list"
|
||||||
handle=".drag-handle"
|
handle=".drag-handle"
|
||||||
@end="onDragEnd"
|
@start="onDragStarted"
|
||||||
|
@end="onDragEndFull"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in localItems"
|
v-for="(item, index) in localItems"
|
||||||
@@ -48,8 +49,8 @@
|
|||||||
<circle cx="6" cy="10" r="1.2" />
|
<circle cx="6" cy="10" r="1.2" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<!-- <span class="item-order">{{ index + 1 }}</span> -->
|
<span class="item-id">#{{ item.id }}</span>
|
||||||
<span class="item-id">{{ item.id }}</span>
|
<span class="item-value">{{ item.value }}</span>
|
||||||
<button class="action-btn" title="Remove from selected" @click="emit('deselect', item.id!)">
|
<button class="action-btn" title="Remove from selected" @click="emit('deselect', item.id!)">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M11.5 7H2.5M5.5 3.5L2 7L5.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M11.5 7H2.5M5.5 3.5L2 7L5.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -92,6 +93,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const sentinel = ref<HTMLElement | null>(null)
|
const sentinel = ref<HTMLElement | null>(null)
|
||||||
|
const panelBody = ref<HTMLElement | null>(null)
|
||||||
let observer: IntersectionObserver | null = null
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
watch(sentinel, (el) => {
|
watch(sentinel, (el) => {
|
||||||
@@ -109,10 +111,67 @@ watch(sentinel, (el) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Auto-scroll during drag ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SCROLL_ZONE = 80
|
||||||
|
const SCROLL_MAX_SPEED = 12
|
||||||
|
|
||||||
|
let isDragging = false
|
||||||
|
let scrollSpeed = 0
|
||||||
|
let scrollRafId: number | null = null
|
||||||
|
|
||||||
|
function scrollLoop() {
|
||||||
|
if (!isDragging || !panelBody.value) {
|
||||||
|
scrollRafId = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (scrollSpeed !== 0) {
|
||||||
|
panelBody.value.scrollTop += scrollSpeed
|
||||||
|
}
|
||||||
|
scrollRafId = requestAnimationFrame(scrollLoop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMoveDrag(e: PointerEvent) {
|
||||||
|
if (!isDragging || !panelBody.value) return
|
||||||
|
const rect = panelBody.value.getBoundingClientRect()
|
||||||
|
const y = e.clientY - rect.top
|
||||||
|
if (y >= 0 && y < SCROLL_ZONE) {
|
||||||
|
scrollSpeed = -((SCROLL_ZONE - y) / SCROLL_ZONE) * SCROLL_MAX_SPEED
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
scrollSpeed = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStarted() {
|
||||||
|
isDragging = true
|
||||||
|
scrollSpeed = 0
|
||||||
|
document.addEventListener('pointermove', onPointerMoveDrag)
|
||||||
|
scrollRafId = requestAnimationFrame(scrollLoop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEndFull(event: { oldIndex: number, newIndex: number }): void {
|
||||||
|
isDragging = false
|
||||||
|
scrollSpeed = 0
|
||||||
|
document.removeEventListener('pointermove', onPointerMoveDrag)
|
||||||
|
if (scrollRafId !== null) {
|
||||||
|
cancelAnimationFrame(scrollRafId)
|
||||||
|
scrollRafId = null
|
||||||
|
}
|
||||||
|
const moved = localItems.value[event.newIndex]
|
||||||
|
if (!moved) return
|
||||||
|
const after = event.newIndex > 0 ? localItems.value[event.newIndex - 1] : null
|
||||||
|
emit('reorder', moved.id!, after?.id ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
|
document.removeEventListener('pointermove', onPointerMoveDrag)
|
||||||
|
if (scrollRafId !== null) cancelAnimationFrame(scrollRafId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Search ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function onSearchInput(e: Event) {
|
function onSearchInput(e: Event) {
|
||||||
@@ -124,17 +183,13 @@ function onSearchInput(e: Event) {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Local items for drag-and-drop ────────────────────────────────────────────
|
||||||
|
|
||||||
const localItems = ref([...props.items])
|
const localItems = ref([...props.items])
|
||||||
|
|
||||||
watch(() => props.items, (val) => {
|
watch(() => props.items, (val) => {
|
||||||
localItems.value = [...val]
|
localItems.value = [...val]
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
function onDragEnd(event: { oldIndex: number, newIndex: number }): void {
|
|
||||||
const moved = localItems.value[event.newIndex]
|
|
||||||
const after = event.newIndex > 0 ? localItems.value[event.newIndex - 1] : null
|
|
||||||
emit('reorder', moved.id!, after?.id ?? null)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -296,26 +351,22 @@ function onDragEnd(event: { oldIndex: number, newIndex: number }): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-order {
|
.item-id {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.68rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 18px;
|
|
||||||
text-align: right;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-id {
|
.item-value {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
&::before {
|
text-overflow: ellipsis;
|
||||||
content: '#';
|
white-space: nowrap;
|
||||||
color: var(--text-muted);
|
|
||||||
margin-right: 1px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
|
|||||||
@@ -21,12 +21,25 @@ export function useItems() {
|
|||||||
|
|
||||||
async function fetchLeft(reset = false): Promise<void> {
|
async function fetchLeft(reset = false): Promise<void> {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
leftPage.value = 1
|
if (leftLoading.value) return
|
||||||
leftItems.value = []
|
leftLoading.value = true
|
||||||
leftHasMore.value = true
|
try {
|
||||||
|
const data = await client.api.itemsList({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
...(leftSearch.value ? { search: leftSearch.value } : {}),
|
||||||
|
})
|
||||||
|
leftItems.value = [...(data.data ?? [])]
|
||||||
|
leftHasMore.value = data.hasMore ?? false
|
||||||
|
leftItemsTotal.value = data.total || 0
|
||||||
|
leftPage.value = 2
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
leftLoading.value = false
|
||||||
}
|
}
|
||||||
if (!leftHasMore.value || leftLoading.value)
|
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
if (!leftHasMore.value || leftLoading.value) return
|
||||||
leftLoading.value = true
|
leftLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await client.api.itemsList({
|
const data = await client.api.itemsList({
|
||||||
@@ -46,12 +59,25 @@ export function useItems() {
|
|||||||
|
|
||||||
async function fetchRight(reset = false): Promise<void> {
|
async function fetchRight(reset = false): Promise<void> {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
rightPage.value = 1
|
if (rightLoading.value) return
|
||||||
rightItems.value = []
|
rightLoading.value = true
|
||||||
rightHasMore.value = true
|
try {
|
||||||
|
const data = await client.api.itemsSelectedList({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
...(rightSearch.value ? { search: rightSearch.value } : {}),
|
||||||
|
})
|
||||||
|
rightItems.value = [...(data.data ?? [])]
|
||||||
|
rightHasMore.value = data.hasMore ?? false
|
||||||
|
rightItemsTotal.value = data.total || 0
|
||||||
|
rightPage.value = 2
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
rightLoading.value = false
|
||||||
}
|
}
|
||||||
if (!rightHasMore.value || rightLoading.value)
|
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
if (!rightHasMore.value || rightLoading.value) return
|
||||||
rightLoading.value = true
|
rightLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await client.api.itemsSelectedList({
|
const data = await client.api.itemsSelectedList({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedItems {
|
export interface PaginatedItems {
|
||||||
|
|||||||
Reference in New Issue
Block a user