You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

410 lines
8.3 KiB

<script setup lang="ts">
import { request } from '~/utils/http/factory'
// ── Types ──
export interface SelectedCard {
cardId: number
sortOrder?: number
}
interface ServerCard {
id: number
type: string
title: string
description: string | null
images: { id: number; url: string; sortOrder: number }[]
}
// ── Props / Emits ──
const props = defineProps<{
modelValue: SelectedCard[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: SelectedCard[]]
}>()
// ── Card search ──
const searchQuery = ref('')
const allCards = ref<ServerCard[]>([])
const loadingCards = ref(false)
const showPicker = ref(false)
// ── Computed ──
const selectedIds = computed(() => new Set(props.modelValue.map((c) => c.cardId)))
const filteredCards = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return allCards.value.filter((c) => !selectedIds.value.has(c.id))
return allCards.value.filter(
(c) =>
!selectedIds.value.has(c.id) &&
(c.title.toLowerCase().includes(q) || c.type.toLowerCase().includes(q)),
)
})
const selectedCards = computed(() => {
return props.modelValue
.map((sc) => {
const card = allCards.value.find((c) => c.id === sc.cardId)
return { ...sc, card }
})
.filter((s) => s.card != null) as {
cardId: number
sortOrder: number
card: ServerCard
}[]
})
const cardThumb = (card: ServerCard) => {
return card.images?.[0]?.url ?? null
}
// ── Actions ──
async function loadCards() {
if (allCards.value.length > 0) return
loadingCards.value = true
try {
const raw = await request<{ code: number; data: { items: ServerCard[] } }>(
'/api/cards?pageSize=200',
)
allCards.value = raw.data.items
} catch {
// silent
} finally {
loadingCards.value = false
}
}
function addCard(card: ServerCard) {
const next = [...props.modelValue, { cardId: card.id, sortOrder: props.modelValue.length }]
emit('update:modelValue', next)
searchQuery.value = ''
}
function removeCard(cardId: number) {
const filtered = props.modelValue
.filter((c) => c.cardId !== cardId)
.map((c, i) => ({ ...c, sortOrder: i }))
emit('update:modelValue', filtered)
}
function moveCard(cardId: number, dir: -1 | 1) {
const idx = props.modelValue.findIndex((c) => c.cardId === cardId)
if (idx === -1) return
const target = idx + dir
if (target < 0 || target >= props.modelValue.length) return
const next = [...props.modelValue]
const tmp = next[idx]!
next[idx] = next[target]!
next[target] = tmp
// Reassign sort order
emit(
'update:modelValue',
next.map((c, i) => ({ ...c, sortOrder: i })),
)
}
function onPickerFocus() {
showPicker.value = true
loadCards()
}
function onPickerBlur() {
// Delay to allow click on dropdown items
setTimeout(() => {
showPicker.value = false
}, 200)
}
// ── Init ──
onMounted(() => {
// Preload cards so selected chips render immediately
if (props.modelValue.length > 0) {
loadCards()
}
})
</script>
<template>
<div class="card-picker">
<!-- Selected Cards -->
<div v-if="selectedCards.length > 0" class="selected-list">
<div
v-for="(sc, idx) in selectedCards"
:key="sc.cardId"
class="selected-chip"
>
<div class="chip-thumb">
<img v-if="cardThumb(sc.card)" :src="cardThumb(sc.card)!" :alt="sc.card.title" />
<span v-else class="chip-thumb-empty" />
</div>
<span class="chip-name">{{ sc.card.title }}</span>
<span class="chip-type">{{ sc.card.type }}</span>
<button
v-if="idx > 0"
class="chip-move"
title="上移"
@click="moveCard(sc.cardId, -1)"
>↑</button>
<button
v-if="idx < selectedCards.length - 1"
class="chip-move"
title="下移"
@click="moveCard(sc.cardId, 1)"
>↓</button>
<button class="chip-remove" title="移除" @click="removeCard(sc.cardId)">×</button>
</div>
</div>
<!-- Search / Add -->
<div class="picker-input-wrap">
<input
v-model="searchQuery"
type="text"
class="picker-input"
placeholder="搜索并添加卡片…"
@focus="onPickerFocus"
@blur="onPickerBlur"
/>
<div v-if="showPicker" class="picker-dropdown">
<div v-if="loadingCards" class="picker-hint">加载中…</div>
<div v-else-if="filteredCards.length === 0" class="picker-hint">
{{ searchQuery ? '无匹配卡片' : '没有可添加的卡片' }}
</div>
<div
v-for="card in filteredCards.slice(0, 20)"
:key="card.id"
class="picker-item"
@mousedown.prevent="addCard(card)"
>
<div class="picker-item-thumb">
<img v-if="cardThumb(card)" :src="cardThumb(card)!" :alt="card.title" />
<span v-else class="picker-item-thumb-empty" />
</div>
<div class="picker-item-info">
<span class="picker-item-name">{{ card.title }}</span>
<span class="picker-item-type">{{ card.type }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.card-picker {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Selected chips ── */
.selected-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.selected-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #efe9de;
border: 1px solid #e6dfd8;
border-radius: 8px;
font-size: 13px;
color: #3d3d3a;
}
.chip-thumb {
width: 24px;
height: 24px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
background: #e8e0d2;
}
.chip-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.chip-thumb-empty {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e8e0d2, #ddd6c8);
}
.chip-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.chip-type {
font-size: 11px;
color: #8b7e6a;
background: #e8e0d2;
padding: 1px 5px;
border-radius: 3px;
}
.chip-move {
width: 20px;
height: 20px;
border: none;
background: none;
color: #8b7e6a;
font-size: 11px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.chip-move:hover {
background: #ddd6c8;
color: #3d3d3a;
}
.chip-remove {
width: 20px;
height: 20px;
border: none;
background: none;
color: #8b7e6a;
font-size: 14px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.chip-remove:hover {
background: #fce4e4;
color: #c64545;
}
/* ── Picker input ── */
.picker-input-wrap {
position: relative;
}
.picker-input {
width: 100%;
padding: 8px 14px;
border: 1px solid #e6dfd8;
border-radius: 8px;
font-size: 13px;
color: #3d3d3a;
background: #faf9f5;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s;
}
.picker-input:focus {
border-color: #cc785c;
}
.picker-input::placeholder {
color: #b8b2a6;
}
.picker-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: #fff;
border: 1px solid #e6dfd8;
border-radius: 8px;
max-height: 260px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.picker-hint {
padding: 14px;
font-size: 13px;
color: #b8b2a6;
text-align: center;
}
.picker-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.1s;
}
.picker-item:hover {
background: #f5f0e8;
}
.picker-item-thumb {
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
background: #e8e0d2;
}
.picker-item-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.picker-item-thumb-empty {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e8e0d2, #ddd6c8);
}
.picker-item-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.picker-item-name {
font-size: 13px;
font-weight: 500;
color: #3d3d3a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picker-item-type {
font-size: 11px;
color: #8b7e6a;
}
</style>