feat: add image project dashboard and canvas editor
Verify and deploy production / verify-and-deploy (push) Successful in 2m34s

This commit is contained in:
2026-10-05 15:20:43 +08:00
parent e961e3091b
commit 276a8ae473
12 changed files with 675 additions and 203 deletions
+2 -1
View File
@@ -61,13 +61,14 @@ jobs:
$SUDO useradd --system --home "$DATA_ROOT" --shell /usr/sbin/nologin "$SERVICE_USER"
fi
$SUDO mkdir -p "$APP_ROOT/releases" "$APP_ROOT/bin" "$DATA_ROOT/projects"
$SUDO mkdir -p "$APP_ROOT/releases" "$APP_ROOT/bin" "$DATA_ROOT/projects" "$DATA_ROOT/image-projects"
$SUDO install -m 0755 "$(command -v node)" "$APP_ROOT/bin/node"
$SUDO mkdir -p "$RELEASE_DIR/.next" "$RELEASE_DIR/data"
$SUDO cp -a .next/standalone/. "$RELEASE_DIR/"
$SUDO cp -a .next/static "$RELEASE_DIR/.next/static"
$SUDO cp -a templates vendor "$RELEASE_DIR/"
$SUDO ln -sfn "$DATA_ROOT/projects" "$RELEASE_DIR/data/projects"
$SUDO ln -sfn "$DATA_ROOT/image-projects" "$RELEASE_DIR/data/image-projects"
$SUDO chown -R root:root "$APP_ROOT"
$SUDO chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_ROOT"
$SUDO ln -sfn "$RELEASE_DIR" "$APP_ROOT/current"
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { readImageProjectFile } from "../../../../../../lib/image-projects";
import { errorResponse } from "../../../../../../lib/http";
export const runtime = "nodejs";
type Context = { params: Promise<{ id: string; file: string }> };
export async function GET(_: Request, context: Context) {
try {
const { id, file } = await context.params;
const image = await readImageProjectFile(id, file);
return new NextResponse(image.data, { headers: { "Content-Type": image.mimeType, "Cache-Control": "private, max-age=3600" } });
} catch (error) { return errorResponse(error); }
}
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { addImagesToProject } from "../../../../../lib/image-projects";
import { errorResponse } from "../../../../../lib/http";
export const runtime = "nodejs";
type Context = { params: Promise<{ id: string }> };
export async function POST(request: Request, context: Context) {
try {
const form = await request.formData();
const files = form.getAll("images").filter((item): item is File => item instanceof File);
return NextResponse.json({ project: await addImagesToProject((await context.params).id, files) });
} catch (error) { return errorResponse(error); }
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { getImageProject, updateImageProject } from "../../../../lib/image-projects";
import { errorResponse } from "../../../../lib/http";
export const runtime = "nodejs";
type Context = { params: Promise<{ id: string }> };
export async function GET(_: Request, context: Context) {
try { return NextResponse.json({ project: await getImageProject((await context.params).id) }); }
catch (error) { return errorResponse(error); }
}
export async function PUT(request: Request, context: Context) {
try { return NextResponse.json({ project: await updateImageProject((await context.params).id, await request.json()) }); }
catch (error) { return errorResponse(error); }
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { createImageProject, listImageProjects } from "../../../lib/image-projects";
import { errorResponse } from "../../../lib/http";
export const runtime = "nodejs";
export async function GET() {
try { return NextResponse.json({ projects: await listImageProjects() }); }
catch (error) { return errorResponse(error); }
}
export async function POST(request: Request) {
try {
const form = await request.formData();
const files = form.getAll("images").filter((item): item is File => item instanceof File);
const project = await createImageProject(files, form.get("name"));
return NextResponse.json({ project }, { status: 201 });
} catch (error) { return errorResponse(error); }
}
+164 -147
View File
@@ -1,151 +1,168 @@
:root {
color-scheme: light;
--red: #ff2442;
--ink: #1f1f1f;
--muted: #8b8b8b;
--sidebar: #f7f7f7;
--panel: #f8f8f8;
}
:root { color-scheme: light; --red: #ff2442; --red-deep: #e91436; --ink: #18181b; --muted: #85858d; --line: #e8e8eb; --soft: #f5f5f7; }
* { box-sizing: border-box; }
html, body { margin: 0; min-width: 1024px; min-height: 100%; background: #fff; color: var(--ink); font-family: Inter, "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button { border: 0; font: inherit; cursor: pointer; }
html, body { margin: 0; min-width: 1024px; min-height: 100%; background: #f6f6f7; color: var(--ink); font-family: Inter, "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button, input, textarea, select { font: inherit; }
button { border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
.creator-shell { min-height: 100vh; background: var(--sidebar); }
/* Kling-inspired project home */
.kling-shell { min-height: 100vh; background: #f6f6f7; }
.kling-windowbar { position: fixed; z-index: 20; inset: 0 0 auto 0; height: 34px; display: flex; align-items: center; gap: 18px; padding: 0 7px; background: #fff; border-bottom: 1px solid #e5e5e7; }
.kling-windowbar strong { width: 142px; font-size: 13px; }
.kling-windowbar button { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; color: #777; }
.kling-sidebar { position: fixed; z-index: 15; left: 0; top: 34px; bottom: 0; width: 76px; display: flex; flex-direction: column; align-items: center; padding: 15px 7px 10px; background: #fff; border-right: 1px solid #e8e8ea; }
.kling-logo { width: 32px; height: 32px; display: grid; place-items: center; margin-bottom: 18px; border: 2px solid var(--red); border-radius: 13px 13px 13px 5px; color: var(--red); font-size: 17px; font-weight: 900; transform: rotate(-8deg); }
.kling-sidebar nav, .side-footer { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 5px; }
.kling-sidebar nav > i { width: 36px; height: 1px; margin: 3px 0; background: #ececef; }
.side-item { width: 62px; min-height: 55px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border-radius: 10px; background: transparent; color: #74747c; font-size: 10px; }
.side-item.active { background: #fff0f2; color: var(--red); }
.side-item:hover { background: #f4f4f6; }
.side-footer { margin-top: auto; }
.side-avatar { width: 30px; height: 30px; display: grid; place-items: center; margin-top: 7px; border-radius: 50%; background: #34343a; color: #fff; font-size: 10px; }
.creator-header {
position: fixed;
z-index: 10;
inset: 0 0 auto 0;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 19px 0 20px;
background: #fff;
}
.creator-brand { display: flex; align-items: center; gap: 13px; white-space: nowrap; }
.creator-brand strong { font-size: 20px; line-height: 1; font-weight: 600; letter-spacing: -.4px; }
.xiaohongshu-logo {
width: 73px;
height: 31px;
display: grid;
place-items: center;
border-radius: 17px;
background: var(--red);
color: #fff;
font-size: 15px;
font-weight: 800;
letter-spacing: -.5px;
}
.creator-account { height: 46px; display: flex; align-items: center; gap: 7px; padding: 0 2px 0 10px; background: transparent; color: #242424; font-size: 16px; }
.account-photo { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; background: radial-gradient(circle at 65% 25%, #f6a45c 0 10%, transparent 11%), linear-gradient(135deg, #302722, #8b5b38 55%, #191919); color: #fff; font-size: 11px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.18); }
.creator-account svg { color: #a0a0a0; margin-left: 2px; }
.creator-sidebar {
position: fixed;
z-index: 5;
left: 0;
top: 64px;
bottom: 0;
width: 220px;
padding: 16px 20px 24px;
background: var(--sidebar);
}
.publish-button {
width: 177px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 24px;
background: var(--red);
color: #fff;
font-size: 15px;
font-weight: 600;
box-shadow: none;
}
.publish-button span { margin: 0 2px; }
.creator-nav { display: flex; flex-direction: column; gap: 1px; margin-top: 18px; }
.creator-nav-item {
width: 100%;
height: 47px;
display: grid;
grid-template-columns: 25px 1fr 18px;
align-items: center;
padding: 0 13px;
border-radius: 9px;
background: transparent;
color: #898989;
text-align: left;
font-size: 14px;
}
.creator-nav-item span { padding-left: 6px; }
.creator-nav-item > svg:last-child { grid-column: 3; }
.creator-nav-item:hover { background: #ededed; color: #555; }
.creator-main {
position: fixed;
left: 220px;
right: 0;
top: 64px;
bottom: 0;
overflow-y: auto;
background: #fff;
border-top-left-radius: 20px;
}
.creator-toolbar {
height: 73px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 39px 0 49px;
}
.creator-tabs { height: 100%; display: flex; align-items: center; gap: 17px; }
.creator-tabs button { min-width: 94px; height: 42px; padding: 0 16px; border-radius: 23px; background: transparent; color: #929292; font-size: 16px; font-weight: 600; }
.creator-tabs button.active { background: #f7f7f7; color: #202020; }
.draft-button { height: 40px; display: flex; align-items: center; gap: 7px; padding: 0 18px; border-radius: 22px; background: #f8f8f8; color: #262626; font-size: 14px; }
.upload-panel {
min-height: 606px;
height: calc(100vh - 199px);
margin: 0 39px 0 34px;
display: grid;
place-items: center;
border-radius: 15px;
background: var(--panel);
}
.upload-center { display: flex; flex-direction: column; align-items: center; transform: translateY(-4px); }
.cloud-upload { height: 103px; display: flex; align-items: center; color: #dfdfdf; }
.cloud-upload svg { fill: #dfdfdf; stroke: #f8f8f8; }
.upload-center p { margin: 10px 0 14px; color: #242424; font-size: 14px; }
.upload-action { min-width: 120px; height: 41px; padding: 0 23px; border-radius: 22px; background: var(--red); color: #fff; font-size: 14px; }
.upload-action:hover, .publish-button:hover { background: #f20f35; }
.upload-guides {
min-height: 134px;
display: grid;
grid-template-columns: repeat(3, 1fr);
margin: 56px 39px 0 203px;
}
.upload-guides > div { min-height: 112px; padding: 16px 42px; border-left: 1px solid #efefef; }
.upload-guides > div:first-child { border-left: 0; }
.upload-guides strong { display: block; margin-bottom: 10px; font-size: 14px; font-weight: 500; }
.upload-guides p { margin: 0; color: #a0a0a0; font-size: 12px; line-height: 1.8; }
@media (max-width: 1300px) {
.creator-tabs { gap: 2px; }
.creator-tabs button { min-width: 82px; padding: 0 11px; }
.upload-guides { margin-left: 70px; }
}
@media (max-height: 800px) {
.upload-panel { min-height: 480px; height: calc(100vh - 185px); }
.upload-guides { margin-top: 34px; }
.kling-main { min-height: 100vh; margin-left: 76px; padding: 17px 24px 48px; background: #f6f6f7; }
.tool-heroes { display: grid; grid-template-columns: 1.22fr .85fr .85fr; gap: 14px; max-width: 1500px; margin: 0 auto; }
.hero-card { position: relative; height: 158px; overflow: hidden; border: 1px solid #e7e7ea; border-radius: 15px; background: #fff; color: var(--ink); text-align: left; box-shadow: 0 1px 1px rgba(0,0,0,.02); }
.hero-card > div:first-child { position: relative; z-index: 2; padding: 40px 20px; }
.hero-card h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 21px; font-weight: 700; letter-spacing: -.5px; }
.hero-card h2 > svg:last-child { margin-left: 2px; }
.hero-card p { margin: 0; color: #777780; font-size: 13px; }
.primary-hero { background: linear-gradient(115deg, #fff 0 44%, #fff3f5 66%, #ffd7de); }
.primary-hero h2 b { color: var(--red); font-size: 25px; }
.new-badge { position: absolute; top: 0; right: 0; padding: 4px 11px; border-radius: 0 0 0 10px; background: var(--red); color: white; font-size: 10px; font-weight: 700; }
.hero-wave { position: absolute; inset: 0 0 0 42%; transform: rotate(-8deg); }
.hero-wave i { position: absolute; left: -10%; width: 130%; height: 32px; border-radius: 50%; filter: blur(3px); }
.hero-wave i:nth-child(1) { top: 34px; background: linear-gradient(90deg, transparent, #ffbbc6, #ff2442); transform: rotate(-8deg); }
.hero-wave i:nth-child(2) { top: 76px; background: linear-gradient(90deg, transparent, #ffd4da, #ff7b91); transform: rotate(8deg); }
.hero-wave i:nth-child(3) { top: 116px; background: linear-gradient(90deg, transparent, #ffe8ec, #ffb5c1); transform: rotate(-5deg); }
.image-hero { background: linear-gradient(145deg, #fff, #fafafa 58%, #f1f1f3); }
.batch-hero { background: linear-gradient(135deg, #fff, #f7f7f8 70%, #ffe9ed); }
.stacked-pictures { position: absolute; right: 18px; bottom: -22px; width: 114px; height: 92px; }
.stacked-pictures i { position: absolute; width: 60px; height: 74px; border: 4px solid #fff; border-radius: 5px; background: linear-gradient(150deg, #ff8ca0, #f0d2b8 55%, #777); box-shadow: 0 5px 18px rgba(0,0,0,.12); }
.stacked-pictures i:nth-child(1) { left: 0; top: 12px; transform: rotate(-13deg); }
.stacked-pictures i:nth-child(2) { left: 30px; top: 3px; transform: rotate(4deg); background: linear-gradient(140deg, #ddd, #ff647e); }
.stacked-pictures i:nth-child(3) { right: 0; top: 16px; transform: rotate(13deg); background: linear-gradient(150deg, #333, #ffbd70); }
.batch-art i { position: absolute; right: 22px; bottom: -15px; width: 78px; height: 104px; border-radius: 8px; background: linear-gradient(160deg, #2b2b2f, #f1f1f1); transform: rotate(8deg); }
.batch-art i:last-child { right: -8px; bottom: -3px; background: linear-gradient(150deg, #ff2442, #ff9aaa); transform: rotate(-8deg); }
.quick-tools { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; max-width: 1500px; margin: 12px auto 0; }
.quick-tool { height: 79px; display: grid; grid-template-columns: 32px 1fr 20px; align-items: center; gap: 8px; padding: 0 18px; border: 1px solid #e7e7ea; border-radius: 13px; background: #fff; color: #34343a; text-align: left; }
.quick-tool > svg:first-child { color: var(--red); }
.quick-tool div { display: flex; flex-direction: column; gap: 5px; }
.quick-tool strong { font-size: 14px; }
.quick-tool small { color: #96969d; font-size: 11px; white-space: nowrap; }
.quick-tool > svg:last-child { color: #999; transition: transform .2s; }
.quick-tool:hover { border-color: #ffc6cf; box-shadow: 0 8px 24px rgba(255,36,66,.08); }
.quick-tool:hover > svg:last-child { transform: translateX(3px); }
.project-feed { max-width: 1500px; margin: 57px auto 0; }
.project-toolbar { display: flex; align-items: center; gap: 14px; }
.feed-tabs { display: flex; padding: 3px; border: 1px solid #e0e0e3; border-radius: 9px; background: #fff; }
.feed-tabs button { height: 30px; padding: 0 17px; border-radius: 6px; background: transparent; color: #77777f; font-size: 13px; }
.feed-tabs button.active { background: #efeff1; color: #202024; font-weight: 600; }
.project-toolbar > i { width: 1px; height: 27px; background: #dedee2; }
.project-search { width: 380px; height: 37px; display: flex; align-items: center; margin-left: 8px; border: 1px solid #dcdce0; border-radius: 9px; background: #fff; }
.project-search input { flex: 1; min-width: 0; height: 100%; padding: 0 13px; border: 0; outline: 0; background: transparent; color: #333; }
.project-search svg { margin: 0 12px; color: #999; }
.create-project-button { height: 38px; margin-left: auto; padding: 0 18px; border-radius: 9px; background: var(--red); color: #fff; font-size: 13px; font-weight: 600; }
.project-categories { display: flex; gap: 29px; margin: 13px 0 18px; }
.project-categories button { padding: 0; background: transparent; color: #696971; font-size: 13px; }
.project-categories button.active { color: var(--red); font-weight: 700; }
.project-masonry { columns: 5 210px; column-gap: 8px; }
.project-tile { display: inline-block; width: 100%; break-inside: avoid; margin-bottom: 15px; overflow: hidden; border-radius: 9px; background: #fff; box-shadow: 0 0 0 1px #e9e9ec; transition: transform .18s, box-shadow .18s; }
.project-tile:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,.09); }
.project-cover { position: relative; min-height: 160px; max-height: 345px; overflow: hidden; background: linear-gradient(140deg, #ececef, #dddde1); }
.project-cover img { width: 100%; height: 100%; display: block; object-fit: cover; }
.project-cover span { position: absolute; right: 8px; bottom: 8px; padding: 3px 7px; border-radius: 10px; background: rgba(20,20,22,.68); color: #fff; font-size: 10px; }
.project-caption { display: flex; flex-direction: column; gap: 4px; padding: 10px 11px 12px; }
.project-caption strong { overflow: hidden; color: #2d2d32; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.project-caption small { color: #9b9ba2; font-size: 10px; }
.project-empty { min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px dashed #dddde1; border-radius: 15px; background: #fff; }
.empty-art { position: relative; width: 130px; height: 90px; margin-bottom: 19px; }
.empty-art span { position: absolute; width: 64px; height: 78px; border: 4px solid #fff; border-radius: 7px; background: linear-gradient(145deg, #ffe0e5, #ff657e); box-shadow: 0 8px 25px rgba(0,0,0,.1); }
.empty-art span:nth-child(1) { left: 8px; top: 8px; transform: rotate(-12deg); }
.empty-art span:nth-child(2) { left: 34px; top: 0; background: linear-gradient(145deg, #f1f1f3, #b8b8bf); }
.empty-art span:nth-child(3) { right: 4px; top: 10px; transform: rotate(12deg); background: linear-gradient(145deg, #ff97a8, #ff2442); }
.project-empty h3 { margin: 0 0 7px; font-size: 16px; }
.project-empty p { margin: 0 0 17px; color: #929299; font-size: 12px; }
.project-empty button { height: 37px; padding: 0 18px; border-radius: 9px; background: var(--red); color: #fff; font-size: 12px; }
.upload-overlay { position: fixed; z-index: 100; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: rgba(255,255,255,.9); backdrop-filter: blur(5px); }
.upload-spinner, .editor-spinner { width: 35px; height: 35px; border: 3px solid #ffd2d9; border-top-color: var(--red); border-radius: 50%; animation: spin .8s linear infinite; }
.upload-overlay strong { font-size: 15px; }
.upload-overlay small { color: #999; }
.dashboard-toast { position: fixed; z-index: 90; right: 22px; bottom: 22px; display: flex; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid #e3e3e6; border-radius: 10px; background: #fff; box-shadow: 0 12px 35px rgba(0,0,0,.12); font-size: 12px; }
.dashboard-toast button { background: transparent; color: #999; }
/* Image project editor */
.image-editor-shell { height: 100vh; min-width: 1100px; overflow: hidden; display: grid; grid-template: 56px 1fr / 164px minmax(520px, 1fr) 304px; background: #ededf0; }
.editor-topbar { grid-column: 1 / 4; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 15px; padding: 0 14px; border-bottom: 1px solid #e1e1e4; background: #fff; }
.editor-brand { display: flex; align-items: center; min-width: 0; gap: 9px; }
.editor-back { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 7px; color: #555; }
.editor-back:hover { background: #f0f0f2; }
.editor-logo { padding: 5px 9px; border-radius: 13px; background: var(--red); color: #fff; font-size: 11px; font-weight: 800; }
.editor-brand i { width: 1px; height: 20px; background: #ddd; }
.editor-brand input { width: min(330px, 36vw); border: 0; outline: 0; color: #222; font-weight: 600; }
.editor-status { display: flex; align-items: center; gap: 7px; color: #8a8a91; font-size: 11px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #55b36a; }
.editor-actions { display: flex; justify-content: flex-end; gap: 9px; }
.ghost-control, .save-control { height: 34px; padding: 0 14px; border-radius: 8px; font-size: 12px; }
.ghost-control { border: 1px solid #dddde1; background: #fff; color: #45454a; }
.save-control { background: var(--red); color: #fff; font-weight: 600; }
.save-control:disabled { opacity: .55; }
.editor-filmstrip { grid-row: 2; padding: 13px 11px; overflow-y: auto; border-right: 1px solid #dedee2; background: #f8f8f9; }
.filmstrip-title { display: flex; justify-content: space-between; padding: 0 3px 10px; font-size: 12px; font-weight: 600; }
.filmstrip-title small { color: #999; font-weight: 400; }
.filmstrip-list { display: flex; flex-direction: column; gap: 10px; }
.filmstrip-item { position: relative; width: 100%; height: 118px; padding: 5px; overflow: hidden; border: 2px solid transparent; border-radius: 9px; background: #eeeef0; }
.filmstrip-item.active { border-color: var(--red); background: #fff; }
.filmstrip-item > span { position: absolute; z-index: 2; left: 7px; top: 7px; width: 19px; height: 19px; display: grid; place-items: center; border-radius: 6px; background: rgba(20,20,22,.7); color: #fff; font-size: 9px; }
.filmstrip-item img { width: 100%; height: 100%; display: block; border-radius: 5px; object-fit: cover; }
.filmstrip-add { width: 100%; height: 35px; margin-top: 10px; border: 1px dashed #ccc; border-radius: 8px; background: transparent; color: #888; }
.editor-workspace { position: relative; grid-row: 2; min-width: 0; display: grid; grid-template-rows: 43px 1fr 48px; overflow: hidden; background: #e9e9ec; }
.canvas-toolbar { display: flex; align-items: center; justify-content: center; gap: 6px; border-bottom: 1px solid #dddde1; background: #f8f8f9; }
.canvas-toolbar button { height: 29px; padding: 0 10px; border-radius: 7px; background: transparent; color: #66666d; font-size: 11px; }
.canvas-toolbar button:hover { background: #ececef; }
.canvas-toolbar i { width: 1px; height: 18px; margin: 0 5px; background: #ddd; }
.canvas-toolbar span { margin-right: 5px; color: #86868e; font-size: 10px; }
.canvas-viewport { min-height: 0; display: grid; place-items: center; overflow: hidden; padding: 28px; background-image: radial-gradient(#d0d0d4 .7px, transparent .7px); background-size: 16px 16px; }
.canvas-page { position: relative; height: min(66vh, 680px); max-width: 82%; aspect-ratio: var(--canvas-ratio); overflow: hidden; box-shadow: 0 12px 42px rgba(20,20,24,.18); touch-action: none; user-select: none; }
.canvas-page > img { width: 100%; height: 100%; display: block; transform-origin: center; pointer-events: none; }
.canvas-selection { position: absolute; inset: 0; border: 1px solid rgba(255,36,66,.75); pointer-events: none; }
.handle { position: absolute; width: 7px; height: 7px; border: 1px solid var(--red); background: #fff; }
.handle.nw { left: -4px; top: -4px; }.handle.ne { right: -4px; top: -4px; }.handle.sw { left: -4px; bottom: -4px; }.handle.se { right: -4px; bottom: -4px; }
.editor-pagination { display: flex; align-items: center; justify-content: center; gap: 13px; border-top: 1px solid #dddde1; background: #f7f7f8; }
.editor-pagination button { width: 28px; height: 28px; border-radius: 7px; background: #fff; color: #555; }
.editor-pagination button:disabled { color: #c5c5c8; }
.editor-pagination span { color: #6f6f76; font-size: 11px; }
.editor-inspector { grid-row: 2; overflow-y: auto; border-left: 1px solid #dddde1; background: #fff; }
.inspector-tabs { height: 44px; display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid #e5e5e8; }
.inspector-tabs button { position: relative; background: transparent; color: #929298; font-size: 12px; }
.inspector-tabs button.active { color: #26262a; font-weight: 600; }
.inspector-tabs button.active::after { content: ""; position: absolute; left: 24px; right: 24px; bottom: 0; height: 2px; background: var(--red); }
.inspector-section { padding: 15px 16px 18px; border-bottom: 1px solid #ececef; }
.inspector-section h3 { display: flex; justify-content: space-between; margin: 0 0 14px; font-size: 12px; }
.inspector-section h3 button { background: transparent; color: #aaa; }
.ratio-buttons, .segmented-control { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.ratio-buttons button, .segmented-control button { height: 31px; border: 1px solid #dedee2; border-radius: 7px; background: #fafafa; color: #666; font-size: 11px; }
.ratio-buttons button.active, .segmented-control button.active { border-color: #ff9bab; background: #fff3f5; color: var(--red); }
.segmented-control { grid-template-columns: 1fr 1fr; margin-bottom: 14px; }
.color-field { display: grid; grid-template-columns: 1fr 27px 68px; align-items: center; gap: 8px; margin-top: 13px; color: #64646b; font-size: 11px; }
.color-field input { width: 27px; height: 27px; padding: 0; border: 0; background: none; }
.color-field code { padding: 5px; border: 1px solid #e0e0e3; border-radius: 5px; color: #777; font-size: 10px; text-align: center; }
.range-field { display: block; margin-top: 12px; }
.range-field > div { display: flex; justify-content: space-between; margin-bottom: 7px; color: #66666d; font-size: 11px; }
.range-field code { color: #999; font-size: 10px; }
.range-field input { width: 100%; accent-color: var(--red); }
.editor-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: #f6f6f7; color: #777; }
.editor-loading a { color: var(--red); font-size: 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 1200px) {
.tool-heroes { grid-template-columns: 1.15fr 1fr; }
.batch-hero { display: none; }
.quick-tools { grid-template-columns: repeat(4, 1fr); }
.quick-tool:last-child { display: none; }
.project-search { width: 270px; }
.image-editor-shell { grid-template-columns: 140px minmax(480px, 1fr) 280px; }
}
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "创作服务平台",
description: "内容创作服务平台。",
title: "Rednote Studio",
description: "小红书图文项目创作与编辑工具。",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
+5
View File
@@ -0,0 +1,5 @@
import ImageEditor from "../../../../components/ImageEditor";
export default async function ImageProjectEditor({ params }: { params: Promise<{ id: string }> }) {
return <ImageEditor projectId={(await params).id} />;
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
type Settings = {
fit: "contain" | "cover";
scale: number;
offsetX: number;
offsetY: number;
rotation: number;
brightness: number;
contrast: number;
saturation: number;
borderRadius: number;
};
type Slide = { id: string; fileName: string; originalName: string; mimeType: string; settings: Settings };
type Project = { id: string; name: string; canvas: { width: number; height: number; background: string }; slides: Slide[]; createdAt: string; updatedAt: string };
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok) throw new Error(data.error || `请求失败:${response.status}`);
return data;
}
function imageUrl(projectId: string, fileName: string) { return `/api/image-projects/${projectId}/images/${fileName}`; }
export default function ImageEditor({ projectId }: { projectId: string }) {
const [project, setProject] = useState<Project | null>(null);
const [selectedId, setSelectedId] = useState("");
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("正在载入项目…");
const fileInput = useRef<HTMLInputElement>(null);
const drag = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(null);
const load = useCallback(async () => {
try {
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${projectId}`));
setProject(data.project);
setSelectedId((current) => current || data.project.slides[0]?.id || "");
setMessage("项目已载入");
} catch (error) { setMessage((error as Error).message); }
}, [projectId]);
useEffect(() => { void load(); }, [load]);
const selected = project?.slides.find((slide) => slide.id === selectedId) || project?.slides[0];
function updateSettings(patch: Partial<Settings>) {
if (!project || !selected) return;
setProject({ ...project, slides: project.slides.map((slide) => slide.id === selected.id ? { ...slide, settings: { ...slide.settings, ...patch } } : slide) });
}
async function save() {
if (!project) return;
setBusy("save");
try {
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${project.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(project) }));
setProject(data.project);
setMessage("所有修改已保存");
} catch (error) { setMessage((error as Error).message); }
finally { setBusy(""); }
}
async function addImages(files: FileList | null) {
if (!project || !files?.length) return;
setBusy("upload");
try {
const form = new FormData();
Array.from(files).forEach((file) => form.append("images", file));
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${project.id}/images`, { method: "POST", body: form }));
setProject(data.project);
setSelectedId(data.project.slides.at(-1)?.id || selectedId);
setMessage(`已添加 ${files.length} 张图片`);
} catch (error) { setMessage((error as Error).message); }
finally { setBusy(""); if (fileInput.current) fileInput.current.value = ""; }
}
function setCanvas(width: number, height: number) {
if (project) setProject({ ...project, canvas: { ...project.canvas, width, height } });
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (!selected) return;
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { x: event.clientX, y: event.clientY, offsetX: selected.settings.offsetX, offsetY: selected.settings.offsetY };
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
if (!drag.current || !selected) return;
const rect = event.currentTarget.getBoundingClientRect();
updateSettings({ offsetX: Math.max(-100, Math.min(100, drag.current.offsetX + (event.clientX - drag.current.x) / rect.width * 100)), offsetY: Math.max(-100, Math.min(100, drag.current.offsetY + (event.clientY - drag.current.y) / rect.height * 100)) });
}
function onPointerUp(event: ReactPointerEvent<HTMLDivElement>) {
drag.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (!project) return <div className="editor-loading"><span className="editor-spinner" /><p>{message}</p><a href="/">返回首页</a></div>;
if (!selected) return <div className="editor-loading"><p>项目中没有图片</p><a href="/">返回首页</a></div>;
const style = {
transform: `translate(${selected.settings.offsetX}%, ${selected.settings.offsetY}%) scale(${selected.settings.scale / 100}) rotate(${selected.settings.rotation}deg)`,
filter: `brightness(${selected.settings.brightness}%) contrast(${selected.settings.contrast}%) saturate(${selected.settings.saturation}%)`,
objectFit: selected.settings.fit,
borderRadius: `${selected.settings.borderRadius}px`,
} as CSSProperties;
const ratioStyle = { "--canvas-ratio": `${project.canvas.width} / ${project.canvas.height}`, background: project.canvas.background } as CSSProperties;
return <main className="image-editor-shell">
<header className="editor-topbar">
<div className="editor-brand"><a href="/" className="editor-back">←</a><span className="editor-logo">小红书</span><i /><input value={project.name} onChange={(event) => setProject({ ...project, name: event.target.value })} /></div>
<div className="editor-status"><span className="status-dot" />{message}</div>
<div className="editor-actions"><button className="ghost-control" onClick={() => fileInput.current?.click()}>+ 添加图片</button><button className="save-control" onClick={save} disabled={Boolean(busy)}>{busy === "save" ? "保存中…" : "保存项目"}</button></div>
<input ref={fileInput} hidden type="file" accept="image/png,image/jpeg,image/webp" multiple onChange={(event) => void addImages(event.target.files)} />
</header>
<aside className="editor-filmstrip">
<div className="filmstrip-title"><span>页面</span><small>{project.slides.length}</small></div>
<div className="filmstrip-list">{project.slides.map((slide, index) => <button key={slide.id} className={`filmstrip-item ${selected.id === slide.id ? "active" : ""}`} onClick={() => setSelectedId(slide.id)}><span>{index + 1}</span><img src={imageUrl(project.id, slide.fileName)} alt={slide.originalName} /></button>)}</div>
<button className="filmstrip-add" onClick={() => fileInput.current?.click()}>+</button>
</aside>
<section className="editor-workspace">
<div className="canvas-toolbar"><button>↖ 选择</button><button>✋ 移动</button><i /><span>{project.canvas.width} × {project.canvas.height}</span><button onClick={() => updateSettings({ scale: 100, offsetX: 0, offsetY: 0, rotation: 0 })}>重置视图</button></div>
<div className="canvas-viewport">
<div className="canvas-page" style={ratioStyle} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}>
<img draggable={false} src={imageUrl(project.id, selected.fileName)} alt={selected.originalName} style={style} />
<div className="canvas-selection"><i className="handle nw"/><i className="handle ne"/><i className="handle sw"/><i className="handle se"/></div>
</div>
</div>
<div className="editor-pagination"><button disabled={project.slides[0].id === selected.id} onClick={() => setSelectedId(project.slides[Math.max(0, project.slides.findIndex((item) => item.id === selected.id) - 1)].id)}>‹</button><span>{project.slides.findIndex((item) => item.id === selected.id) + 1} / {project.slides.length}</span><button disabled={project.slides.at(-1)?.id === selected.id} onClick={() => setSelectedId(project.slides[Math.min(project.slides.length - 1, project.slides.findIndex((item) => item.id === selected.id) + 1)].id)}>›</button></div>
</section>
<aside className="editor-inspector">
<div className="inspector-tabs"><button className="active">画面</button><button>文字</button><button>素材</button></div>
<InspectorSection title="画布">
<div className="ratio-buttons"><button className={project.canvas.width === project.canvas.height ? "active" : ""} onClick={() => setCanvas(1080, 1080)}>1:1</button><button className={project.canvas.width / project.canvas.height === .75 ? "active" : ""} onClick={() => setCanvas(1080, 1440)}>3:4</button><button className={project.canvas.width / project.canvas.height === .8 ? "active" : ""} onClick={() => setCanvas(1080, 1350)}>4:5</button></div>
<label className="color-field"><span>背景</span><input type="color" value={project.canvas.background} onChange={(event) => setProject({ ...project, canvas: { ...project.canvas, background: event.target.value } })} /><code>{project.canvas.background.toUpperCase()}</code></label>
</InspectorSection>
<InspectorSection title="图片适配">
<div className="segmented-control"><button className={selected.settings.fit === "cover" ? "active" : ""} onClick={() => updateSettings({ fit: "cover" })}>填充</button><button className={selected.settings.fit === "contain" ? "active" : ""} onClick={() => updateSettings({ fit: "contain" })}>完整</button></div>
<Range label="缩放" value={selected.settings.scale} min={20} max={300} suffix="%" onChange={(scale) => updateSettings({ scale })} />
<Range label="旋转" value={selected.settings.rotation} min={-180} max={180} suffix="°" onChange={(rotation) => updateSettings({ rotation })} />
<Range label="圆角" value={selected.settings.borderRadius} min={0} max={200} suffix="" onChange={(borderRadius) => updateSettings({ borderRadius })} />
</InspectorSection>
<InspectorSection title="画面调整">
<Range label="亮度" value={selected.settings.brightness} min={0} max={200} suffix="%" onChange={(brightness) => updateSettings({ brightness })} />
<Range label="对比度" value={selected.settings.contrast} min={0} max={200} suffix="%" onChange={(contrast) => updateSettings({ contrast })} />
<Range label="饱和度" value={selected.settings.saturation} min={0} max={200} suffix="%" onChange={(saturation) => updateSettings({ saturation })} />
</InspectorSection>
</aside>
</main>;
}
function InspectorSection({ title, children }: { title: string; children: React.ReactNode }) {
return <section className="inspector-section"><h3>{title}<button>⌃</button></h3>{children}</section>;
}
function Range({ label, value, min, max, suffix, onChange }: { label: string; value: number; min: number; max: number; suffix: string; onChange: (value: number) => void }) {
return <label className="range-field"><div><span>{label}</span><code>{Math.round(value)}{suffix}</code></div><input type="range" min={min} max={max} value={value} onChange={(event) => onChange(Number(event.target.value))} /></label>;
}
+98 -53
View File
@@ -1,76 +1,121 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
type IconName = "home" | "note" | "code" | "chart" | "flag" | "bulb" | "school" | "book" | "draft" | "plus" | "chevron" | "upload";
type IconName = "home" | "create" | "assets" | "projects" | "canvas" | "tools" | "bell" | "api" | "plus" | "image" | "layers" | "magic" | "resize" | "grid" | "search" | "arrow";
type Project = { id: string; name: string; slides: Array<{ fileName: string }>; canvas: { width: number; height: number }; updatedAt: string };
const paths: Record<IconName, React.ReactNode> = {
home: <><path d="m4 10 8-6 8 6v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M9 14h6"/></>,
note: <><rect x="5" y="3" width="14" height="18" rx="3"/><path d="M9 8h6M9 12h5"/></>,
code: <><rect x="3" y="4" width="18" height="16" rx="3"/><path d="m9 9-3 3 3 3m6-6 3 3-3 3m-2-7-2 8"/></>,
chart: <><rect x="3" y="4" width="18" height="16" rx="3"/><path d="m7 15 3-3 3 2 4-5"/></>,
flag: <><path d="M6 21V5m0 1c4-3 7 3 12 0v9c-5 3-8-3-12 0"/></>,
bulb: <><path d="M9 18h6m-5 3h4"/><path d="M8.2 15.5a7 7 0 1 1 7.6 0c-.8.5-.8 1.5-.8 2.5H9c0-1 0-2-.8-2.5Z"/></>,
school: <><path d="m3 9 9-5 9 5-9 5Z"/><path d="M7 12v5c3 2 7 2 10 0v-5m4-3v6"/></>,
book: <><path d="M6 3h11a2 2 0 0 1 2 2v16H7a3 3 0 0 1-3-3V5a2 2 0 0 1 2-2Z"/><path d="M7 15h12M9 7h6"/></>,
draft: <><path d="M5 5h14v15H5z"/><path d="M9 3h6v4H9zM8 11h8m-8 4h6"/></>,
plus: <><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M12 9v6m-3-3h6"/></>,
chevron: <path d="m8 10 4 4 4-4"/>,
upload: <><path d="M7 18a5 5 0 0 1-.5-10A7 7 0 0 1 20 10.5 4 4 0 0 1 19 18"/><path d="m9 12 3-3 3 3m-3-3v10"/></>,
home: <path d="M4 11 12 4l8 7v9H5v-9"/>,
create: <><circle cx="12" cy="12" r="8"/><path d="M12 8v8m-4-4h8"/></>,
assets: <><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 4-4 3 3 2-2 2 3"/></>,
projects: <><path d="M4 7h6l2 2h8v10H4z"/><path d="M4 7V5h6l2 2"/></>,
canvas: <><rect x="5" y="5" width="14" height="14" rx="2"/><path d="M2 8V3h5M17 3h5v5M22 16v5h-5M7 21H2v-5"/></>,
tools: <><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/></>,
bell: <><path d="M6 17h12l-2-3V9a4 4 0 0 0-8 0v5Z"/><path d="M10 20h4"/></>,
api: <><path d="M8 8 4 12l4 4m8-8 4 4-4 4M14 5l-4 14"/></>,
plus: <path d="M12 5v14M5 12h14"/>,
image: <><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m5 18 5-5 3 3 2-2 4 4"/></>,
layers: <><path d="m12 3 9 5-9 5-9-5Z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/></>,
magic: <><path d="m15 4 5 5L8 21l-5-5Z"/><path d="m13 6 5 5M7 3v3M5.5 4.5h3M20 15v4M18 17h4"/></>,
resize: <><path d="M8 3H3v5M16 3h5v5M3 16v5h5M21 16v5h-5"/><path d="m3 8 6-6m12 6-6-6M3 16l6 6m12-6-6 6"/></>,
grid: <><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></>,
search: <><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></>,
arrow: <path d="M5 12h14m-4-4 4 4-4 4"/>,
};
function Icon({ name, size = 19, strokeWidth = 1.55 }: { name: IconName; size?: number; strokeWidth?: number }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
function Icon({ name, size = 20 }: { name: IconName; size?: number }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
}
const navItems: Array<{ icon: IconName; label: string; expandable?: boolean }> = [
{ icon: "home", label: "首页" },
{ icon: "note", label: "笔记管理" },
{ icon: "code", label: "Builder hub", expandable: true },
{ icon: "chart", label: "数据看板", expandable: true },
{ icon: "flag", label: "活动中心" },
{ icon: "bulb", label: "笔记灵感" },
{ icon: "school", label: "创作学院" },
{ icon: "book", label: "创作百科" },
];
const tabs = ["上传视频", "上传图文", "写长文", "发播客"];
function imageUrl(project: Project) { return project.slides[0] ? `/api/image-projects/${project.id}/images/${project.slides[0].fileName}` : ""; }
export default function Studio() {
const [activeTab, setActiveTab] = useState("上传视频");
const [projects, setProjects] = useState<Project[]>([]);
const [filter, setFilter] = useState("项目");
const [query, setQuery] = useState("");
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState("");
const input = useRef<HTMLInputElement>(null);
return <main className="creator-shell">
<header className="creator-header">
<div className="creator-brand"><span className="xiaohongshu-logo">小红书</span><strong>创作服务平台</strong></div>
<button className="creator-account"><span className="account-photo">创</span><span>内容工作室</span><Icon name="chevron" size={14} /></button>
</header>
useEffect(() => {
fetch("/api/image-projects").then(async (response) => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "项目载入失败");
setProjects(data.projects || []);
}).catch((error) => setMessage((error as Error).message));
}, []);
<aside className="creator-sidebar">
<button className="publish-button"><Icon name="plus" size={19} strokeWidth={1.8} /><span>发布笔记</span><Icon name="chevron" size={18} strokeWidth={2} /></button>
<nav className="creator-nav">
{navItems.map((item) => <button className="creator-nav-item" key={item.label}><Icon name={item.icon} /><span>{item.label}</span>{item.expandable && <Icon name="chevron" size={17} />}</button>)}
async function createProject(files: FileList | null) {
if (!files?.length) return;
setUploading(true);
setMessage(`正在创建项目并上传 ${files.length} 张图片…`);
try {
const form = new FormData();
Array.from(files).forEach((file) => form.append("images", file));
const response = await fetch("/api/image-projects", { method: "POST", body: form });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "上传失败");
window.location.assign(`/projects/${data.project.id}/editor`);
} catch (error) { setMessage((error as Error).message); setUploading(false); if (input.current) input.current.value = ""; }
}
const visibleProjects = projects.filter((project) => project.name.toLowerCase().includes(query.trim().toLowerCase()));
return <main className="kling-shell">
<header className="kling-windowbar"><strong>首页</strong><button aria-label="新建"><Icon name="plus" size={18} /></button></header>
<aside className="kling-sidebar">
<div className="kling-logo">R</div>
<nav>
<SideItem icon="create" label="创作" active />
<SideItem icon="assets" label="资产" />
<i />
<SideItem icon="projects" label="项目" />
<SideItem icon="canvas" label="灵动画布" />
<SideItem icon="tools" label="全部工具" />
</nav>
<div className="side-footer"><SideItem icon="bell" label="通知" /><SideItem icon="api" label="API" /><div className="side-avatar">HX</div></div>
</aside>
<section className="creator-main">
<div className="creator-toolbar">
<div className="creator-tabs">{tabs.map((tab) => <button key={tab} className={activeTab === tab ? "active" : ""} onClick={() => setActiveTab(tab)}>{tab}</button>)}</div>
<button className="draft-button"><Icon name="draft" size={18} /><span>草稿箱(0)</span></button>
<section className="kling-main">
<div className="tool-heroes">
<button className="hero-card primary-hero" onClick={() => input.current?.click()}><div><span className="new-badge">NEW</span><h2><b>RED</b> 开始创作 <Icon name="arrow" /></h2><p>上传图片,开启你的图文项目</p></div><div className="hero-wave"><i/><i/><i/></div></button>
<button className="hero-card image-hero" onClick={() => input.current?.click()}><div><h2><Icon name="image" /> 图片创作 <Icon name="arrow" /></h2><p>多图编辑、尺寸适配、画面调整</p></div><div className="stacked-pictures"><i/><i/><i/></div></button>
<button className="hero-card batch-hero" onClick={() => input.current?.click()}><div><h2><Icon name="layers" /> 批量图文 <Icon name="arrow" /></h2><p>一次导入多张图片,逐页精细编辑</p></div><div className="batch-art"><i/><i/></div></button>
</div>
<div className="upload-panel">
<div className="upload-center">
<div className="cloud-upload"><Icon name="upload" size={104} strokeWidth={1.15} /></div>
<p>{activeTab === "上传视频" ? "拖拽视频到此或点击上传" : activeTab === "上传图文" ? "拖拽图片到此或点击上传" : activeTab === "写长文" ? "点击开始创作长文" : "拖拽音频到此或点击上传"}</p>
<button className="upload-action">{activeTab === "上传视频" ? "上传视频" : activeTab === "上传图文" ? "上传图文" : activeTab === "写长文" ? "开始写作" : "上传音频"}</button>
<div className="quick-tools">
<ToolCard icon="canvas" title="灵动画布" subtitle="专业画布编辑器" onClick={() => input.current?.click()} />
<ToolCard icon="magic" title="封面生成" subtitle="快速制作小红书封面" />
<ToolCard icon="layers" title="批量排版" subtitle="统一多页视觉样式" />
<ToolCard icon="resize" title="尺寸适配" subtitle="一键切换常用比例" />
<ToolCard icon="grid" title="全部" subtitle="探索更多创作工具" />
</div>
<section className="project-feed">
<div className="project-toolbar">
<div className="feed-tabs">{["项目", "最近", "收藏"].map((tab) => <button key={tab} className={filter === tab ? "active" : ""} onClick={() => setFilter(tab)}>{tab}</button>)}</div>
<i />
<label className="project-search"><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目" /><Icon name="search" size={18} /></label>
<button className="create-project-button" onClick={() => input.current?.click()}>+ 新建图文项目</button>
</div>
</div>
<div className="upload-guides">
<div><strong>{activeTab === "上传视频" ? "视频大小" : "文件大小"}</strong><p>{activeTab === "上传视频" ? <>支持时长4小时以内,<br />最大20GB的视频文件</> : <>单个文件不超过20GB,<br />支持批量选择上传</>}</p></div>
<div><strong>{activeTab === "上传视频" ? "视频格式" : "支持格式"}</strong><p>{activeTab === "上传视频" ? <>支持常用视频格式,<br />推荐使用mp4、mov</> : <>支持平台常用格式,<br />上传后可继续编辑</>}</p></div>
<div><strong>{activeTab === "上传视频" ? "视频分辨率" : "内容质量"}</strong><p>{activeTab === "上传视频" ? <>超过1080P的视频用网页端上传画质更清晰</> : <>建议上传清晰、完整的原创内容</>}</p></div>
</div>
<div className="project-categories"><button className="active">全部</button><button>小红书图文</button><button>封面设计</button><button>方形图片</button><button>竖版长图</button><button>最近编辑</button></div>
{visibleProjects.length ? <div className="project-masonry">{visibleProjects.map((project, index) => <a className={`project-tile tile-${index % 5}`} href={`/projects/${project.id}/editor`} key={project.id}><div className="project-cover" style={{ aspectRatio: `${project.canvas.width}/${project.canvas.height}` }}>{imageUrl(project) && <img src={imageUrl(project)} alt="" />}<span>{project.slides.length} 页</span></div><div className="project-caption"><strong>{project.name}</strong><small>{new Date(project.updatedAt).toLocaleDateString("zh-CN")} 编辑</small></div></a>)}</div> : <div className="project-empty"><div className="empty-art"><span/><span/><span/></div><h3>开始第一个图文项目</h3><p>上传 1–20 张图片,进入专业画布逐页编辑</p><button onClick={() => input.current?.click()}>上传图片创建项目</button></div>}
</section>
</section>
<input ref={input} hidden type="file" accept="image/png,image/jpeg,image/webp" multiple onChange={(event) => void createProject(event.target.files)} />
{uploading && <div className="upload-overlay"><span className="upload-spinner"/><strong>{message}</strong><small>请不要关闭页面</small></div>}
{!uploading && message && <div className="dashboard-toast">{message}<button onClick={() => setMessage("")}>×</button></div>}
</main>;
}
function SideItem({ icon, label, active }: { icon: IconName; label: string; active?: boolean }) {
return <button className={`side-item ${active ? "active" : ""}`}><Icon name={icon} size={20} /><span>{label}</span></button>;
}
function ToolCard({ icon, title, subtitle, onClick }: { icon: IconName; title: string; subtitle: string; onClick?: () => void }) {
return <button className="quick-tool" onClick={onClick}><Icon name={icon} /><div><strong>{title}</strong><small>{subtitle}</small></div><Icon name="arrow" size={17} /></button>;
}
+1
View File
@@ -2,6 +2,7 @@ import path from "node:path";
export const APP_ROOT = process.cwd();
export const PROJECTS_ROOT = path.join(APP_ROOT, "data", "projects");
export const IMAGE_PROJECTS_ROOT = path.join(APP_ROOT, "data", "image-projects");
export const TEMPLATES_ROOT = path.join(APP_ROOT, "templates");
export const SKILL_ROOT = path.join(APP_ROOT, "vendor", "minitool-zip-builder");
export const SKILL_VERSION = "1.6.0";
+177
View File
@@ -0,0 +1,177 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { IMAGE_PROJECTS_ROOT } from "./config";
export type ImageSettings = {
fit: "contain" | "cover";
scale: number;
offsetX: number;
offsetY: number;
rotation: number;
brightness: number;
contrast: number;
saturation: number;
borderRadius: number;
};
export type ImageSlide = {
id: string;
fileName: string;
originalName: string;
mimeType: string;
settings: ImageSettings;
};
export type ImageProject = {
id: string;
name: string;
canvas: { width: number; height: number; background: string };
slides: ImageSlide[];
createdAt: string;
updatedAt: string;
};
const PROJECT_ID = /^[0-9a-f-]{36}$/;
const FILE_ID = /^[0-9a-f-]{36}\.(?:png|jpe?g|webp)$/;
const MIME_EXTENSIONS: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
const DEFAULT_SETTINGS: ImageSettings = { fit: "cover", scale: 100, offsetX: 0, offsetY: 0, rotation: 0, brightness: 100, contrast: 100, saturation: 100, borderRadius: 0 };
function projectRoot(id: string) {
if (!PROJECT_ID.test(id)) throw new Error("Invalid image project id");
return path.join(IMAGE_PROJECTS_ROOT, id);
}
function projectFile(id: string) { return path.join(projectRoot(id), "project.json"); }
function clamp(value: unknown, min: number, max: number, fallback: number) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
function normalizeSettings(value: unknown, previous = DEFAULT_SETTINGS): ImageSettings {
const input = value && typeof value === "object" ? value as Partial<ImageSettings> : {};
return {
fit: input.fit === "contain" ? "contain" : input.fit === "cover" ? "cover" : previous.fit,
scale: clamp(input.scale, 20, 300, previous.scale),
offsetX: clamp(input.offsetX, -100, 100, previous.offsetX),
offsetY: clamp(input.offsetY, -100, 100, previous.offsetY),
rotation: clamp(input.rotation, -180, 180, previous.rotation),
brightness: clamp(input.brightness, 0, 200, previous.brightness),
contrast: clamp(input.contrast, 0, 200, previous.contrast),
saturation: clamp(input.saturation, 0, 200, previous.saturation),
borderRadius: clamp(input.borderRadius, 0, 200, previous.borderRadius),
};
}
function cleanName(value: unknown, fallback: string) {
return typeof value === "string" && value.trim() ? value.trim().slice(0, 80) : fallback;
}
async function readProject(id: string): Promise<ImageProject> {
return JSON.parse(await fs.readFile(projectFile(id), "utf8"));
}
async function writeProject(project: ImageProject) {
await fs.writeFile(projectFile(project.id), JSON.stringify(project, null, 2), "utf8");
}
function validateFiles(files: File[]) {
if (files.length < 1 || files.length > 20) throw new Error("请选择 1 到 20 张图片");
let total = 0;
for (const file of files) {
if (!MIME_EXTENSIONS[file.type]) throw new Error(`不支持的图片格式:${file.name}`);
if (file.size > 25 * 1024 * 1024) throw new Error(`单张图片不能超过 25MB:${file.name}`);
total += file.size;
}
if (total > 150 * 1024 * 1024) throw new Error("图片总大小不能超过 150MB");
}
async function storeFiles(root: string, files: File[]): Promise<ImageSlide[]> {
const imagesRoot = path.join(root, "images");
await fs.mkdir(imagesRoot, { recursive: true });
const slides: ImageSlide[] = [];
for (const file of files) {
const id = crypto.randomUUID();
const fileName = `${id}.${MIME_EXTENSIONS[file.type]}`;
await fs.writeFile(path.join(imagesRoot, fileName), Buffer.from(await file.arrayBuffer()));
slides.push({ id, fileName, originalName: cleanName(file.name, "图片"), mimeType: file.type, settings: { ...DEFAULT_SETTINGS } });
}
return slides;
}
export async function listImageProjects() {
await fs.mkdir(IMAGE_PROJECTS_ROOT, { recursive: true });
const entries = await fs.readdir(IMAGE_PROJECTS_ROOT, { withFileTypes: true });
const projects = await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
try { return await readProject(entry.name); } catch { return null; }
}));
return projects.filter((project): project is ImageProject => Boolean(project)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function createImageProject(files: File[], requestedName?: unknown) {
validateFiles(files);
await fs.mkdir(IMAGE_PROJECTS_ROOT, { recursive: true });
const id = crypto.randomUUID();
const root = projectRoot(id);
await fs.mkdir(root, { recursive: true });
try {
const slides = await storeFiles(root, files);
const now = new Date().toISOString();
const baseName = path.parse(files[0].name).name || "图文项目";
const project: ImageProject = {
id,
name: cleanName(requestedName, files.length > 1 ? `${baseName} 等 ${files.length} 张` : baseName),
canvas: { width: 1080, height: 1440, background: "#ffffff" },
slides,
createdAt: now,
updatedAt: now,
};
await writeProject(project);
return project;
} catch (error) {
await fs.rm(root, { recursive: true, force: true });
throw error;
}
}
export async function getImageProject(id: string) { return readProject(id); }
export async function addImagesToProject(id: string, files: File[]) {
validateFiles(files);
const project = await readProject(id);
if (project.slides.length + files.length > 20) throw new Error("每个项目最多包含 20 张图片");
const slides = await storeFiles(projectRoot(id), files);
project.slides.push(...slides);
project.updatedAt = new Date().toISOString();
await writeProject(project);
return project;
}
export async function updateImageProject(id: string, input: unknown) {
const project = await readProject(id);
const value = input && typeof input === "object" ? input as Partial<ImageProject> : {};
project.name = cleanName(value.name, project.name);
if (value.canvas && typeof value.canvas === "object") {
project.canvas = {
width: clamp(value.canvas.width, 320, 4096, project.canvas.width),
height: clamp(value.canvas.height, 320, 4096, project.canvas.height),
background: typeof value.canvas.background === "string" && /^#[0-9a-f]{6}$/i.test(value.canvas.background) ? value.canvas.background : project.canvas.background,
};
}
if (Array.isArray(value.slides)) {
const settingsById = new Map(value.slides.map((slide) => [slide.id, slide.settings]));
project.slides = project.slides.map((slide) => ({ ...slide, settings: normalizeSettings(settingsById.get(slide.id), slide.settings) }));
}
project.updatedAt = new Date().toISOString();
await writeProject(project);
return project;
}
export async function readImageProjectFile(id: string, fileName: string) {
if (!FILE_ID.test(fileName)) throw new Error("Invalid image file");
const project = await readProject(id);
const slide = project.slides.find((item) => item.fileName === fileName);
if (!slide) throw new Error("Image not found");
return { data: await fs.readFile(path.join(projectRoot(id), "images", fileName)), mimeType: slide.mimeType };
}