Files
blogflare/src/lib/components/admin/FeaturedImagePicker.svelte
T

383 lines
8.3 KiB
Svelte

<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { addToast } from '$lib/stores/toast.svelte';
import { onMount } from 'svelte';
import Tabs from './Tabs.svelte';
let {
cover = $bindable(''),
title = '特色图',
inputName = 'cover',
aspectRatio = '16/9',
adaptiveHeight = false,
previewable = false
} = $props<{
cover?: string | null;
title?: string;
inputName?: string;
aspectRatio?: string;
adaptiveHeight?: boolean;
previewable?: boolean;
}>();
let pickerElement = $state<HTMLDivElement>();
let previewHeightRatio = $derived(
(() => {
const [width, height] = aspectRatio.split('/').map(Number);
return width > 0 && height > 0 ? height / width : 9 / 16;
})()
);
let activeTab = $state('url');
let isUploading = $state(false);
let images = $state<any[]>([]);
let page = $state(1);
let hasMore = $state(true);
let isLoadingLibrary = $state(false);
onMount(() => {
if (!previewable || !pickerElement) return;
const selector = '[data-fancybox="featured-image-preview"]';
const container = pickerElement;
let FancyboxInstance: unknown = null;
import('@fancyapps/ui/dist/index.js').then(async ({ Fancybox }) => {
await import('@fancyapps/ui/dist/fancybox/fancybox.css');
FancyboxInstance = Fancybox;
Fancybox.bind(container, selector, {});
});
return () => {
if (FancyboxInstance) {
(FancyboxInstance as any).unbind(container, selector);
}
};
});
async function loadLibrary(reset = false) {
if (reset) {
page = 1;
images = [];
hasMore = true;
}
if (!hasMore || isLoadingLibrary) return;
isLoadingLibrary = true;
try {
const res = await fetch(`/admin/api/images?page=${page}&limit=12`);
const data = (await res.json()) as any;
if (data.success) {
images = [...images, ...data.images];
if (page >= data.pagination.totalPages) {
hasMore = false;
} else {
page++;
}
}
} catch (err: any) {
console.error(err);
addToast('加载图库失败', 'error');
} finally {
isLoadingLibrary = false;
}
}
function handleTabChange(tab: string) {
activeTab = tab;
if (tab === 'library' && images.length === 0) {
loadLibrary(true);
}
}
async function handleUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
isUploading = true;
try {
const res = await fetch('/admin/api/upload', {
method: 'POST',
body: formData
});
const data = (await res.json()) as any;
if (data.success) {
cover = data.url;
addToast('特色图上传成功', 'success');
activeTab = 'url';
} else {
addToast(data.error || '上传失败', 'error');
}
} catch (err: any) {
console.error(err);
addToast('网络错误,上传失败', 'error');
} finally {
isUploading = false;
input.value = '';
}
}
</script>
<div class="sidebar-box" bind:this={pickerElement}>
{#if title}
<h3>{title}</h3>
{/if}
{#if cover}
<div
class="cover-preview"
class:adaptive-height={adaptiveHeight}
style={`--preview-aspect-ratio: ${aspectRatio}; --preview-height-ratio: ${previewHeightRatio}`}
>
{#if previewable}
<a
class="cover-preview-link"
href={cover}
rel="external"
data-fancybox="featured-image-preview"
data-caption={title || '特色图'}
aria-label="查看特色图大图"
title="点击查看大图"
>
<img src={cover} alt="图片预览" />
</a>
{:else}
<img src={cover} alt="图片预览" />
{/if}
<button type="button" class="btn-clear" onclick={() => (cover = '')}>移除</button>
</div>
{/if}
<Tabs
tabs={[
{ id: 'url', label: '链接' },
{ id: 'upload', label: '直接上传' },
{ id: 'library', label: '图库选择' }
]}
bind:activeTab
fullWidth={true}
small={true}
onchange={handleTabChange}
/>
<div class="tab-content">
{#if activeTab === 'url'}
<!-- 用 text 而非 url:该输入框不参与提交(无 name),但 type=url 的原生校验会
因为相对路径等非法值而静默拦截整个表单提交 -->
<input
type="text"
inputmode="url"
placeholder="输入或粘贴图片链接"
bind:value={cover}
class="full-width-input"
/>
{:else if activeTab === 'upload'}
<div class="upload-area" class:disabled={isUploading}>
<label>
<span>{isUploading ? '正在上传...' : '点击选择图片上传'}</span>
<input
type="file"
accept="image/*"
onchange={handleUpload}
disabled={isUploading}
style="display: none"
/>
</label>
</div>
{:else if activeTab === 'library'}
<div class="library-grid">
{#each images as img (img.id || img)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="library-item"
onclick={() => {
cover = img.url;
activeTab = 'url';
}}
title={img.filename}
>
<img src={img.url} alt={img.filename} loading="lazy" />
</div>
{/each}
</div>
{#if hasMore}
<button
type="button"
class="btn-load-more"
onclick={() => loadLibrary()}
disabled={isLoadingLibrary}
>
{isLoadingLibrary ? '加载中...' : '加载更多'}
</button>
{/if}
{#if !isLoadingLibrary && images.length === 0}
<div class="empty-text">附件库暂无图片</div>
{/if}
{/if}
</div>
<!-- 隐藏表单项,随大表单一起提交 -->
<input type="hidden" name={inputName} value={cover || ''} />
</div>
<style>
.sidebar-box h3 {
margin: 0 0 12px 0;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-color);
}
.cover-preview {
margin-bottom: 12px;
position: relative;
border: 2px solid var(--text-color);
background: #f5f5f5;
overflow: hidden;
}
.cover-preview.adaptive-height {
container-type: inline-size;
}
.cover-preview-link {
display: block;
background: #f5f5f5;
cursor: zoom-in;
}
.cover-preview img {
width: 100%;
height: auto;
display: block;
object-fit: contain;
max-height: 280px;
}
.cover-preview:not(.adaptive-height) img {
aspect-ratio: var(--preview-aspect-ratio);
}
.cover-preview.adaptive-height img {
max-height: min(280px, calc(100cqw * var(--preview-height-ratio)));
}
.btn-clear {
position: absolute;
top: 8px;
right: 8px;
background: rgba(217, 83, 79, 0.9);
color: #fff;
border: 1px solid rgba(0, 0, 0, 0.8);
border-radius: 0;
padding: 4px 10px;
font-size: 0.8rem;
cursor: pointer;
font-weight: 600;
backdrop-filter: blur(4px);
}
.btn-clear:hover {
background: rgba(217, 83, 79, 1);
}
.full-width-input {
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.015);
font-family: inherit;
box-sizing: border-box;
border-radius: 0;
transition:
border-color 0.3s ease,
box-shadow 0.3s ease;
}
.full-width-input:focus {
outline: none;
border-color: var(--accent-color, #419b38);
box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
}
.upload-area label {
display: block;
border: 2px dashed var(--text-color);
padding: 30px 10px;
text-align: center;
cursor: pointer;
background: var(--bg-color);
transition: background 0.2s;
font-weight: 600;
}
.upload-area label:hover {
background: rgba(0, 0, 0, 0.03);
}
.upload-area.disabled label {
opacity: 0.6;
cursor: not-allowed;
}
.library-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
margin-bottom: 10px;
max-height: 250px;
overflow-y: auto;
}
.library-item {
aspect-ratio: 1;
border: 2px solid var(--text-color);
cursor: pointer;
transition: border-color 0.2s;
}
.library-item:hover {
border-color: var(--accent-color, #419b38);
}
.library-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.btn-load-more {
width: 100%;
padding: 6px;
background: transparent;
border: 2px solid var(--text-color);
cursor: pointer;
font-family: inherit;
font-weight: 600;
}
.btn-load-more:hover {
background: var(--text-color);
color: var(--bg-color);
}
.empty-text {
text-align: center;
padding: 20px 0;
color: var(--text-muted);
font-size: 0.9rem;
}
</style>