Files
LitePan/web/src/components/admin/DriverPickerStep.vue
T
2026-07-27 19:31:43 +08:00

814 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { DriverInfo } from "@/api/types";
import DriverIcon from "@/components/driver/DriverIcon.vue";
import AppButton from "@/components/base/AppButton.vue";
const ITEM_H = 196;
type SlideItem = { driver: DriverInfo; key: string; realIndex: number };
const props = defineProps<{ drivers: DriverInfo[]; modelValue: string }>();
const emit = defineEmits<{ "update:modelValue": [string]; next: [] }>();
const VIEW_KEY = "litepan_add_account_driver_view_mode";
const saved = localStorage.getItem(VIEW_KEY);
const viewMode = ref<"carousel" | "grid">(
saved === "carousel" || saved === "grid" ? saved : "carousel",
);
const searchQuery = ref("");
const trackIndex = ref(0);
const trackTransition = ref(true);
const trackResetting = ref(false);
const filtered = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return props.drivers;
return props.drivers.filter(
(d) =>
d.display_name.toLowerCase().includes(q) ||
d.name.toLowerCase().includes(q) ||
(d.description || "").toLowerCase().includes(q),
);
});
const slides = computed((): SlideItem[] => {
const list = filtered.value;
if (list.length <= 1) {
return list.map((driver, realIndex) => ({ driver, key: driver.name, realIndex }));
}
const first = list[0];
const last = list[list.length - 1];
return [
{ driver: last, key: `clone-head-${last.name}`, realIndex: list.length - 1 },
...list.map((driver, realIndex) => ({ driver, key: driver.name, realIndex })),
{ driver: first, key: `clone-tail-${first.name}`, realIndex: 0 },
];
});
const positionCur = computed(() => {
const slide = slides.value[trackIndex.value];
return slide ? slide.realIndex + 1 : 0;
});
function syncTrackFromModel() {
const list = filtered.value;
if (!list.length) {
trackIndex.value = 0;
emit("update:modelValue", "");
return;
}
let idx = list.findIndex((d) => d.name === props.modelValue);
if (idx < 0) idx = 0;
trackIndex.value = list.length <= 1 ? idx : idx + 1;
if (list[idx] && list[idx].name !== props.modelValue) {
emit("update:modelValue", list[idx].name);
}
}
watch(() => props.drivers, syncTrackFromModel, { immediate: true });
watch(filtered, () => {
syncTrackFromModel();
});
function selectDriver(name: string, realIndex?: number) {
const list = filtered.value;
if (realIndex !== undefined) {
trackIndex.value = list.length <= 1 ? realIndex : realIndex + 1;
} else {
const idx = list.findIndex((d) => d.name === name);
if (idx >= 0) trackIndex.value = list.length <= 1 ? idx : idx + 1;
}
emit("update:modelValue", name);
}
function move(delta: number) {
const n = filtered.value.length;
if (n <= 1 || trackResetting.value) return;
trackTransition.value = true;
trackIndex.value += delta;
const slide = slides.value[trackIndex.value];
if (slide) emit("update:modelValue", slide.driver.name);
}
function resetTrackIndex(next: number) {
trackResetting.value = true;
trackTransition.value = false;
trackIndex.value = next;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
trackTransition.value = true;
trackResetting.value = false;
});
});
}
function onTrackTransitionEnd(e: TransitionEvent) {
if (trackResetting.value) return;
if (e.target !== e.currentTarget || e.propertyName !== "transform") return;
const n = filtered.value.length;
if (n <= 1) return;
if (trackIndex.value === 0) {
resetTrackIndex(n);
} else if (trackIndex.value === n + 1) {
resetTrackIndex(1);
}
}
function toggleView() {
viewMode.value = viewMode.value === "carousel" ? "grid" : "carousel";
localStorage.setItem(VIEW_KEY, viewMode.value);
}
function clearSearch() {
searchQuery.value = "";
}
const canNext = computed(() => Boolean(props.modelValue));
function driverDescription(driver: DriverInfo) {
return driver.description || "云存储服务,支持文件浏览与管理";
}
function driverCardTags(driver: DriverInfo) {
return (driver.card_tags || []).map((tag) => String(tag).trim()).filter(Boolean).slice(0, 4);
}
function driverAuthLabel(driver: DriverInfo) {
if (driver.auth_label) return driver.auth_label;
if (driver.supports_oauth) return "OAuth";
if (driver.supports_qr_login) return "扫码";
if (driver.auth_type === "cookie") return "Cookie";
if (driver.auth_type === "token") return "Token";
if (driver.auth_type === "none") return "本地";
return "认证";
}
</script>
<template>
<div class="picker">
<div class="picker__viewport">
<div v-show="viewMode === 'carousel'" class="carousel">
<div
class="carousel__track"
:class="{ 'carousel__track--instant': !trackTransition }"
:style="{ transform: `translateY(-${trackIndex * ITEM_H}px)` }"
@transitionend="onTrackTransitionEnd"
>
<div
v-for="slide in slides"
:key="slide.key"
class="carousel__item"
@click="selectDriver(slide.driver.name, slide.realIndex)"
>
<div
class="carousel__card"
:class="{ selected: slide.driver.name === modelValue }"
:style="{ '--driver-color': slide.driver.card_color }"
>
<div class="carousel__content">
<DriverIcon
:logo="slide.driver.card_logo"
:color="slide.driver.card_color"
:name="slide.driver.display_name"
:size="68"
/>
<div class="carousel__info">
<div class="carousel__title-row">
<h3>{{ slide.driver.display_name }}</h3>
<span class="carousel__auth">{{ driverAuthLabel(slide.driver) }}</span>
</div>
<p>{{ driverDescription(slide.driver) }}</p>
<div v-if="driverCardTags(slide.driver).length" class="carousel__tags">
<span
v-for="tag in driverCardTags(slide.driver)"
:key="tag"
class="carousel__tag"
>
{{ tag }}
</span>
</div>
</div>
</div>
<div v-if="filtered.length > 1" class="carousel__controls">
<button type="button" class="nav-arrow" @click.stop="move(-1)">▲</button>
<div class="position-indicator">
<span class="position-text">{{ positionCur }}</span>
<div class="position-divider" />
<span class="total-text">{{ filtered.length }}</span>
</div>
<button type="button" class="nav-arrow" @click.stop="move(1)">▼</button>
</div>
</div>
</div>
</div>
</div>
<div v-show="viewMode === 'grid'" class="grid">
<button
v-for="(driver, index) in filtered"
:key="driver.name"
type="button"
class="mini-card"
:class="{ selected: driver.name === modelValue }"
@click="selectDriver(driver.name, index)"
>
<DriverIcon
:logo="driver.card_logo"
:color="driver.card_color"
:name="driver.display_name"
:size="30"
/>
<span class="mini-card__name">{{ driver.display_name }}</span>
</button>
<div v-if="!filtered.length" class="grid__empty">未找到匹配的驱动</div>
</div>
</div>
<div class="actions">
<div class="actions__search">
<button
type="button"
class="view-toggle"
:title="viewMode === 'carousel' ? '切换到卡片视图' : '切换到翻动视图'"
@click="toggleView"
>
<svg v-if="viewMode === 'carousel'" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="1" y="1" width="6" height="6" rx="1" />
<rect x="9" y="1" width="6" height="6" rx="1" />
<rect x="1" y="9" width="6" height="6" rx="1" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</svg>
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 5h12v1.5H2V5zm0 5h12V11.5H2V11z" />
<path d="M5 2.5 2 5.5l3 3V2.5zm6 0v6l3-3-3-3z" />
</svg>
</button>
<div class="search-box">
<span class="search-box__icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-4-4" />
</svg>
</span>
<input
v-model="searchQuery"
type="text"
class="search-box__input"
placeholder="搜索驱动..."
/>
<button v-if="searchQuery" type="button" class="search-box__clear" @click="clearSearch">×</button>
</div>
<span v-if="searchQuery" class="search-hint">找到 {{ filtered.length }} 个匹配的驱动</span>
</div>
<AppButton variant="primary" :disabled="!canNext" @click="emit('next')">
下一步 →
</AppButton>
</div>
</div>
</template>
<style scoped>
.picker {
display: flex;
flex-direction: column;
gap: 10px;
}
.picker__viewport {
height: 196px;
flex-shrink: 0;
}
.carousel {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 12px;
isolation: isolate;
}
.carousel__track {
display: flex;
flex-direction: column;
transition: transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.carousel__track--instant {
transition: none;
}
.carousel__item {
height: 196px;
flex-shrink: 0;
contain: paint;
}
.carousel__card {
position: relative;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at 16% 28%, rgba(255, 255, 255, 0.94), transparent 24%),
radial-gradient(ellipse at 76% 18%, color-mix(in srgb, var(--driver-color, var(--brand)) 9%, transparent), transparent 30%),
radial-gradient(ellipse at 42% 92%, rgba(255, 255, 255, 0.7), transparent 24%),
linear-gradient(
135deg,
color-mix(in srgb, var(--driver-color, var(--brand)) 4%, var(--surface)) 0%,
color-mix(in srgb, var(--brand) 8%, var(--surface-sunken)) 58%,
color-mix(in srgb, var(--driver-color, var(--brand)) 7%, var(--surface)) 100%
);
border: 2px solid var(--border);
border-radius: 12px;
cursor: pointer;
display: flex;
align-items: center;
padding: 20px 38px;
box-sizing: border-box;
transition: border-color 0.3s ease, background 0.3s ease, box-shadow 0.3s ease;
overflow: hidden;
transform: translateZ(0);
backface-visibility: hidden;
contain: paint;
}
.carousel__card:hover {
border-color: var(--brand);
box-shadow: var(--shadow-card);
}
.carousel__card.selected {
border-color: var(--brand);
}
.carousel__content {
position: relative;
z-index: 1;
flex: 1;
display: flex;
align-items: center;
gap: 24px;
min-width: 0;
transform: translateZ(0);
backface-visibility: hidden;
}
.carousel__title-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
margin-bottom: 7px;
}
.carousel__info h3 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--text);
line-height: 1.15;
}
.carousel__auth {
flex-shrink: 0;
padding: 3px 8px;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--driver-color, var(--brand)) 14%, rgba(255, 255, 255, 0.8));
color: color-mix(in srgb, var(--driver-color, var(--brand)) 82%, var(--text));
font-size: 11.5px;
font-weight: 700;
line-height: 1.2;
}
.carousel__info p {
margin: 0;
color: var(--text-muted);
font-size: 13.5px;
line-height: 1.45;
max-width: 460px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.carousel__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.carousel__tag {
height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.78);
border: 1px solid color-mix(in srgb, var(--driver-color, var(--brand)) 16%, var(--border));
color: color-mix(in srgb, var(--driver-color, var(--brand)) 62%, var(--text-muted));
font-size: 11px;
font-weight: 600;
box-shadow: 0 4px 12px rgba(30, 55, 95, 0.05);
}
.carousel__controls {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding-left: 30px;
opacity: 0.7;
}
.carousel__card:hover .carousel__controls {
opacity: 1;
}
.nav-arrow {
width: 34px;
height: 34px;
border: none;
border-radius: 50%;
background: var(--surface);
color: var(--brand);
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-soft);
transition: all 0.3s ease;
}
.nav-arrow:hover {
background: var(--brand);
color: var(--text-on-brand);
transform: scale(1.1);
}
.position-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--brand);
min-width: 20px;
}
.position-text {
font-size: 15px;
font-weight: 700;
color: var(--brand);
line-height: 1;
}
.position-divider {
width: 12px;
height: 1px;
background: color-mix(in srgb, var(--brand) 40%, transparent);
margin: 2px 0;
}
.total-text {
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--brand) 70%, var(--text-muted));
line-height: 1;
}
.grid {
height: 100%;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 60px;
align-content: start;
gap: 8px;
padding-right: 2px;
}
.mini-card {
height: 60px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-sunken);
display: flex;
align-items: center;
gap: 8px;
padding: 7px 9px;
cursor: pointer;
text-align: left;
transition: all 0.25s ease;
overflow: hidden;
}
.mini-card:hover {
border-color: var(--border);
background: var(--surface);
box-shadow: var(--shadow-card);
}
.mini-card.selected {
border-color: var(--brand);
background: color-mix(in srgb, var(--brand) 8%, var(--surface));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand) 12%, transparent);
}
.mini-card__name {
min-width: 0;
color: var(--text);
font-size: 12px;
font-weight: 600;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.grid__empty {
grid-column: 1 / -1;
height: 100%;
min-height: 0;
border: 2px dashed var(--border);
border-radius: 12px;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
padding-bottom: 0;
flex-shrink: 0;
}
.actions__search {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.view-toggle {
width: 40px;
height: 40px;
border: 2px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--brand);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: all 0.25s ease;
}
.view-toggle:hover {
border-color: var(--brand);
background: color-mix(in srgb, var(--brand) 8%, var(--surface));
}
.search-box {
position: relative;
width: 240px;
flex-shrink: 0;
}
.search-box__icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
display: flex;
pointer-events: none;
}
.search-box__input {
width: 100%;
height: 40px;
padding: 10px 36px 10px 42px;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 13px;
color: var(--text);
background: var(--surface);
box-sizing: border-box;
}
.search-box__input::placeholder {
color: var(--text-muted);
}
.search-box__input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 15%, transparent);
}
.search-box__clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: var(--danger);
color: var(--text-on-brand);
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.search-hint {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
@media (max-width: 640px) {
.picker {
gap: 8px;
}
.carousel__card {
padding: 18px 48px 18px 22px;
}
.carousel__content {
gap: 18px;
}
.carousel__content :deep(.driver-icon) {
width: 58px !important;
height: 58px !important;
flex: 0 0 58px;
}
.carousel__title-row {
margin-bottom: 0;
}
.carousel__info h3 {
font-size: 22px;
line-height: 1.16;
word-break: break-word;
}
.carousel__auth,
.carousel__info p,
.carousel__tags {
display: none;
}
.carousel__controls {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
gap: 8px;
padding-left: 0;
}
.nav-arrow {
width: 30px;
height: 30px;
}
.grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 56px;
gap: 6px;
}
.mini-card {
height: 56px;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 5px 4px;
text-align: center;
}
.mini-card :deep(.driver-icon) {
width: 24px !important;
height: 24px !important;
flex: 0 0 24px;
}
.mini-card__name {
width: 100%;
font-size: 10px;
line-height: 1.15;
-webkit-line-clamp: 1;
}
.actions {
gap: 8px;
}
.actions__search {
gap: 8px;
min-width: 0;
}
.search-box {
width: auto;
min-width: 0;
flex: 1;
}
.search-hint {
display: none;
}
}
:global(:root[data-theme="dark"] .carousel__card) {
background:
radial-gradient(ellipse at 18% 30%, rgba(55, 79, 118, 0.28), transparent 28%),
radial-gradient(ellipse at 74% 16%, color-mix(in srgb, var(--driver-color, var(--brand)) 16%, transparent), transparent 34%),
linear-gradient(
135deg,
color-mix(in srgb, var(--driver-color, var(--brand)) 7%, var(--surface-sunken)) 0%,
color-mix(in srgb, var(--brand) 8%, var(--surface)) 58%,
color-mix(in srgb, var(--driver-color, var(--brand)) 9%, var(--surface-sunken)) 100%
);
border-color: color-mix(in srgb, var(--brand) 76%, var(--border));
}
:global(:root[data-theme="dark"] .carousel__info h3) {
color: var(--text);
}
:global(:root[data-theme="dark"] .carousel__info p) {
color: color-mix(in srgb, var(--text-muted) 82%, #ffffff);
}
:global(:root[data-theme="dark"] .carousel__auth) {
background: color-mix(in srgb, var(--driver-color, var(--brand)) 22%, rgba(255, 255, 255, 0.1));
color: color-mix(in srgb, var(--driver-color, var(--brand)) 52%, #dbeafe);
}
:global(:root[data-theme="dark"] .carousel__tag) {
background: color-mix(in srgb, var(--surface-muted) 86%, var(--driver-color, var(--brand)));
border-color: color-mix(in srgb, var(--driver-color, var(--brand)) 28%, var(--border));
color: color-mix(in srgb, var(--driver-color, var(--brand)) 42%, #d7e3f7);
box-shadow: none;
}
:global(:root[data-theme="dark"] .nav-arrow) {
background: color-mix(in srgb, var(--surface-muted) 92%, transparent);
color: color-mix(in srgb, var(--brand) 78%, #ffffff);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
}
:global(:root[data-theme="dark"] .mini-card) {
background: var(--surface-sunken);
border-color: var(--border);
}
:global(:root[data-theme="dark"] .mini-card:hover) {
background: var(--surface-hover);
}
:global(:root[data-theme="dark"] .mini-card.selected) {
background: color-mix(in srgb, var(--brand) 14%, var(--surface));
}
:root[data-skin="brutal"] .carousel,
:root[data-skin="brutal"] .carousel__card,
:root[data-skin="brutal"] .nav-arrow,
:root[data-skin="brutal"] .mini-card,
:root[data-skin="brutal"] .grid__empty,
:root[data-skin="brutal"] .view-toggle,
:root[data-skin="brutal"] .search-box__input,
:root[data-skin="brutal"] .search-box__clear {
border-radius: 0;
}
:root[data-skin="brutal"] .carousel__card,
:root[data-skin="brutal"] .mini-card,
:root[data-skin="brutal"] .grid__empty,
:root[data-skin="brutal"] .view-toggle,
:root[data-skin="brutal"] .search-box__input {
border-color: var(--brutal-ink);
}
:root[data-skin="brutal"] .carousel__card:hover,
:root[data-skin="brutal"] .mini-card:hover,
:root[data-skin="brutal"] .view-toggle:hover {
background: var(--brutal-yellow);
border-color: var(--brutal-ink);
box-shadow: 4px 4px 0 var(--brutal-ink);
transform: none;
}
:root[data-skin="brutal"] .carousel__card.selected,
:root[data-skin="brutal"] .mini-card.selected {
background: color-mix(in srgb, var(--brand) 12%, var(--surface));
border-color: var(--brand);
box-shadow: 4px 4px 0 color-mix(in srgb, var(--brand) 48%, var(--brutal-ink));
}
:root[data-skin="brutal"] .nav-arrow:hover,
:root[data-skin="brutal"] .search-box__clear:hover {
background: var(--brutal-yellow);
color: var(--brutal-ink);
transform: none;
}
:root[data-skin="brutal"] .search-box__input:focus {
border-color: var(--brand);
box-shadow: 4px 4px 0 color-mix(in srgb, var(--brand) 55%, var(--brutal-ink));
}
:root[data-skin="brutal"] .view-toggle rect {
rx: 0;
}
</style>