|
|
@@ -0,0 +1,221 @@
|
|
|
+<div class="ui-page">
|
|
|
+ <div class="mx-auto flex max-w-4xl flex-col gap-6 px-4 py-6 sm:py-8">
|
|
|
+ @include('filament.partials.page-header', [
|
|
|
+ 'kicker' => '教材配图',
|
|
|
+ 'title' => '管理配图',
|
|
|
+ 'subtitle' => '与「编辑教材」分开维护;可多次多选追加,排好序后一次保存到教材数据。',
|
|
|
+ 'actions' => new \Illuminate\Support\HtmlString(
|
|
|
+ '<a class="btn btn-ghost border border-slate-200 text-slate-700 hover:bg-slate-50" href="' . \App\Filament\Resources\TextbookResource::getUrl('edit', ['record' => $recordId]) . '">编辑教材信息</a>' .
|
|
|
+ '<a class="btn btn-outline border-slate-200" href="' . \App\Filament\Resources\TextbookResource::getUrl('index') . '">返回列表</a>'
|
|
|
+ ),
|
|
|
+ ])
|
|
|
+
|
|
|
+ @if($officialTitle !== '')
|
|
|
+ <div class="inline-flex max-w-full items-center gap-2 rounded-full border border-slate-200 bg-slate-50/90 px-4 py-2 text-sm text-slate-700">
|
|
|
+ <svg class="h-4 w-4 shrink-0 text-sky-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3A1.5 1.5 0 0 0 1.5 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008H12V8.25Z" />
|
|
|
+ </svg>
|
|
|
+ <span class="text-slate-500">当前教材</span>
|
|
|
+ <span class="truncate font-medium text-slate-900" title="{{ $officialTitle }}">{{ $officialTitle }}</span>
|
|
|
+ </div>
|
|
|
+ @endif
|
|
|
+
|
|
|
+ <div class="ui-card overflow-hidden">
|
|
|
+ <div class="ui-card-header !items-start !border-b-slate-100">
|
|
|
+ <div>
|
|
|
+ <div class="ui-section-title">上传与排序</div>
|
|
|
+ <p class="ui-subtitle mt-0.5 max-w-xl">支持 JPEG / PNG / WebP,单张不超过 5MB。第一张为封面主图。</p>
|
|
|
+ </div>
|
|
|
+ <span class="shrink-0 rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
|
|
|
+ 已选 {{ count($coverUrls) }} 张
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="ui-card-body space-y-8 !py-6">
|
|
|
+ {{-- 上传区 --}}
|
|
|
+ <div
|
|
|
+ class="rounded-2xl border border-slate-200 bg-gradient-to-b from-slate-50/80 to-white p-5 sm:p-6"
|
|
|
+ x-data="{
|
|
|
+ previews: [],
|
|
|
+ uploading: false,
|
|
|
+ progress: 0,
|
|
|
+ refreshPreviews(event) {
|
|
|
+ this.previews.forEach((p) => URL.revokeObjectURL(p.url))
|
|
|
+ const files = Array.from(event.target.files || [])
|
|
|
+ this.previews = files.map((file, idx) => ({
|
|
|
+ id: idx,
|
|
|
+ name: file.name,
|
|
|
+ sizeKb: Math.max(1, Math.round(file.size / 1024)),
|
|
|
+ url: URL.createObjectURL(file),
|
|
|
+ }))
|
|
|
+ },
|
|
|
+ clearPreviews() {
|
|
|
+ this.previews.forEach((p) => URL.revokeObjectURL(p.url))
|
|
|
+ this.previews = []
|
|
|
+ this.progress = 0
|
|
|
+ },
|
|
|
+ }"
|
|
|
+ x-on:livewire-upload-start="uploading = true; progress = 0"
|
|
|
+ x-on:livewire-upload-progress="uploading = true; progress = $event.detail.progress"
|
|
|
+ x-on:livewire-upload-finish="uploading = false; progress = 100"
|
|
|
+ x-on:livewire-upload-error="uploading = false"
|
|
|
+ >
|
|
|
+ <div class="flex gap-4" style="display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
|
|
|
+ <div class="space-y-3" style="flex:1 1 420px;min-width:320px;">
|
|
|
+ <label class="block">
|
|
|
+ <span class="text-sm font-medium text-slate-800">选择本地图片</span>
|
|
|
+ <span class="ml-1.5 text-xs font-normal text-slate-500">(可多选)</span>
|
|
|
+ </label>
|
|
|
+ <div class="flex flex-col gap-3 rounded-xl border-2 border-dashed border-slate-200 bg-white/80 p-4 transition-colors hover:border-slate-300">
|
|
|
+ <input
|
|
|
+ type="file"
|
|
|
+ wire:model="photos"
|
|
|
+ multiple
|
|
|
+ accept="image/jpeg,image/png,image/webp"
|
|
|
+ x-ref="photoInput"
|
|
|
+ x-on:change="refreshPreviews($event)"
|
|
|
+ class="block w-full cursor-pointer text-sm text-slate-600 file:mr-4 file:cursor-pointer file:rounded-lg file:border-0 file:bg-sky-50 file:px-4 file:py-2.5 file:text-sm file:font-medium file:text-sky-800 file:ring-1 file:ring-sky-200/80 hover:file:bg-sky-100"
|
|
|
+ />
|
|
|
+ <p class="text-xs text-slate-500" style="white-space:normal;word-break:normal;">可多次选择,每次点「加入列表」把本次选中的图追加到下方;全部满意后再点底部保存。</p>
|
|
|
+ </div>
|
|
|
+ <div class="space-y-2" x-show="previews.length > 0" x-cloak>
|
|
|
+ <div class="flex items-center justify-between">
|
|
|
+ <p class="text-xs font-medium text-slate-600">本次待上传预览</p>
|
|
|
+ <p class="text-xs text-slate-500" x-show="uploading">上传中:<span x-text="progress"></span>%</p>
|
|
|
+ </div>
|
|
|
+ <ul class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
|
|
+ <template x-for="item in previews" :key="item.id">
|
|
|
+ <li class="space-y-1">
|
|
|
+ <div
|
|
|
+ class="rounded-xl p-[2px] transition-all"
|
|
|
+ :style="uploading
|
|
|
+ ? `background: conic-gradient(#0ea5e9 ${progress * 3.6}deg, #e2e8f0 0deg);`
|
|
|
+ : 'background:#e2e8f0;'"
|
|
|
+ >
|
|
|
+ <div class="relative aspect-square overflow-hidden rounded-[10px] bg-white">
|
|
|
+ <img :src="item.url" alt="" class="h-full w-full object-cover" />
|
|
|
+ <div
|
|
|
+ class="absolute inset-0 flex items-center justify-center bg-slate-900/35 text-xs font-semibold text-white"
|
|
|
+ x-show="uploading"
|
|
|
+ >
|
|
|
+ <span x-text="`${progress}%`"></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <p class="truncate text-[11px] text-slate-500" :title="item.name" x-text="item.name"></p>
|
|
|
+ <p class="text-[10px] text-slate-400" x-text="`${item.sizeKb} KB`"></p>
|
|
|
+ </li>
|
|
|
+ </template>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ <div wire:loading wire:target="photos" class="flex items-center gap-2 text-sm text-slate-500">
|
|
|
+ <span class="loading loading-spinner loading-sm text-sky-500"></span>
|
|
|
+ 读取文件中…
|
|
|
+ </div>
|
|
|
+ @error('photos.*')
|
|
|
+ <p class="text-sm text-error">{{ $message }}</p>
|
|
|
+ @enderror
|
|
|
+ </div>
|
|
|
+ <div class="flex shrink-0 flex-col items-stretch gap-2" style="flex:0 0 170px;min-width:170px;">
|
|
|
+ <span class="hidden text-xs font-medium text-slate-500" aria-hidden="true">操作</span>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ wire:click="appendPhotos"
|
|
|
+ wire:loading.attr="disabled"
|
|
|
+ wire:target="appendPhotos,photos"
|
|
|
+ x-on:click="setTimeout(() => { if (!uploading) { clearPreviews(); if ($refs.photoInput) { $refs.photoInput.value = '' } } }, 300)"
|
|
|
+ class="btn btn-primary w-full border-0 bg-sky-500 text-white shadow-sm hover:bg-sky-600"
|
|
|
+ style="width:100%;"
|
|
|
+ >
|
|
|
+ <span wire:loading.remove wire:target="appendPhotos" class="inline-flex items-center justify-center gap-2">
|
|
|
+ <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
|
+ </svg>
|
|
|
+ 加入列表
|
|
|
+ </span>
|
|
|
+ <span wire:loading wire:target="appendPhotos" class="inline-flex items-center gap-2">
|
|
|
+ <span class="loading loading-spinner loading-sm"></span>
|
|
|
+ 处理中
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {{-- 预览列表 --}}
|
|
|
+ <div>
|
|
|
+ <div class="mb-4 flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 pb-3">
|
|
|
+ <h3 class="text-sm font-semibold text-slate-900">配图顺序</h3>
|
|
|
+ <p class="text-xs text-slate-500">首张通常为封面;可用上移 / 下移调整。</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ @if(count($coverUrls) === 0)
|
|
|
+ <div class="ui-empty bg-slate-50/50 py-12">
|
|
|
+ <div class="rounded-full bg-slate-100 p-4 text-slate-400">
|
|
|
+ <svg class="mx-auto h-10 w-10" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3A1.5 1.5 0 0 0 1.5 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008H12V8.25Z" />
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <p class="ui-empty-title">暂无配图</p>
|
|
|
+ <p class="ui-empty-desc max-w-sm">从上方选择图片并「加入列表」,或先在存储/API 侧同步后再刷新本页。</p>
|
|
|
+ </div>
|
|
|
+ @else
|
|
|
+ <ul class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
|
+ @foreach($coverUrls as $i => $url)
|
|
|
+ <li class="group overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm ring-slate-100 transition hover:border-slate-300 hover:shadow-md">
|
|
|
+ <div class="relative aspect-[3/4] w-full overflow-hidden bg-slate-100">
|
|
|
+ @if($i === 0)
|
|
|
+ <span class="absolute left-2 top-2 z-10 rounded-md bg-sky-500 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white shadow-sm">封面</span>
|
|
|
+ @endif
|
|
|
+ <img src="{{ $url }}" alt="" class="h-full w-full object-contain" loading="lazy" />
|
|
|
+ </div>
|
|
|
+ <div class="border-t border-slate-100 p-3">
|
|
|
+ <p class="truncate font-mono text-[11px] text-slate-400" title="{{ $url }}">{{ \Illuminate\Support\Str::limit($url, 36) }}</p>
|
|
|
+ <div class="mt-2 flex flex-wrap gap-1">
|
|
|
+ <button type="button" wire:click="moveUp({{ $i }})" class="btn btn-ghost btn-xs h-7 min-h-0 gap-0 border border-transparent px-2 hover:border-slate-200" @if($i === 0) disabled @endif>上移</button>
|
|
|
+ <button type="button" wire:click="moveDown({{ $i }})" class="btn btn-ghost btn-xs h-7 min-h-0 gap-0 border border-transparent px-2 hover:border-slate-200" @if($i === count($coverUrls) - 1) disabled @endif>下移</button>
|
|
|
+ <button type="button" wire:click="removeAt({{ $i }})" class="btn btn-ghost btn-xs h-7 min-h-0 text-rose-600 hover:bg-rose-50">移除</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </li>
|
|
|
+ @endforeach
|
|
|
+ </ul>
|
|
|
+ @endif
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="border-t border-slate-100 pt-5">
|
|
|
+ <p class="text-xs text-slate-500" style="display:block;max-width:100%;white-space:normal;word-break:normal;">保存后会写入教材的配图字段;移除仅影响本次会话直至保存。</p>
|
|
|
+ <div class="mt-3 flex justify-end">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ wire:click="saveCovers"
|
|
|
+ wire:loading.attr="disabled"
|
|
|
+ wire:target="saveCovers"
|
|
|
+ class="btn btn-primary border-0 bg-sky-500 px-8 text-white shadow-sm hover:bg-sky-600"
|
|
|
+ style="min-width:220px;"
|
|
|
+ >
|
|
|
+ <span wire:loading.remove wire:target="saveCovers" class="inline-flex items-center justify-center gap-2">
|
|
|
+ <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
|
+ </svg>
|
|
|
+ 保存到服务器
|
|
|
+ </span>
|
|
|
+ <span wire:loading wire:target="saveCovers" class="inline-flex items-center gap-2">
|
|
|
+ <span class="loading loading-spinner loading-sm"></span>
|
|
|
+ 保存中…
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div class="mt-3">
|
|
|
+ <p wire:loading wire:target="saveCovers" class="text-xs text-sky-600">正在保存到服务器,请稍候…</p>
|
|
|
+ @if($saveFeedback)
|
|
|
+ <p class="text-xs {{ $saveFeedbackType === 'success' ? 'text-emerald-600' : ($saveFeedbackType === 'error' ? 'text-rose-600' : 'text-amber-600') }}">
|
|
|
+ {{ $saveFeedback }}
|
|
|
+ </p>
|
|
|
+ @endif
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</div>
|