383 lines
8.3 KiB
Svelte
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>
|