feat: 背景来源——本地上传 / 预设几何 / Unsplash 搜索

SourcePanel 常驻在右栏「背景」一节,三个来源任选:

- 本地上传:文件选择 + 全窗口拖放,转 object URL 装进画布,
  换图与卸载时释放旧的 blob 地址
- 预设几何:四种底纹(斜向圆角条纹 / 噪点渐变 / 网格线 / 同心弧),
  用标题字符串作种子派生配色,同种子像素级确定——纯函数派生+渲染,
  DOM 只留在薄薄一层出口,Node 侧可直接验确定性;色相按前缀分桶,
  同系列标题自动落在相邻色区
- Unsplash 搜索:Access Key 读 VITE_UNSPLASH_KEY,未配置只显示提示、
  另两个来源照常可用;缩略图网格点选装 regular 尺寸;选定即触发
  download 统计(条款义务);署名不画进图,面板上给复制按钮
This commit is contained in:
2026-09-04 16:10:37 +08:00
parent 95eeb69ff8
commit d072067b00
5 changed files with 1152 additions and 22 deletions
+527 -8
View File
@@ -1,22 +1,541 @@
<script setup lang="ts">
/**
* 【T4 待实现 — 背景来源面板】
* 【T4 — 背景来源面板】
*
* 三个来源:本地上传、预设几何、Unsplash 搜索。选中后调
* `useEditor().addImage(asset.src)` 装进画布。
* 三个来源:本地上传、预设几何、Unsplash 搜索。任一来源选中的素材都调
* `useEditor().addImage(asset.src)` 装进画布(按 cover 方式铺满并选中)。
*
* Unsplash 的署名**不画进图里**:把 `asset.attribution.text` 显示在面板上,
* 旁边一个复制按钮,用户自己贴进文章。
* - 上传:文件选择 + 全窗口拖放。object URL 换图时释放旧的。
* - 预设几何:种子输入(建议填标题)+ 四张实时缩略图。同种子必同图。
* - Unsplash:没配 key 只显示提示,另外两个来源照常可用;
* 搜索结果点一张装 `regular` 尺寸;选定即触发 download 统计(条款义务);
* 署名不画进图,显示在面板上给复制按钮。
*
* 只依赖 src/style.css 里的变量与 .field / .row 类,不自带颜色。
*/
import { onBeforeUnmount, onMounted, reactive, ref, watch, type WatchStopHandle } from "vue";
import { useEditor } from "@/editor/useEditor";
import { fromUpload, releaseObjectUrl } from "@/sources/upload";
import {
GEOMETRY_KINDS,
deriveGeometryParams,
geometryToDataUrl,
createGeometryAsset,
type GeometryKind,
} from "@/sources/geometry";
import {
unsplashStatus,
searchUnsplash,
trackDownload,
toAsset,
type UnsplashPhoto,
} from "@/sources/unsplash";
import type { Attribution, BackgroundAsset } from "@/types";
const { addImage, size } = useEditor();
/* ---------------------------------------------------------------- 通用 */
const busy = ref(false);
const busyLabel = ref("");
const notice = ref(""); // 非阻塞提示/错误,下一次动作清掉
const lastAttribution = ref<Attribution | null>(null);
function errorText(err: unknown, fallback: string): string {
return err instanceof Error && err.message ? err.message : fallback;
}
/** 三个来源共用的一条路径:素材 → 替换旧上传 URL → 进画布 → 记署名。 */
async function place(asset: BackgroundAsset, label: string) {
if (busy.value) return;
busyLabel.value = label;
busy.value = true;
notice.value = "";
try {
// 换背景 = 替换:上一张本地上传的 object URL 不再需要,释放掉。
if (lastUploadUrl.value && lastUploadUrl.value !== asset.src) {
releaseObjectUrl(lastUploadUrl.value);
}
lastUploadUrl.value = asset.kind === "upload" ? asset.src : "";
await addImage(asset.src);
lastAttribution.value = asset.attribution ?? null;
} catch (err) {
notice.value = errorText(err, "图片没能装进画布");
} finally {
busy.value = false;
}
}
/* ---------------------------------------------------------------- 本地上传 */
const fileInput = ref<HTMLInputElement | null>(null);
const lastUploadUrl = ref("");
function onFilePick(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ""; // 允许重复选同一个文件
if (file) void applyFile(file);
}
async function applyFile(file: File) {
if (busy.value) return;
if (!file.type.startsWith("image/")) {
notice.value = "只能放图片文件";
return;
}
busyLabel.value = "读取图片…";
busy.value = true;
notice.value = "";
let asset: BackgroundAsset;
try {
asset = await fromUpload(file);
} catch (err) {
notice.value = errorText(err, "图片读不出来");
busy.value = false;
return;
}
busy.value = false;
await place(asset, "载入图片…");
}
/* 全窗口拖放:文件拖进窗口任意处即作为背景载入。
面板常驻显示,所以在面板上挂 window 级监听即可覆盖画布区。 */
const dragging = ref(false);
let dragDepth = 0;
function hasFiles(dt: DataTransfer | null): boolean {
return !!dt && dt.types.includes("Files");
}
/** 落到原生 file input 上的拖放交给 input 自己处理,别抢。 */
function onNativeInput(e: Event): boolean {
const el = e.target as Element | null;
return !!el && !!el.closest?.('input[type="file"]');
}
function onDragEnter(e: DragEvent) {
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
dragDepth++;
dragging.value = true;
}
function onDragOver(e: DragEvent) {
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
e.preventDefault(); // 不放行就没有 drop 事件
}
function onDragLeave(e: DragEvent) {
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) dragging.value = false;
}
function onDrop(e: DragEvent) {
dragDepth = 0;
dragging.value = false;
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
const file = Array.from(e.dataTransfer?.files ?? []).find((f) =>
f.type.startsWith("image/"),
);
if (!file) {
notice.value = "拖进来的不是图片";
return;
}
void applyFile(file);
}
/* ---------------------------------------------------------------- 预设几何 */
const seedText = ref("");
const THUMB_W = 320;
const thumbH = () =>
Math.max(72, Math.round((THUMB_W * size.value.height) / size.value.width));
const thumbs = reactive<Record<GeometryKind, string>>({
stripes: "",
noise: "",
grid: "",
arcs: "",
});
let thumbTimer: number | undefined;
function refreshThumbs() {
window.clearTimeout(thumbTimer);
thumbTimer = window.setTimeout(() => {
for (const k of GEOMETRY_KINDS) {
const params = deriveGeometryParams(seedText.value, k.id, THUMB_W, thumbH());
thumbs[k.id] = geometryToDataUrl(params);
}
}, 120);
}
function randomizeSeed() {
const pool = "abcdefghjkmnpqrstuvwxyz23456789";
let s = "";
for (let i = 0; i < 6; i++) s += pool[Math.floor(Math.random() * pool.length)];
seedText.value = s;
}
function addGeometry(kind: GeometryKind) {
if (busy.value) return;
try {
const asset = createGeometryAsset(
seedText.value,
kind,
size.value.width,
size.value.height,
);
void place(asset, "生成几何底纹…");
} catch (err) {
notice.value = errorText(err, "几何底纹生成失败");
}
}
/* ---------------------------------------------------------------- Unsplash */
const unsplashState = ref(unsplashStatus());
const query = ref("");
const results = ref<UnsplashPhoto[]>([]);
const searchedFor = ref("");
const searching = ref(false);
const searchError = ref("");
async function doSearch() {
const q = query.value.trim();
if (!q || searching.value || busy.value) return;
searchError.value = "";
searchedFor.value = "";
searching.value = true;
try {
const hits = await searchUnsplash(q);
results.value = hits;
searchedFor.value = q;
} catch (err) {
searchError.value = errorText(err, "搜索失败");
results.value = [];
} finally {
searching.value = false;
}
}
async function pickPhoto(photo: UnsplashPhoto) {
if (busy.value) return;
trackDownload(photo); // 条款义务:用户选定即触发 download 统计
await place(toAsset(photo), "载入 Unsplash 图…");
}
/* ---------------------------------------------------------------- 署名复制 */
const copied = ref(false);
async function copyAttribution(a: Attribution) {
const text = a.text;
try {
await navigator.clipboard.writeText(text);
} catch {
// 非安全上下文(http 局域网)兜底
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
copied.value = true;
window.setTimeout(() => (copied.value = false), 1500);
}
/* ---------------------------------------------------------------- 生命周期 */
let stopWatch: WatchStopHandle | undefined;
onMounted(() => {
window.addEventListener("dragenter", onDragEnter);
window.addEventListener("dragover", onDragOver);
window.addEventListener("dragleave", onDragLeave);
window.addEventListener("drop", onDrop);
refreshThumbs();
stopWatch = watch(
[() => seedText.value, () => size.value.width, () => size.value.height],
refreshThumbs,
);
});
onBeforeUnmount(() => {
window.removeEventListener("dragenter", onDragEnter);
window.removeEventListener("dragover", onDragOver);
window.removeEventListener("dragleave", onDragLeave);
window.removeEventListener("drop", onDrop);
window.clearTimeout(thumbTimer);
stopWatch?.();
if (lastUploadUrl.value) releaseObjectUrl(lastUploadUrl.value);
});
</script>
<template>
<p class="todo">T4 待实现:上传 / 预设几何 / Unsplash</p>
<div class="source-panel">
<p v-if="notice" class="notice">{{ notice }}</p>
<!-- ============ 本地上传 ============ -->
<h3 class="block-title">本地上传</h3>
<div class="field">
<button class="wide" :disabled="busy" @click="fileInput?.click()">选择图片文件…</button>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="onFilePick"
/>
<p class="hint">或直接把图片拖进窗口任意位置,自动铺满画布。换图时旧地址自动释放。</p>
</div>
<!-- ============ 预设几何 ============ -->
<h3 class="block-title">预设几何</h3>
<div class="field">
<div class="row">
<input
v-model="seedText"
placeholder="配色种子,建议填标题"
:disabled="busy"
/>
<button class="mini" title="随机一组配色(仍是确定性的:同种子同图)" :disabled="busy" @click="randomizeSeed">
随机
</button>
</div>
<div class="thumbs">
<button
v-for="k in GEOMETRY_KINDS"
:key="k.id"
class="thumb"
:style="{ aspectRatio: `${THUMB_W} / ${thumbH()}` }"
:disabled="busy"
:title="k.label"
@click="addGeometry(k.id)"
>
<img :src="thumbs[k.id]" :alt="k.label" />
<span class="thumb-label">{{ k.label }}</span>
</button>
</div>
</div>
<!-- ============ Unsplash 搜索 ============ -->
<h3 class="block-title">Unsplash 搜索</h3>
<template v-if="unsplashState === 'ready'">
<div class="field row">
<input
v-model="query"
placeholder="搜什么关键词"
:disabled="searching || busy"
@keyup.enter="doSearch"
/>
<button :disabled="searching || busy || !query.trim()" @click="doSearch">搜索</button>
</div>
<p v-if="searching" class="hint">搜索中…</p>
<p v-else-if="searchError" class="hint">{{ searchError }}</p>
<p v-else-if="searchedFor" class="hint">
「{{ searchedFor }}」{{ results.length }} 张,点一张装进画布
</p>
<div v-if="results.length" class="results">
<button
v-for="ph in results"
:key="ph.id"
class="shot"
:disabled="busy"
:title="ph.altDescription ?? ph.description ?? ph.authorName"
@click="pickPhoto(ph)"
>
<img :src="ph.thumbUrl" :alt="ph.altDescription ?? ph.authorName" loading="lazy" />
</button>
</div>
</template>
<p v-else class="hint">
未配置 Unsplash Key —— 在仓库根目录 <code>.env.local</code> 里写
<code>VITE_UNSPLASH_KEY=…</code> 后重启 dev。本地上传与预设几何不受影响。
</p>
<!-- ============ 署名(不画进图) ============ -->
<div v-if="lastAttribution" class="field attrib">
<label>署名 · 复制进文章正文</label>
<div class="attrib-row row">
<code class="attrib-text">{{ lastAttribution.text }}</code>
<button class="mini" @click="copyAttribution(lastAttribution)">
{{ copied ? "已复制" : "复制" }}
</button>
</div>
<a
v-if="lastAttribution.url"
class="attrib-link"
:href="lastAttribution.url"
target="_blank"
rel="noreferrer"
>打开原图页 ↗</a>
</div>
<p v-if="busy" class="busy">{{ busyLabel }}</p>
<!-- 拖放提示层:文件悬停在窗口上时给个明确信号 -->
<Teleport to="body">
<div v-if="dragging" class="drop-veil" aria-hidden="true">
<div class="drop-hint">松开鼠标:把这张图作为封面背景</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.todo {
.source-panel {
display: flex;
flex-direction: column;
gap: 4px;
}
.block-title {
margin: 10px 0 6px;
font-size: 12px;
font-weight: 600;
color: var(--fg-dim);
margin: 0;
}
.block-title:first-child {
margin-top: 0;
}
.hidden {
display: none;
}
.hint {
color: var(--fg-dim);
line-height: 1.6;
margin: 6px 0 0;
word-break: break-word;
}
.hint code {
color: var(--fg);
background: var(--bg3);
border-radius: 3px;
padding: 0 4px;
font-size: 12px;
}
.notice {
color: var(--fg-dim);
background: var(--bg3);
border-left: 2px solid var(--line);
margin: 0 0 8px;
padding: 4px 8px;
line-height: 1.6;
border-radius: 0 var(--radius) var(--radius) 0;
}
.wide {
width: 100%;
}
.mini {
padding: 4px 8px;
white-space: nowrap;
}
/* 几何缩略图:2×2 */
.thumbs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.thumb {
position: relative;
display: block;
width: 100%;
padding: 0;
overflow: hidden;
border-radius: var(--radius);
line-height: 0;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-label {
position: absolute;
left: 6px;
bottom: 5px;
color: var(--fg);
font-size: 11px;
line-height: 1.2;
text-shadow: 0 1px 3px rgb(0 0 0 / 0.7);
}
/* Unsplash 结果网格:3 列 */
.results {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-top: 8px;
}
.shot {
position: relative;
aspect-ratio: 4 / 3;
padding: 0;
overflow: hidden;
border-radius: var(--radius);
background: var(--bg);
}
.shot img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 署名块 */
.attrib {
border-top: 1px solid var(--line);
padding-top: 8px;
margin-top: 2px;
}
.attrib-row {
gap: 6px;
}
.attrib-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
background: var(--bg3);
border-radius: var(--radius);
padding: 3px 6px;
font-size: 12px;
}
.attrib-link {
display: inline-block;
margin-top: 4px;
color: var(--fg-dim);
}
.busy {
margin: 6px 0 0;
color: var(--fg-dim);
font-style: italic;
}
/* 全窗口拖放提示(fixed 盖住整个 app,不拦鼠标) */
.drop-veil {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
pointer-events: none;
}
.drop-hint {
background: var(--bg2);
border: 1px dashed var(--accent);
color: var(--fg);
border-radius: var(--radius);
padding: 12px 18px;
font-size: 14px;
box-shadow: 0 8px 32px rgb(0 0 0 / 0.45);
}
</style>