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
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>
|
|
|