Merge branch 't13-background'

This commit is contained in:
2026-09-05 18:25:02 +08:00
2 changed files with 693 additions and 22 deletions
+552 -2
View File
@@ -1,7 +1,557 @@
<script setup lang="ts">
/** 【占位】BackgroundMenu 的内容由对应开发线实现。见任务书。 */
/**
* 【T13 — 顶栏「背景」下拉面板】
*
* 这是 `Dropdown` 的 panel 插槽内容(T11 在 TopBar 里包 Dropdown),所以根元素
* 直接就是面板内容,不在这里自己套 Dropdown。行样式用 style.css 里的全局
* `.menu-*` 类(作用域样式够不到插槽内容)。
*
* 四段:纯色 / 预设 / 上传 / 图库。核心语义是**换背景 = 替换那一个专用背景图层**,
* 不是再加一层——这里所有入口都走 `useBackground().setBackground/setSolid`。
*/
import { computed, ref } from "vue";
import { useEditor } from "@/editor/useEditor";
import { useBackground } from "@/editor/useBackground";
import { fromUpload } from "@/sources/upload";
import {
createGeometryAsset,
deriveGeometryParams,
geometryToDataUrl,
type GeometryKind,
} from "@/sources/geometry";
import { PHOTO_PROVIDERS, PhotoProviderError } from "@/sources/providers";
import type { Attribution, BackgroundAsset, PhotoProvider, PhotoProviderId, StockPhoto } from "@/types";
import { IconChevronDown, IconGeometry, IconSearch, IconUpload } from "@/ui/icons";
const { size } = useEditor();
const { setBackground, setSolid } = useBackground();
/* ---------------------------------------------------------------- 通用 */
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;
}
/** 一条素材 → 那一个背景图层。成功记一步历史(setBackground 里 commit)。 */
async function applyAsset(asset: BackgroundAsset, label: string) {
if (busy.value) return;
busy.value = true;
busyLabel.value = label;
notice.value = "";
try {
await setBackground(asset);
lastAttribution.value = asset.attribution ?? null;
} catch (err) {
notice.value = errorText(err, "图片没能装进画布");
} finally {
busy.value = false;
}
}
/* ---------------------------------------------------------------- 纯色 */
const solidColor = ref("#1b1b1f"); // 默认和画板底色一致
function onSolidChange(e: Event) {
const v = (e.target as HTMLInputElement).value;
solidColor.value = v;
// 色板拖动只预览,松手(change)才真正替换并记一步历史,避免拖一帧记一步。
setSolid(v);
}
/** 色板拖动时同步 swatch 的受控值,不 commit。 */
function onSolidInput(e: Event) {
solidColor.value = (e.target as HTMLInputElement).value;
}
/* ---------------------------------------------------------------- 预设 */
interface GeometryPreset {
id: string;
label: string;
kind: GeometryKind;
/**
* 固定种子字符串。它的 FNV hash 决定色相(见 geometry.ts 的 deriveGeometryParams),
* 下面这些词是特意挑来把 12 个预设分散在色环上的;label 只是展示名,不必和 seed 同字。
*/
seed: string;
}
/**
* 12 个预设底纹 = 4 种图案 × 3 档色相,风格参照 color4bg.com 那类「适合当封面
* 底图的抽象渐变 / 网格 / 光斑」。配色已经由 deriveGeometryParams 压暗降饱和,
* 保证封面上压白字可读——这是 t4-sources 就定下的约束,别改回去。
*/
const PRESETS: GeometryPreset[] = [
{ id: "p-stripe-red", label: "绯红斜纹", kind: "stripes", seed: "琥珀" },
{ id: "p-stripe-cyan", label: "雾青斜纹", kind: "stripes", seed: "雾蓝" },
{ id: "p-stripe-violet", label: "墨紫斜纹", kind: "stripes", seed: "石墨" },
{ id: "p-noise-olive", label: "橄榄噪点", kind: "noise", seed: "赭石" },
{ id: "p-noise-moss", label: "苔绿噪点", kind: "noise", seed: "松绿" },
{ id: "p-noise-rose", label: "玫红噪点", kind: "noise", seed: "极光" },
{ id: "p-grid-navy", label: "藏青网格", kind: "grid", seed: "藏青" },
{ id: "p-grid-indigo", label: "靛蓝网格", kind: "grid", seed: "靛蓝" },
{ id: "p-grid-smoke", label: "烟紫网格", kind: "grid", seed: "胭脂" },
{ id: "p-arc-pine", label: "松青光弧", kind: "arcs", seed: "群青" },
{ id: "p-arc-dusk", label: "暮蓝光弧", kind: "arcs", seed: "焦糖" },
{ id: "p-arc-magenta", label: "品红光弧", kind: "arcs", seed: "烟紫" },
];
const THUMB_W = 160;
const THUMB_H = 100;
/** 预设 + 它的缩略图(就是那个渐变本身,确定性生成,computed 缓存不重复渲)。 */
const presetThumbs = computed(() =>
PRESETS.map((p) => ({
...p,
thumb: geometryToDataUrl(deriveGeometryParams(p.seed, p.kind, THUMB_W, THUMB_H)),
})),
);
function applyPreset(p: GeometryPreset) {
if (busy.value) return;
try {
const asset = createGeometryAsset(p.seed, p.kind, size.value.width, size.value.height);
void applyAsset(asset, "生成预设底纹…");
} catch (err) {
notice.value = errorText(err, "预设底纹生成失败");
}
}
/* ---------------------------------------------------------------- 上传 */
const fileInput = ref<HTMLInputElement | null>(null);
/**
* 本会话上传产生的 object URL。**刻意不 revoke**:撤销 / 模板重载时 fabric 还要按
* src 重新取图,提前放掉会让历史里的背景变空白。页面卸载时浏览器统一回收,
* 泄漏只限本会话、受上传次数约束。这里不能用组件的 onBeforeUnmount 释放——
* 下拉是 v-if 挂载的,每次开合都会卸载,那时放掉同样会废掉撤销。
*/
const liveUploadUrls: string[] = [];
async function onFilePick(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ""; // 允许重复选同一个文件
if (!file || busy.value) return;
try {
const asset = await fromUpload(file);
liveUploadUrls.push(asset.src);
await applyAsset(asset, "载入图片…");
} catch (err) {
notice.value = errorText(err, "图片没能装进画布");
}
}
/* ---------------------------------------------------------------- 图库 */
const providers = PHOTO_PROVIDERS;
const galleryOpen = ref(false);
const activeProviderId = ref<PhotoProviderId | null>(providers[0]?.id ?? null);
const activeProvider = computed<PhotoProvider | null>(
() => providers.find((p) => p.id === activeProviderId.value) ?? providers[0] ?? null,
);
const query = ref("");
const results = ref<StockPhoto[]>([]);
const page = ref(1);
const searching = ref(false);
const loadingMore = ref(false);
const searchError = ref("");
function selectProvider(id: PhotoProviderId) {
activeProviderId.value = id;
query.value = "";
results.value = [];
page.value = 1;
searchError.value = "";
}
async function doSearch() {
const provider = activeProvider.value;
const q = query.value.trim();
if (!provider || !q || searching.value || busy.value) return;
searchError.value = "";
searching.value = true;
try {
results.value = await provider.search(q, 1);
page.value = 1;
} catch (err) {
// PhotoProviderError 的 message 是给用户看的一句话,直接显示。
searchError.value = err instanceof PhotoProviderError ? err.message : errorText(err, "搜索失败");
results.value = [];
} finally {
searching.value = false;
}
}
async function loadMore() {
const provider = activeProvider.value;
const q = query.value.trim();
if (!provider || !q || loadingMore.value || searching.value) return;
loadingMore.value = true;
try {
const next = await provider.search(q, page.value + 1);
results.value = results.value.concat(next);
page.value += 1;
} catch (err) {
searchError.value = err instanceof PhotoProviderError ? err.message : "加载更多失败";
} finally {
loadingMore.value = false;
}
}
async function pickPhoto(photo: StockPhoto) {
const provider = activeProvider.value;
if (!provider || busy.value) return;
provider.notifyDownload?.(photo); // 条款义务,无条件调,不判断是哪家
await applyAsset(provider.toAsset(photo), "载入图库图片…");
}
/* ---------------------------------------------------------------- 署名复制 */
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);
}
</script>
<template>
<div class="menu-block">(BackgroundMenu 待实现)</div>
<div class="bg-menu">
<p v-if="notice" class="notice">{{ notice }}</p>
<!-- 1. 纯色 -->
<label class="menu-row" title="纯色背景">
<IconGeometry />
<span>纯色</span>
<input
type="color"
class="swatch"
:value="solidColor"
aria-label="纯色背景"
@input="onSolidInput"
@change="onSolidChange"
/>
</label>
<div class="menu-sep"></div>
<!-- 2. 预设(3 列卡片网格,缩略图就是那个渐变) -->
<div class="menu-block">
<div class="preset-grid">
<button
v-for="p in presetThumbs"
:key="p.id"
class="preset"
:disabled="busy"
:title="p.label"
@click="applyPreset(p)"
>
<img :src="p.thumb" :alt="p.label" />
<span class="preset-label">{{ p.label }}</span>
</button>
</div>
</div>
<div class="menu-sep"></div>
<!-- 3. 上传 -->
<button class="menu-row" :disabled="busy" @click="fileInput?.click()">
<IconUpload />
<span>从本地选择</span>
</button>
<input ref="fileInput" type="file" accept="image/*" class="hidden-input" @change="onFilePick" />
<div class="menu-sep"></div>
<!-- 4. 图库 -->
<button class="menu-row" :aria-expanded="galleryOpen" @click="galleryOpen = !galleryOpen">
<IconSearch />
<span>图库搜索</span>
<IconChevronDown class="menu-aside" :class="{ rot: galleryOpen }" />
</button>
<div v-if="galleryOpen" class="menu-block gallery">
<p v-if="providers.length === 0" class="hint">图库还没接入,先用上面的上传 / 预设。</p>
<template v-else>
<div class="tabs">
<button
v-for="p in providers"
:key="p.id"
class="tab"
:class="{ on: p.id === activeProviderId }"
@click="selectProvider(p.id)"
>
{{ p.label }}
</button>
</div>
<template v-if="activeProvider?.status() === 'missing'">
<p class="hint">未配置 API key,暂时搜不了。</p>
<a class="key-link" :href="activeProvider.keyUrl" target="_blank" rel="noreferrer">
去申请 API key ↗
</a>
</template>
<template v-else>
<div class="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="error">{{ searchError }}</p>
<div v-if="results.length" class="results">
<button
v-for="ph in results"
:key="ph.id"
class="shot"
:disabled="busy"
:title="ph.alt ?? ph.authorName"
@click="pickPhoto(ph)"
>
<img :src="ph.thumbUrl" :alt="ph.alt ?? ph.authorName" loading="lazy" />
</button>
</div>
<button v-if="results.length" class="menu-row" :disabled="loadingMore" @click="loadMore">
<span>{{ loadingMore ? "加载中…" : "加载更多" }}</span>
</button>
</template>
</template>
</div>
<!-- 署名:可复制的文本,不画进图(用户明确要求过) -->
<template v-if="lastAttribution">
<div class="menu-sep"></div>
<div class="menu-block attrib">
<div class="attrib-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>
</template>
<p v-if="busy" class="busy">{{ busyLabel }}</p>
</div>
</template>
<style scoped>
.bg-menu {
min-width: 240px;
}
.notice {
margin: 0 9px 6px;
color: var(--fg-dim);
background: var(--bg3);
border-left: 2px solid var(--line);
padding: 4px 8px;
line-height: 1.6;
border-radius: 0 var(--radius) var(--radius) 0;
}
/* ---- 纯色:右侧一个方形色块 ---- */
.swatch {
width: 18px;
height: 18px;
padding: 0;
margin-left: auto;
flex: none;
border: 1px solid var(--line);
border-radius: 4px;
background: none;
cursor: pointer;
}
.swatch::-webkit-color-swatch-wrapper {
padding: 0;
}
.swatch::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
/* ---- 预设:3 列卡片网格 ---- */
.preset-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.preset {
position: relative;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
aspect-ratio: 16 / 10;
line-height: 0;
background: var(--bg);
}
.preset:hover:not(:disabled) {
border-color: var(--accent);
}
.preset img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.preset-label {
position: absolute;
left: 3px;
bottom: 3px;
font-size: 10px;
line-height: 1.2;
padding: 1px 4px;
border-radius: 3px;
background: rgb(0 0 0 / 0.55); /* 底衬:压暗条,白字底下不再直接是图案 */
color: var(--fg);
}
/* ---- 上传 ---- */
.hidden-input {
display: none;
}
/* ---- 图库 ---- */
.gallery {
display: flex;
flex-direction: column;
gap: 6px;
}
.tabs {
display: flex;
gap: 4px;
}
.tab {
flex: 1;
padding: 3px 6px;
font-size: 11px;
color: var(--fg-dim);
}
.tab.on {
color: var(--accent);
border-color: var(--accent);
}
.row {
display: flex;
gap: 6px;
align-items: center;
}
.row input {
flex: 1;
}
.results {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.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;
}
.hint {
margin: 0;
color: var(--fg-dim);
line-height: 1.6;
word-break: break-word;
}
.error {
margin: 0;
color: var(--danger); /* 错误色用 danger,不是 accent */
line-height: 1.6;
word-break: break-word;
}
.key-link {
color: var(--accent);
font-size: 11px;
}
/* ---- 署名 ---- */
.attrib {
display: flex;
flex-direction: column;
gap: 4px;
}
.attrib-row {
display: flex;
gap: 6px;
align-items: center;
}
.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 {
color: var(--fg-dim);
font-size: 11px;
}
.mini {
padding: 3px 8px;
white-space: nowrap;
font-size: 11px;
}
.busy {
margin: 6px 9px 0;
color: var(--fg-dim);
font-style: italic;
}
/* 展开图库时角标转一下 */
.menu-aside.rot {
transform: rotate(180deg);
}
</style>