xiaolongmr commited on
Commit ·
db2e2ca
1
Parent(s): d7f06c8
批量生图页改版:提示词列表移到输入框上方、支持分隔符与 xlsx 导入
Browse files- 布局重构:不再把多行编辑器塞进输入框,改成「列表在上、原输入框编辑选中项」——
点列表里任意一条,输入框就显示并编辑那一条(原来输入框的内容会在进入批量模式时带进第 1 条)
- 每条独立设置平铺出来:参考图、张数、模型、比例直接可见可改(不再藏在「参数」展开里);
改过之后旁边出现「恢复跟全局」用来清掉行级覆盖
- 分隔符:默认 ----,可在批量区自定义;「拆分输入框」把当前这条按分隔符拆成多条;
批量模式下粘贴含分隔符的内容也会自动拆
- 导入 xlsx / xls / csv(SheetJS):读各工作表第一列的非空单元格,跳过「提示词 / prompt」
这类表头,单元格内还能再按分隔符拆;导入结果追加进列表
- 修两个 bug:
① 追加条目改用函数式 setState —— 否则拆分时会把同一批次里刚改好的那条顶回原文;
② 导入时不能提前把 input.value = "" —— 会让已拿到的 File 变成不可读(NotReadableError)
- 依赖:新增 xlsx(SheetJS)
- web/package-lock.json +104 -0
- web/package.json +1 -0
- web/src/app/batch/components/image-composer.tsx +395 -190
web/package-lock.json
CHANGED
|
@@ -37,6 +37,7 @@
|
|
| 37 |
"remark-gfm": "^4.0.1",
|
| 38 |
"sonner": "^2.0.6",
|
| 39 |
"tailwind-merge": "^3.3.1",
|
|
|
|
| 40 |
"zustand": "^5.0.8"
|
| 41 |
},
|
| 42 |
"devDependencies": {
|
|
@@ -3362,6 +3363,15 @@
|
|
| 3362 |
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
| 3363 |
}
|
| 3364 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3365 |
"node_modules/agent-base": {
|
| 3366 |
"version": "6.0.2",
|
| 3367 |
"license": "MIT",
|
|
@@ -3794,6 +3804,19 @@
|
|
| 3794 |
"url": "https://github.com/sponsors/wooorm"
|
| 3795 |
}
|
| 3796 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3797 |
"node_modules/chalk": {
|
| 3798 |
"version": "4.1.2",
|
| 3799 |
"dev": true,
|
|
@@ -3875,6 +3898,15 @@
|
|
| 3875 |
"node": ">=6"
|
| 3876 |
}
|
| 3877 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3878 |
"node_modules/color-convert": {
|
| 3879 |
"version": "2.0.1",
|
| 3880 |
"dev": true,
|
|
@@ -3958,6 +3990,18 @@
|
|
| 3958 |
}
|
| 3959 |
}
|
| 3960 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3961 |
"node_modules/cross-spawn": {
|
| 3962 |
"version": "7.0.6",
|
| 3963 |
"dev": true,
|
|
@@ -5072,6 +5116,15 @@
|
|
| 5072 |
"node": ">= 6"
|
| 5073 |
}
|
| 5074 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5075 |
"node_modules/framer-motion": {
|
| 5076 |
"version": "12.42.2",
|
| 5077 |
"license": "MIT",
|
|
@@ -9053,6 +9106,18 @@
|
|
| 9053 |
"url": "https://github.com/sponsors/wooorm"
|
| 9054 |
}
|
| 9055 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9056 |
"node_modules/stable-hash": {
|
| 9057 |
"version": "0.0.5",
|
| 9058 |
"dev": true,
|
|
@@ -9953,6 +10018,24 @@
|
|
| 9953 |
"url": "https://github.com/sponsors/ljharb"
|
| 9954 |
}
|
| 9955 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9956 |
"node_modules/word-wrap": {
|
| 9957 |
"version": "1.2.5",
|
| 9958 |
"dev": true,
|
|
@@ -9982,6 +10065,27 @@
|
|
| 9982 |
"dev": true,
|
| 9983 |
"license": "ISC"
|
| 9984 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9985 |
"node_modules/y18n": {
|
| 9986 |
"version": "5.0.8",
|
| 9987 |
"dev": true,
|
|
|
|
| 37 |
"remark-gfm": "^4.0.1",
|
| 38 |
"sonner": "^2.0.6",
|
| 39 |
"tailwind-merge": "^3.3.1",
|
| 40 |
+
"xlsx": "^0.18.5",
|
| 41 |
"zustand": "^5.0.8"
|
| 42 |
},
|
| 43 |
"devDependencies": {
|
|
|
|
| 3363 |
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
| 3364 |
}
|
| 3365 |
},
|
| 3366 |
+
"node_modules/adler-32": {
|
| 3367 |
+
"version": "1.3.1",
|
| 3368 |
+
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
| 3369 |
+
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
| 3370 |
+
"license": "Apache-2.0",
|
| 3371 |
+
"engines": {
|
| 3372 |
+
"node": ">=0.8"
|
| 3373 |
+
}
|
| 3374 |
+
},
|
| 3375 |
"node_modules/agent-base": {
|
| 3376 |
"version": "6.0.2",
|
| 3377 |
"license": "MIT",
|
|
|
|
| 3804 |
"url": "https://github.com/sponsors/wooorm"
|
| 3805 |
}
|
| 3806 |
},
|
| 3807 |
+
"node_modules/cfb": {
|
| 3808 |
+
"version": "1.2.2",
|
| 3809 |
+
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
| 3810 |
+
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
| 3811 |
+
"license": "Apache-2.0",
|
| 3812 |
+
"dependencies": {
|
| 3813 |
+
"adler-32": "~1.3.0",
|
| 3814 |
+
"crc-32": "~1.2.0"
|
| 3815 |
+
},
|
| 3816 |
+
"engines": {
|
| 3817 |
+
"node": ">=0.8"
|
| 3818 |
+
}
|
| 3819 |
+
},
|
| 3820 |
"node_modules/chalk": {
|
| 3821 |
"version": "4.1.2",
|
| 3822 |
"dev": true,
|
|
|
|
| 3898 |
"node": ">=6"
|
| 3899 |
}
|
| 3900 |
},
|
| 3901 |
+
"node_modules/codepage": {
|
| 3902 |
+
"version": "1.15.0",
|
| 3903 |
+
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
| 3904 |
+
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
| 3905 |
+
"license": "Apache-2.0",
|
| 3906 |
+
"engines": {
|
| 3907 |
+
"node": ">=0.8"
|
| 3908 |
+
}
|
| 3909 |
+
},
|
| 3910 |
"node_modules/color-convert": {
|
| 3911 |
"version": "2.0.1",
|
| 3912 |
"dev": true,
|
|
|
|
| 3990 |
}
|
| 3991 |
}
|
| 3992 |
},
|
| 3993 |
+
"node_modules/crc-32": {
|
| 3994 |
+
"version": "1.2.2",
|
| 3995 |
+
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
| 3996 |
+
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
| 3997 |
+
"license": "Apache-2.0",
|
| 3998 |
+
"bin": {
|
| 3999 |
+
"crc32": "bin/crc32.njs"
|
| 4000 |
+
},
|
| 4001 |
+
"engines": {
|
| 4002 |
+
"node": ">=0.8"
|
| 4003 |
+
}
|
| 4004 |
+
},
|
| 4005 |
"node_modules/cross-spawn": {
|
| 4006 |
"version": "7.0.6",
|
| 4007 |
"dev": true,
|
|
|
|
| 5116 |
"node": ">= 6"
|
| 5117 |
}
|
| 5118 |
},
|
| 5119 |
+
"node_modules/frac": {
|
| 5120 |
+
"version": "1.1.2",
|
| 5121 |
+
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
| 5122 |
+
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
| 5123 |
+
"license": "Apache-2.0",
|
| 5124 |
+
"engines": {
|
| 5125 |
+
"node": ">=0.8"
|
| 5126 |
+
}
|
| 5127 |
+
},
|
| 5128 |
"node_modules/framer-motion": {
|
| 5129 |
"version": "12.42.2",
|
| 5130 |
"license": "MIT",
|
|
|
|
| 9106 |
"url": "https://github.com/sponsors/wooorm"
|
| 9107 |
}
|
| 9108 |
},
|
| 9109 |
+
"node_modules/ssf": {
|
| 9110 |
+
"version": "0.11.2",
|
| 9111 |
+
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
| 9112 |
+
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
| 9113 |
+
"license": "Apache-2.0",
|
| 9114 |
+
"dependencies": {
|
| 9115 |
+
"frac": "~1.1.2"
|
| 9116 |
+
},
|
| 9117 |
+
"engines": {
|
| 9118 |
+
"node": ">=0.8"
|
| 9119 |
+
}
|
| 9120 |
+
},
|
| 9121 |
"node_modules/stable-hash": {
|
| 9122 |
"version": "0.0.5",
|
| 9123 |
"dev": true,
|
|
|
|
| 10018 |
"url": "https://github.com/sponsors/ljharb"
|
| 10019 |
}
|
| 10020 |
},
|
| 10021 |
+
"node_modules/wmf": {
|
| 10022 |
+
"version": "1.0.2",
|
| 10023 |
+
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
| 10024 |
+
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
| 10025 |
+
"license": "Apache-2.0",
|
| 10026 |
+
"engines": {
|
| 10027 |
+
"node": ">=0.8"
|
| 10028 |
+
}
|
| 10029 |
+
},
|
| 10030 |
+
"node_modules/word": {
|
| 10031 |
+
"version": "0.3.0",
|
| 10032 |
+
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
| 10033 |
+
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
| 10034 |
+
"license": "Apache-2.0",
|
| 10035 |
+
"engines": {
|
| 10036 |
+
"node": ">=0.8"
|
| 10037 |
+
}
|
| 10038 |
+
},
|
| 10039 |
"node_modules/word-wrap": {
|
| 10040 |
"version": "1.2.5",
|
| 10041 |
"dev": true,
|
|
|
|
| 10065 |
"dev": true,
|
| 10066 |
"license": "ISC"
|
| 10067 |
},
|
| 10068 |
+
"node_modules/xlsx": {
|
| 10069 |
+
"version": "0.18.5",
|
| 10070 |
+
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
| 10071 |
+
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
| 10072 |
+
"license": "Apache-2.0",
|
| 10073 |
+
"dependencies": {
|
| 10074 |
+
"adler-32": "~1.3.0",
|
| 10075 |
+
"cfb": "~1.2.1",
|
| 10076 |
+
"codepage": "~1.15.0",
|
| 10077 |
+
"crc-32": "~1.2.1",
|
| 10078 |
+
"ssf": "~0.11.2",
|
| 10079 |
+
"wmf": "~1.0.1",
|
| 10080 |
+
"word": "~0.3.0"
|
| 10081 |
+
},
|
| 10082 |
+
"bin": {
|
| 10083 |
+
"xlsx": "bin/xlsx.njs"
|
| 10084 |
+
},
|
| 10085 |
+
"engines": {
|
| 10086 |
+
"node": ">=0.8"
|
| 10087 |
+
}
|
| 10088 |
+
},
|
| 10089 |
"node_modules/y18n": {
|
| 10090 |
"version": "5.0.8",
|
| 10091 |
"dev": true,
|
web/package.json
CHANGED
|
@@ -40,6 +40,7 @@
|
|
| 40 |
"remark-gfm": "^4.0.1",
|
| 41 |
"sonner": "^2.0.6",
|
| 42 |
"tailwind-merge": "^3.3.1",
|
|
|
|
| 43 |
"zustand": "^5.0.8"
|
| 44 |
},
|
| 45 |
"devDependencies": {
|
|
|
|
| 40 |
"remark-gfm": "^4.0.1",
|
| 41 |
"sonner": "^2.0.6",
|
| 42 |
"tailwind-merge": "^3.3.1",
|
| 43 |
+
"xlsx": "^0.18.5",
|
| 44 |
"zustand": "^5.0.8"
|
| 45 |
},
|
| 46 |
"devDependencies": {
|
web/src/app/batch/components/image-composer.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
-
import { Check, ChevronDown, ImagePlus, Layers, LoaderCircle, Plus,
|
|
|
|
| 3 |
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type PointerEvent, type RefObject } from "react";
|
| 4 |
|
| 5 |
import {
|
|
@@ -53,6 +54,12 @@ function createBatchItem(prompt = ""): BatchItem {
|
|
| 53 |
return { id: `batch-${Date.now().toString(36)}-${batchItemSeq}`, prompt, count: 1, refIndexes: [] };
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
type ImageComposerProps = {
|
| 57 |
prompt: string;
|
| 58 |
imageCount: string;
|
|
@@ -212,8 +219,14 @@ export function ImageComposer({
|
|
| 212 |
const [isDraggingImage, setIsDraggingImage] = useState(false);
|
| 213 |
const [isBatchMode, setIsBatchMode] = useState(false);
|
| 214 |
const [batchItems, setBatchItems] = useState<BatchItem[]>(() => [createBatchItem()]);
|
| 215 |
-
const [batchExpandedIds, setBatchExpandedIds] = useState<string[]>([]);
|
| 216 |
const [batchPickerId, setBatchPickerId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
const [sizeMenuPos, setSizeMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
| 218 |
const [modelMenuPos, setModelMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
| 219 |
// 心跳由全局 PresenceProvider 统一上报(覆盖站内所有页面),这里只负责展示数字。
|
|
@@ -299,8 +312,8 @@ export function ImageComposer({
|
|
| 299 |
: unitCreditCost.toFixed(2).replace(/\.?0+$/, "");
|
| 300 |
|
| 301 |
// ---- 批量模式 ----------------------------------------------------------
|
| 302 |
-
// 一
|
| 303 |
-
//
|
| 304 |
const batchValidItems = useMemo(() => batchItems.filter((item) => item.prompt.trim()), [batchItems]);
|
| 305 |
const batchTotalCount = batchValidItems.reduce((sum, item) => sum + Math.max(1, item.count), 0);
|
| 306 |
const batchEstimatedCost = batchTotalCount * unitCreditCost;
|
|
@@ -308,38 +321,54 @@ export function ImageComposer({
|
|
| 308 |
? String(batchEstimatedCost)
|
| 309 |
: batchEstimatedCost.toFixed(2).replace(/\.?0+$/, "");
|
| 310 |
const batchItemCounts = useMemo(() => ({ valid: batchValidItems.length, total: batchItems.length }), [batchItems.length, batchValidItems.length]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
const enterBatchMode = () => {
|
| 313 |
setIsBatchMode(true);
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
| 315 |
// 把已经敲在输入框里的提示词带进第 1 条,不清空用户内容。
|
| 316 |
-
const
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
});
|
| 323 |
};
|
| 324 |
const exitBatchMode = () => {
|
| 325 |
setIsBatchMode(false);
|
| 326 |
setBatchPickerId(null);
|
| 327 |
-
|
|
|
|
| 328 |
};
|
| 329 |
const updateBatchItem = (id: string, patch: Partial<BatchItem>) => {
|
| 330 |
setBatchItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
| 331 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
const addBatchItem = () => {
|
| 333 |
-
|
|
|
|
|
|
|
| 334 |
setBatchPickerId(null);
|
| 335 |
};
|
| 336 |
const removeBatchItem = (id: string) => {
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
|
|
|
|
|
|
| 343 |
};
|
| 344 |
const toggleBatchReference = (itemId: string, index: number) => {
|
| 345 |
setBatchItems((current) => current.map((item) => {
|
|
@@ -351,6 +380,96 @@ export function ImageComposer({
|
|
| 351 |
return { ...item, refIndexes: next };
|
| 352 |
}));
|
| 353 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
const handleBatchSubmitClick = async () => {
|
| 355 |
if (!onBatchSubmit) return;
|
| 356 |
const items: BatchSubmitItem[] = batchValidItems.map((item) => ({
|
|
@@ -365,8 +484,9 @@ export function ImageComposer({
|
|
| 365 |
if (items.length === 0) return;
|
| 366 |
await onBatchSubmit(items);
|
| 367 |
setBatchItems([createBatchItem()]);
|
| 368 |
-
setBatchExpandedIds([]);
|
| 369 |
setBatchPickerId(null);
|
|
|
|
|
|
|
| 370 |
setIsBatchMode(false);
|
| 371 |
};
|
| 372 |
const requiresImageSize = imageModel.trim().toLowerCase() === "gpt-image-2-4k";
|
|
@@ -630,195 +750,280 @@ export function ImageComposer({
|
|
| 630 |
onPointerDown={handlePromptResizePointerDown}
|
| 631 |
/>
|
| 632 |
{isBatchMode ? (
|
| 633 |
-
<div className="px-2.5 pb-
|
| 634 |
-
<div className="mb-
|
| 635 |
-
<span className="text-xs font-medium text-stone-
|
|
|
|
|
|
|
|
|
|
| 636 |
<span className="shrink-0 text-[11px] tabular-nums text-stone-400">
|
| 637 |
-
{batchItemCounts.valid} 条
|
| 638 |
</span>
|
| 639 |
</div>
|
| 640 |
-
|
|
|
|
| 641 |
{batchItems.map((item, index) => {
|
| 642 |
-
const
|
| 643 |
-
const isExpanded = batchExpandedIds.includes(item.id);
|
| 644 |
return (
|
| 645 |
-
<div
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
: "border-stone-200 bg-white text-stone-500 hover:bg-stone-100",
|
| 665 |
-
)}
|
| 666 |
-
title="从参考图池里选图(不选 = 纯文生图)"
|
| 667 |
-
>
|
| 668 |
-
{item.refIndexes.length > 0 ? `参考图 ${item.refIndexes.length}` : "参考图"}
|
| 669 |
-
</button>
|
| 670 |
-
<div className="flex h-9 shrink-0 items-center rounded-full border border-stone-200 bg-white px-1">
|
| 671 |
-
<button
|
| 672 |
-
type="button"
|
| 673 |
-
className="flex size-6 items-center justify-center rounded-full text-stone-500 hover:bg-stone-100"
|
| 674 |
-
onClick={() => updateBatchItem(item.id, { count: Math.max(1, item.count - 1) })}
|
| 675 |
-
aria-label="减少张数"
|
| 676 |
-
>−</button>
|
| 677 |
-
<input
|
| 678 |
-
type="number"
|
| 679 |
-
inputMode="numeric"
|
| 680 |
-
min="1"
|
| 681 |
-
max="100"
|
| 682 |
-
value={item.count}
|
| 683 |
-
onChange={(event) => {
|
| 684 |
-
const next = Number(event.target.value);
|
| 685 |
-
updateBatchItem(item.id, { count: Number.isFinite(next) && next > 0 ? Math.min(100, Math.floor(next)) : 1 });
|
| 686 |
-
}}
|
| 687 |
-
className="w-7 border-0 bg-transparent px-0 text-center text-xs font-medium text-stone-700 outline-none"
|
| 688 |
-
aria-label="这条生成张数"
|
| 689 |
-
/>
|
| 690 |
-
<button
|
| 691 |
-
type="button"
|
| 692 |
-
className="flex size-6 items-center justify-center rounded-full text-stone-500 hover:bg-stone-100"
|
| 693 |
-
onClick={() => updateBatchItem(item.id, { count: Math.min(100, item.count + 1) })}
|
| 694 |
-
aria-label="增加张数"
|
| 695 |
-
>+</button>
|
| 696 |
-
</div>
|
| 697 |
-
<button
|
| 698 |
-
type="button"
|
| 699 |
-
data-testid="batch-row-params"
|
| 700 |
-
onClick={() => toggleBatchExpanded(item.id)}
|
| 701 |
className={cn(
|
| 702 |
-
"
|
| 703 |
-
|
| 704 |
)}
|
| 705 |
-
title="这一条的模型 / 比例(默认跟随全局)"
|
| 706 |
>
|
| 707 |
-
|
| 708 |
-
</
|
| 709 |
-
<
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
|
|
|
|
|
|
|
|
|
| 715 |
>
|
| 716 |
-
|
| 717 |
-
</
|
| 718 |
-
</div>
|
| 719 |
-
|
| 720 |
-
{isPickerOpen ? (
|
| 721 |
-
<div className="border-t border-stone-100 px-2.5 py-2">
|
| 722 |
-
{referenceImages.length === 0 ? (
|
| 723 |
-
<p className="text-[11px] leading-4 text-stone-400">
|
| 724 |
-
还没有参考图。点左边的图片按钮上传,上传的图会进「图池」,所有条目都能选。
|
| 725 |
-
</p>
|
| 726 |
-
) : (
|
| 727 |
-
<>
|
| 728 |
-
<p className="mb-1.5 text-[11px] text-stone-400">点选这条要用的参考图(可多选,不选就是纯文生图)</p>
|
| 729 |
-
<div className="flex flex-wrap gap-1.5">
|
| 730 |
-
{referenceImages.map((image, refIndex) => {
|
| 731 |
-
const active = item.refIndexes.includes(refIndex);
|
| 732 |
-
return (
|
| 733 |
-
<button
|
| 734 |
-
key={`${item.id}-ref-${refIndex}`}
|
| 735 |
-
type="button"
|
| 736 |
-
data-testid="batch-ref-option"
|
| 737 |
-
onClick={() => toggleBatchReference(item.id, refIndex)}
|
| 738 |
-
className={cn(
|
| 739 |
-
"relative overflow-hidden rounded-lg border-2 transition",
|
| 740 |
-
active ? "border-stone-900" : "border-transparent hover:border-stone-300",
|
| 741 |
-
)}
|
| 742 |
-
title={image.name}
|
| 743 |
-
>
|
| 744 |
-
{/* eslint-disable-next-line @next/next/no-img-element */}
|
| 745 |
-
<img src={image.dataUrl} alt={image.name} className="size-11 object-cover" />
|
| 746 |
-
{active ? (
|
| 747 |
-
<span className="absolute right-0 top-0 flex size-4 items-center justify-center rounded-bl-md bg-stone-950 text-white">
|
| 748 |
-
<Check className="size-2.5" />
|
| 749 |
-
</span>
|
| 750 |
-
) : null}
|
| 751 |
-
</button>
|
| 752 |
-
);
|
| 753 |
-
})}
|
| 754 |
-
</div>
|
| 755 |
-
</>
|
| 756 |
-
)}
|
| 757 |
-
</div>
|
| 758 |
) : null}
|
| 759 |
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
</select>
|
| 772 |
-
<select
|
| 773 |
-
value={item.size ?? imageSize}
|
| 774 |
-
onChange={(event) => updateBatchItem(item.id, { size: event.target.value })}
|
| 775 |
-
className="h-8 rounded-lg border border-stone-200 bg-white px-2 text-xs text-stone-700 outline-none"
|
| 776 |
-
aria-label="这条的比例"
|
| 777 |
-
>
|
| 778 |
-
{imageSizeOptions.map((option) => (
|
| 779 |
-
<option key={option.value || "auto"} value={option.value}>{option.label}</option>
|
| 780 |
-
))}
|
| 781 |
-
</select>
|
| 782 |
-
<button
|
| 783 |
-
type="button"
|
| 784 |
-
onClick={() => updateBatchItem(item.id, { model: undefined, size: undefined })}
|
| 785 |
-
className="text-[11px] text-stone-400 underline decoration-dotted hover:text-stone-600"
|
| 786 |
-
>
|
| 787 |
-
恢复跟全局
|
| 788 |
-
</button>
|
| 789 |
-
</div>
|
| 790 |
-
) : null}
|
| 791 |
</div>
|
| 792 |
);
|
| 793 |
})}
|
| 794 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 795 |
<button
|
| 796 |
type="button"
|
| 797 |
-
data-testid="batch-
|
| 798 |
-
onClick={
|
| 799 |
-
className=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
>
|
| 801 |
-
|
| 802 |
-
添加一条
|
| 803 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
</div>
|
| 805 |
-
) :
|
| 806 |
-
<PromptComposerTextarea
|
| 807 |
-
ref={textareaRef}
|
| 808 |
-
value={prompt}
|
| 809 |
-
style={promptHeight ? { height: `${promptHeight}px` } : undefined}
|
| 810 |
-
onValueChange={onPromptChange}
|
| 811 |
-
onPaste={handleTextareaPaste}
|
| 812 |
-
placeholder={
|
| 813 |
-
referenceImages.length > 0
|
| 814 |
-
? "描述你希望如何修改参考图"
|
| 815 |
-
: "输入你想要生成的画面,也可直接粘贴图片"
|
| 816 |
-
}
|
| 817 |
-
onSubmit={onSubmit}
|
| 818 |
-
onOptimize={onOptimizePrompt}
|
| 819 |
-
isOptimizing={isOptimizingPrompt}
|
| 820 |
-
/>
|
| 821 |
-
)}
|
| 822 |
{isDraggingImage ? (
|
| 823 |
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-[24px] border-2 border-dashed border-stone-900 bg-white/85 text-sm font-medium text-stone-900 backdrop-blur-[1px] sm:rounded-[32px]">
|
| 824 |
<div className="flex items-center gap-2 rounded-full bg-stone-950 px-4 py-2 text-white shadow-lg">
|
|
|
|
| 1 |
"use client";
|
| 2 |
+
import { Check, ChevronDown, FileSpreadsheet, ImagePlus, Layers, LoaderCircle, Plus, Scissors, X } from "lucide-react";
|
| 3 |
+
import * as XLSX from "xlsx";
|
| 4 |
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type PointerEvent, type RefObject } from "react";
|
| 5 |
|
| 6 |
import {
|
|
|
|
| 54 |
return { id: `batch-${Date.now().toString(36)}-${batchItemSeq}`, prompt, count: 1, refIndexes: [] };
|
| 55 |
}
|
| 56 |
|
| 57 |
+
/** 批量提示词默认分隔符:粘贴 / 导入时用它把一大段文本拆成多条。 */
|
| 58 |
+
const BATCH_DEFAULT_SEPARATOR = "----";
|
| 59 |
+
|
| 60 |
+
/** 表格首行常见表头,导入时跳过,免得把「提示词」也生成一张图。 */
|
| 61 |
+
const BATCH_SHEET_HEADERS = new Set(["提示词", "提示语", "prompt", "prompts", "内容", "描述"]);
|
| 62 |
+
|
| 63 |
type ImageComposerProps = {
|
| 64 |
prompt: string;
|
| 65 |
imageCount: string;
|
|
|
|
| 219 |
const [isDraggingImage, setIsDraggingImage] = useState(false);
|
| 220 |
const [isBatchMode, setIsBatchMode] = useState(false);
|
| 221 |
const [batchItems, setBatchItems] = useState<BatchItem[]>(() => [createBatchItem()]);
|
|
|
|
| 222 |
const [batchPickerId, setBatchPickerId] = useState<string | null>(null);
|
| 223 |
+
/** 当前编辑中的条目(输入框显示的就是它)。 */
|
| 224 |
+
const [activeBatchId, setActiveBatchId] = useState<string | null>(null);
|
| 225 |
+
/** 拆分提示词用的分隔符,默认 ----,可自行改。 */
|
| 226 |
+
const [batchSeparator, setBatchSeparator] = useState(BATCH_DEFAULT_SEPARATOR);
|
| 227 |
+
/** 导入 / 拆分后的一句提示文案。 */
|
| 228 |
+
const [batchNotice, setBatchNotice] = useState("");
|
| 229 |
+
const batchImportInputRef = useRef<HTMLInputElement | null>(null);
|
| 230 |
const [sizeMenuPos, setSizeMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
| 231 |
const [modelMenuPos, setModelMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
| 232 |
// 心跳由全局 PresenceProvider 统一上报(覆盖站内所有页面),这里只负责展示数字。
|
|
|
|
| 312 |
: unitCreditCost.toFixed(2).replace(/\.?0+$/, "");
|
| 313 |
|
| 314 |
// ---- 批量模式 ----------------------------------------------------------
|
| 315 |
+
// 批量 = 一串「提示词条目」:列表显示在输入框**上方**,原来的输入框用来编辑
|
| 316 |
+
// 「当前选中那条」的提示词。每条仍可独立选参考图、独立张数、按需覆盖参数。
|
| 317 |
const batchValidItems = useMemo(() => batchItems.filter((item) => item.prompt.trim()), [batchItems]);
|
| 318 |
const batchTotalCount = batchValidItems.reduce((sum, item) => sum + Math.max(1, item.count), 0);
|
| 319 |
const batchEstimatedCost = batchTotalCount * unitCreditCost;
|
|
|
|
| 321 |
? String(batchEstimatedCost)
|
| 322 |
: batchEstimatedCost.toFixed(2).replace(/\.?0+$/, "");
|
| 323 |
const batchItemCounts = useMemo(() => ({ valid: batchValidItems.length, total: batchItems.length }), [batchItems.length, batchValidItems.length]);
|
| 324 |
+
const activeBatchItem = useMemo(
|
| 325 |
+
() => batchItems.find((item) => item.id === activeBatchId) ?? null,
|
| 326 |
+
[batchItems, activeBatchId],
|
| 327 |
+
);
|
| 328 |
+
const activeBatchIndex = activeBatchItem ? batchItems.findIndex((item) => item.id === activeBatchItem.id) : -1;
|
| 329 |
|
| 330 |
const enterBatchMode = () => {
|
| 331 |
setIsBatchMode(true);
|
| 332 |
+
setBatchPickerId(null);
|
| 333 |
+
setBatchNotice("");
|
| 334 |
+
const pending = prompt.trim();
|
| 335 |
+
if (pending && batchItems.length === 1 && !batchItems[0].prompt.trim()) {
|
| 336 |
// 把已经敲在输入框里的提示词带进第 1 条,不清空用户内容。
|
| 337 |
+
const next = [{ ...batchItems[0], prompt }];
|
| 338 |
+
setBatchItems(next);
|
| 339 |
+
setActiveBatchId(next[0].id);
|
| 340 |
+
return;
|
| 341 |
+
}
|
| 342 |
+
setActiveBatchId(batchItems[0]?.id ?? null);
|
|
|
|
| 343 |
};
|
| 344 |
const exitBatchMode = () => {
|
| 345 |
setIsBatchMode(false);
|
| 346 |
setBatchPickerId(null);
|
| 347 |
+
setActiveBatchId(null);
|
| 348 |
+
setBatchNotice("");
|
| 349 |
};
|
| 350 |
const updateBatchItem = (id: string, patch: Partial<BatchItem>) => {
|
| 351 |
setBatchItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
| 352 |
};
|
| 353 |
+
const selectBatchItem = (id: string) => {
|
| 354 |
+
setActiveBatchId(id);
|
| 355 |
+
setBatchPickerId(null);
|
| 356 |
+
};
|
| 357 |
const addBatchItem = () => {
|
| 358 |
+
const item = createBatchItem();
|
| 359 |
+
setBatchItems((current) => [...current, item]);
|
| 360 |
+
setActiveBatchId(item.id);
|
| 361 |
setBatchPickerId(null);
|
| 362 |
};
|
| 363 |
const removeBatchItem = (id: string) => {
|
| 364 |
+
if (batchItems.length <= 1) return;
|
| 365 |
+
const index = batchItems.findIndex((item) => item.id === id);
|
| 366 |
+
const next = batchItems.filter((item) => item.id !== id);
|
| 367 |
+
setBatchItems(next);
|
| 368 |
+
if (id === activeBatchId) {
|
| 369 |
+
setActiveBatchId(next[Math.min(Math.max(index, 0), next.length - 1)]?.id ?? null);
|
| 370 |
+
}
|
| 371 |
+
setBatchPickerId(null);
|
| 372 |
};
|
| 373 |
const toggleBatchReference = (itemId: string, index: number) => {
|
| 374 |
setBatchItems((current) => current.map((item) => {
|
|
|
|
| 380 |
return { ...item, refIndexes: next };
|
| 381 |
}));
|
| 382 |
};
|
| 383 |
+
|
| 384 |
+
/** 按当前分隔符把一段文本拆成多条提示词;分隔符留空时退化为按换行拆。 */
|
| 385 |
+
const splitBatchPrompts = (raw: string): string[] => {
|
| 386 |
+
const separator = batchSeparator.trim();
|
| 387 |
+
const parts = separator ? raw.split(separator) : raw.split(/\r?\n/);
|
| 388 |
+
return parts.map((part) => part.trim()).filter(Boolean);
|
| 389 |
+
};
|
| 390 |
+
|
| 391 |
+
/** 追加若干条提示词(跳过空串),选中最后一条,返回实际追加条数。 */
|
| 392 |
+
const appendBatchPrompts = (prompts: string[]): number => {
|
| 393 |
+
const cleaned = prompts.map((item) => item.trim()).filter(Boolean);
|
| 394 |
+
if (cleaned.length === 0) return 0;
|
| 395 |
+
const created = cleaned.map((text) => createBatchItem(text));
|
| 396 |
+
// ⚠️ 必须用函数式更新:调用方可能刚在同一批次里改过某条(如拆分时改了当前条),
|
| 397 |
+
// 用闭包里的 batchItems 直接赋值会把那次改动顶掉。
|
| 398 |
+
setBatchItems((current) => {
|
| 399 |
+
// 列表里只有一条空行时直接占用它,避免头上留一行空的。
|
| 400 |
+
const reuseEmpty = current.length === 1 && !current[0].prompt.trim();
|
| 401 |
+
return reuseEmpty ? created : [...current, ...created];
|
| 402 |
+
});
|
| 403 |
+
setActiveBatchId(created[created.length - 1].id);
|
| 404 |
+
return cleaned.length;
|
| 405 |
+
};
|
| 406 |
+
|
| 407 |
+
/** 导入 xlsx / xls / csv:读各工作表第一列的非空单元格,单元格内再按分隔符拆。 */
|
| 408 |
+
const handleBatchImportFile = async (file: File) => {
|
| 409 |
+
try {
|
| 410 |
+
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
| 411 |
+
const prompts: string[] = [];
|
| 412 |
+
workbook.SheetNames.forEach((name) => {
|
| 413 |
+
const sheet = workbook.Sheets[name];
|
| 414 |
+
if (!sheet) return;
|
| 415 |
+
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, blankrows: false, defval: "" });
|
| 416 |
+
const firstColumn = rows.map((row) => String(row?.[0] ?? "").trim()).filter(Boolean);
|
| 417 |
+
if (firstColumn.length > 0) {
|
| 418 |
+
// 常见表头(「提示词」「prompt」)不该被当成一条提示词。
|
| 419 |
+
if (BATCH_SHEET_HEADERS.has(firstColumn[0].toLowerCase())) firstColumn.shift();
|
| 420 |
+
prompts.push(...firstColumn);
|
| 421 |
+
return;
|
| 422 |
+
}
|
| 423 |
+
// 第一列整列为空(提示词横着排):退回收集表内所有非空单元格。
|
| 424 |
+
rows.forEach((row) => {
|
| 425 |
+
(row ?? []).forEach((cell) => {
|
| 426 |
+
const text = String(cell ?? "").trim();
|
| 427 |
+
if (text) prompts.push(text);
|
| 428 |
+
});
|
| 429 |
+
});
|
| 430 |
+
});
|
| 431 |
+
const added = appendBatchPrompts(prompts.flatMap((item) => splitBatchPrompts(item)));
|
| 432 |
+
setBatchNotice(added > 0 ? `已导入 ${added} 条` : "这个文件里没读到提示词");
|
| 433 |
+
} catch (error) {
|
| 434 |
+
setBatchNotice(error instanceof Error ? `导入失败:${error.message}` : "导入失败");
|
| 435 |
+
}
|
| 436 |
+
setIsBatchMode(true);
|
| 437 |
+
};
|
| 438 |
+
|
| 439 |
+
/** 把当前输入框(= 选中条目)的内容按分隔符拆开,其余追加为新条目。 */
|
| 440 |
+
const handleBatchSplitActive = () => {
|
| 441 |
+
if (!activeBatchItem) return;
|
| 442 |
+
const parts = splitBatchPrompts(activeBatchItem.prompt);
|
| 443 |
+
if (parts.length <= 1) {
|
| 444 |
+
setBatchNotice(`没找到分隔符「${batchSeparator.trim() || "换行"}」`);
|
| 445 |
+
return;
|
| 446 |
+
}
|
| 447 |
+
const [first, ...rest] = parts;
|
| 448 |
+
updateBatchItem(activeBatchItem.id, { prompt: first });
|
| 449 |
+
appendBatchPrompts(rest);
|
| 450 |
+
setBatchNotice(`已拆成 ${parts.length} 条`);
|
| 451 |
+
};
|
| 452 |
+
|
| 453 |
+
const handleBatchActivePromptChange = (value: string) => {
|
| 454 |
+
if (!activeBatchItem) return;
|
| 455 |
+
updateBatchItem(activeBatchItem.id, { prompt: value });
|
| 456 |
+
};
|
| 457 |
+
|
| 458 |
+
/** 批量模式下的粘贴:内容带分隔符就一次拆成多条,否则按普通粘贴处理。 */
|
| 459 |
+
const handleBatchPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
| 460 |
+
const text = event.clipboardData?.getData("text") ?? "";
|
| 461 |
+
const parts = text ? splitBatchPrompts(text) : [];
|
| 462 |
+
if (parts.length <= 1 || !activeBatchItem) {
|
| 463 |
+
handleTextareaPaste(event);
|
| 464 |
+
return;
|
| 465 |
+
}
|
| 466 |
+
event.preventDefault();
|
| 467 |
+
const [first, ...rest] = parts;
|
| 468 |
+
updateBatchItem(activeBatchItem.id, { prompt: first });
|
| 469 |
+
appendBatchPrompts(rest);
|
| 470 |
+
setBatchNotice(`粘贴内容已拆成 ${parts.length} 条`);
|
| 471 |
+
};
|
| 472 |
+
|
| 473 |
const handleBatchSubmitClick = async () => {
|
| 474 |
if (!onBatchSubmit) return;
|
| 475 |
const items: BatchSubmitItem[] = batchValidItems.map((item) => ({
|
|
|
|
| 484 |
if (items.length === 0) return;
|
| 485 |
await onBatchSubmit(items);
|
| 486 |
setBatchItems([createBatchItem()]);
|
|
|
|
| 487 |
setBatchPickerId(null);
|
| 488 |
+
setActiveBatchId(null);
|
| 489 |
+
setBatchNotice("");
|
| 490 |
setIsBatchMode(false);
|
| 491 |
};
|
| 492 |
const requiresImageSize = imageModel.trim().toLowerCase() === "gpt-image-2-4k";
|
|
|
|
| 750 |
onPointerDown={handlePromptResizePointerDown}
|
| 751 |
/>
|
| 752 |
{isBatchMode ? (
|
| 753 |
+
<div className="border-b border-stone-100 px-2.5 pb-2.5 pt-3 sm:px-4" data-testid="batch-panel">
|
| 754 |
+
<div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">
|
| 755 |
+
<span className="flex items-center gap-1.5 text-xs font-medium text-stone-600">
|
| 756 |
+
<Layers className="size-3.5" />
|
| 757 |
+
批量提示词
|
| 758 |
+
</span>
|
| 759 |
<span className="shrink-0 text-[11px] tabular-nums text-stone-400">
|
| 760 |
+
{batchItemCounts.valid} 条 · 共 {batchTotalCount} 张
|
| 761 |
</span>
|
| 762 |
</div>
|
| 763 |
+
|
| 764 |
+
<div className="max-h-[38dvh] space-y-1 overflow-y-auto pr-0.5" data-testid="batch-list">
|
| 765 |
{batchItems.map((item, index) => {
|
| 766 |
+
const isActive = item.id === activeBatchId;
|
|
|
|
| 767 |
return (
|
| 768 |
+
<div
|
| 769 |
+
key={item.id}
|
| 770 |
+
data-testid="batch-row"
|
| 771 |
+
data-active={isActive ? "true" : "false"}
|
| 772 |
+
className={cn(
|
| 773 |
+
"group flex items-center gap-1.5 rounded-xl border px-2 py-1.5 transition",
|
| 774 |
+
isActive
|
| 775 |
+
? "border-stone-300 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.06)]"
|
| 776 |
+
: "border-transparent bg-stone-50 hover:bg-white",
|
| 777 |
+
)}
|
| 778 |
+
>
|
| 779 |
+
<button
|
| 780 |
+
type="button"
|
| 781 |
+
data-testid="batch-row-select"
|
| 782 |
+
onClick={() => selectBatchItem(item.id)}
|
| 783 |
+
className="flex min-w-0 flex-1 items-center gap-2 py-0.5 text-left"
|
| 784 |
+
title="选中后在上面输入框里编辑这条"
|
| 785 |
+
>
|
| 786 |
+
<span
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
className={cn(
|
| 788 |
+
"flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium tabular-nums",
|
| 789 |
+
isActive ? "bg-stone-900 text-white" : "bg-stone-200 text-stone-500",
|
| 790 |
)}
|
|
|
|
| 791 |
>
|
| 792 |
+
{index + 1}
|
| 793 |
+
</span>
|
| 794 |
+
<span className={cn("truncate text-[13px] leading-5", item.prompt.trim() ? "text-stone-700" : "text-stone-400")}>
|
| 795 |
+
{item.prompt.trim() || "(空)选中后在上面的输入框填写"}
|
| 796 |
+
</span>
|
| 797 |
+
</button>
|
| 798 |
+
|
| 799 |
+
{item.refIndexes.length > 0 ? (
|
| 800 |
+
<span
|
| 801 |
+
className="shrink-0 rounded-full bg-stone-900/85 px-1.5 py-0.5 text-[10px] leading-none tabular-nums text-white"
|
| 802 |
+
title={`这条已选 ${item.refIndexes.length} 张参考图`}
|
| 803 |
>
|
| 804 |
+
{item.refIndexes.length}
|
| 805 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
) : null}
|
| 807 |
|
| 808 |
+
<span className="shrink-0 text-[11px] tabular-nums text-stone-400">×{item.count}</span>
|
| 809 |
+
|
| 810 |
+
<button
|
| 811 |
+
type="button"
|
| 812 |
+
data-testid="batch-row-remove"
|
| 813 |
+
onClick={() => removeBatchItem(item.id)}
|
| 814 |
+
className="flex size-6 shrink-0 items-center justify-center rounded-full text-stone-300 opacity-0 transition hover:bg-rose-50 hover:text-rose-500 focus-visible:opacity-100 group-hover:opacity-100"
|
| 815 |
+
aria-label={`删除第 ${index + 1} 条`}
|
| 816 |
+
>
|
| 817 |
+
<X className="size-3.5" />
|
| 818 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 819 |
</div>
|
| 820 |
);
|
| 821 |
})}
|
| 822 |
</div>
|
| 823 |
+
|
| 824 |
+
<div className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-1.5 px-0.5 text-[11px]">
|
| 825 |
+
<button
|
| 826 |
+
type="button"
|
| 827 |
+
data-testid="batch-add-row"
|
| 828 |
+
onClick={addBatchItem}
|
| 829 |
+
className="inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium text-stone-500 transition hover:bg-stone-100 hover:text-stone-800"
|
| 830 |
+
>
|
| 831 |
+
<Plus className="size-3.5" />
|
| 832 |
+
添加一条
|
| 833 |
+
</button>
|
| 834 |
+
<span className="text-stone-200">·</span>
|
| 835 |
+
<button
|
| 836 |
+
type="button"
|
| 837 |
+
data-testid="batch-import"
|
| 838 |
+
onClick={() => batchImportInputRef.current?.click()}
|
| 839 |
+
className="inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium text-stone-500 transition hover:bg-stone-100 hover:text-stone-800"
|
| 840 |
+
>
|
| 841 |
+
<FileSpreadsheet className="size-3.5" />
|
| 842 |
+
导入 xlsx
|
| 843 |
+
</button>
|
| 844 |
+
<span className="text-stone-200">·</span>
|
| 845 |
+
<label className="inline-flex items-center gap-1 text-stone-400">
|
| 846 |
+
分隔符
|
| 847 |
+
<input
|
| 848 |
+
data-testid="batch-separator"
|
| 849 |
+
value={batchSeparator}
|
| 850 |
+
onChange={(event) => setBatchSeparator(event.target.value)}
|
| 851 |
+
placeholder="----"
|
| 852 |
+
className="h-6 w-14 rounded-md border border-stone-200 bg-white px-1.5 text-center text-[11px] text-stone-700 outline-none focus:border-stone-400"
|
| 853 |
+
/>
|
| 854 |
+
</label>
|
| 855 |
+
<button
|
| 856 |
+
type="button"
|
| 857 |
+
data-testid="batch-split"
|
| 858 |
+
onClick={handleBatchSplitActive}
|
| 859 |
+
className="inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium text-stone-500 transition hover:bg-stone-100 hover:text-stone-800"
|
| 860 |
+
>
|
| 861 |
+
<Scissors className="size-3.5" />
|
| 862 |
+
拆分输入框
|
| 863 |
+
</button>
|
| 864 |
+
{batchNotice ? <span data-testid="batch-notice" className="text-stone-400">{batchNotice}</span> : null}
|
| 865 |
+
</div>
|
| 866 |
+
|
| 867 |
+
<input
|
| 868 |
+
ref={batchImportInputRef}
|
| 869 |
+
type="file"
|
| 870 |
+
accept=".xlsx,.xls,.csv"
|
| 871 |
+
data-testid="batch-import-input"
|
| 872 |
+
className="hidden"
|
| 873 |
+
onChange={(event) => {
|
| 874 |
+
const input = event.target;
|
| 875 |
+
const file = input.files?.[0];
|
| 876 |
+
if (!file) return;
|
| 877 |
+
// ⚠️ 必须先读完再清空 input:提前 value = "" 会让拿到的 File 变成不可读,
|
| 878 |
+
// Chrome 会抛 NotReadableError(permission problems ...)。
|
| 879 |
+
void handleBatchImportFile(file).finally(() => {
|
| 880 |
+
input.value = "";
|
| 881 |
+
});
|
| 882 |
+
}}
|
| 883 |
+
/>
|
| 884 |
+
</div>
|
| 885 |
+
) : null}
|
| 886 |
+
|
| 887 |
+
<PromptComposerTextarea
|
| 888 |
+
ref={textareaRef}
|
| 889 |
+
value={isBatchMode ? (activeBatchItem?.prompt ?? "") : prompt}
|
| 890 |
+
style={promptHeight ? { height: `${promptHeight}px` } : undefined}
|
| 891 |
+
onValueChange={isBatchMode ? handleBatchActivePromptChange : onPromptChange}
|
| 892 |
+
onPaste={isBatchMode ? handleBatchPaste : handleTextareaPaste}
|
| 893 |
+
placeholder={
|
| 894 |
+
isBatchMode
|
| 895 |
+
? activeBatchIndex >= 0
|
| 896 |
+
? `编辑第 ${activeBatchIndex + 1} 条提示词(粘贴含 ${batchSeparator.trim() || "换行"} 的内容可一次拆成多条)`
|
| 897 |
+
: "先在上面选一条,或点「添加一条」"
|
| 898 |
+
: referenceImages.length > 0
|
| 899 |
+
? "描述你希望如何修改参考图"
|
| 900 |
+
: "输入你想要生成的画面,也可直接粘贴图片"
|
| 901 |
+
}
|
| 902 |
+
onSubmit={isBatchMode ? handleBatchSubmitClick : onSubmit}
|
| 903 |
+
onOptimize={isBatchMode ? undefined : onOptimizePrompt}
|
| 904 |
+
isOptimizing={isBatchMode ? false : isOptimizingPrompt}
|
| 905 |
+
/>
|
| 906 |
+
|
| 907 |
+
{isBatchMode && activeBatchItem ? (
|
| 908 |
+
<div
|
| 909 |
+
className="flex flex-wrap items-center gap-1.5 border-t border-stone-100 px-2.5 py-2 sm:px-4"
|
| 910 |
+
data-testid="batch-row-settings"
|
| 911 |
+
>
|
| 912 |
+
<span className="shrink-0 text-[11px] text-stone-400">第 {activeBatchIndex + 1} 条</span>
|
| 913 |
<button
|
| 914 |
type="button"
|
| 915 |
+
data-testid="batch-row-ref"
|
| 916 |
+
onClick={() => setBatchPickerId(batchPickerId === activeBatchItem.id ? null : activeBatchItem.id)}
|
| 917 |
+
className={cn(
|
| 918 |
+
"h-7 shrink-0 rounded-full border px-2.5 text-[11px] font-medium transition",
|
| 919 |
+
activeBatchItem.refIndexes.length > 0
|
| 920 |
+
? "border-stone-900 bg-stone-950 text-white hover:bg-stone-800"
|
| 921 |
+
: "border-stone-200 bg-white text-stone-500 hover:bg-stone-100",
|
| 922 |
+
)}
|
| 923 |
+
title="从参考图池里选图(不选 = 纯文生图)"
|
| 924 |
>
|
| 925 |
+
{activeBatchItem.refIndexes.length > 0 ? `参考图 ${activeBatchItem.refIndexes.length}` : "参考图"}
|
|
|
|
| 926 |
</button>
|
| 927 |
+
<div className="flex h-7 shrink-0 items-center rounded-full border border-stone-200 bg-white px-1">
|
| 928 |
+
<button
|
| 929 |
+
type="button"
|
| 930 |
+
className="flex size-5 items-center justify-center rounded-full text-stone-500 hover:bg-stone-100"
|
| 931 |
+
onClick={() => updateBatchItem(activeBatchItem.id, { count: Math.max(1, activeBatchItem.count - 1) })}
|
| 932 |
+
aria-label="减少张数"
|
| 933 |
+
>−</button>
|
| 934 |
+
<input
|
| 935 |
+
type="number"
|
| 936 |
+
inputMode="numeric"
|
| 937 |
+
min="1"
|
| 938 |
+
max="100"
|
| 939 |
+
value={activeBatchItem.count}
|
| 940 |
+
onChange={(event) => {
|
| 941 |
+
const next = Number(event.target.value);
|
| 942 |
+
updateBatchItem(activeBatchItem.id, { count: Number.isFinite(next) && next > 0 ? Math.min(100, Math.floor(next)) : 1 });
|
| 943 |
+
}}
|
| 944 |
+
className="w-7 border-0 bg-transparent px-0 text-center text-xs font-medium text-stone-700 outline-none"
|
| 945 |
+
aria-label="这条生成张数"
|
| 946 |
+
/>
|
| 947 |
+
<button
|
| 948 |
+
type="button"
|
| 949 |
+
className="flex size-5 items-center justify-center rounded-full text-stone-500 hover:bg-stone-100"
|
| 950 |
+
onClick={() => updateBatchItem(activeBatchItem.id, { count: Math.min(100, activeBatchItem.count + 1) })}
|
| 951 |
+
aria-label="增加张数"
|
| 952 |
+
>+</button>
|
| 953 |
+
</div>
|
| 954 |
+
<select
|
| 955 |
+
data-testid="batch-row-model"
|
| 956 |
+
value={activeBatchItem.model ?? imageModel}
|
| 957 |
+
onChange={(event) => updateBatchItem(activeBatchItem.id, { model: event.target.value })}
|
| 958 |
+
className="h-7 max-w-[9.5rem] shrink-0 rounded-full border border-stone-200 bg-white px-2 text-[11px] text-stone-600 outline-none"
|
| 959 |
+
title="这条用哪个模型(默认跟随全局)"
|
| 960 |
+
aria-label="这条的模型"
|
| 961 |
+
>
|
| 962 |
+
{modelOptions.map((option) => (
|
| 963 |
+
<option key={option.key} value={option.id}>{option.id}</option>
|
| 964 |
+
))}
|
| 965 |
+
</select>
|
| 966 |
+
<select
|
| 967 |
+
data-testid="batch-row-size"
|
| 968 |
+
value={activeBatchItem.size ?? imageSize}
|
| 969 |
+
onChange={(event) => updateBatchItem(activeBatchItem.id, { size: event.target.value })}
|
| 970 |
+
className="h-7 shrink-0 rounded-full border border-stone-200 bg-white px-2 text-[11px] text-stone-600 outline-none"
|
| 971 |
+
title="这条用哪个比例(默认跟随全局)"
|
| 972 |
+
aria-label="这条的比例"
|
| 973 |
+
>
|
| 974 |
+
{imageSizeOptions.map((option) => (
|
| 975 |
+
<option key={option.value || "auto"} value={option.value}>{option.label}</option>
|
| 976 |
+
))}
|
| 977 |
+
</select>
|
| 978 |
+
|
| 979 |
+
{batchPickerId === activeBatchItem.id ? (
|
| 980 |
+
<div className="w-full pt-1">
|
| 981 |
+
{referenceImages.length === 0 ? (
|
| 982 |
+
<p className="text-[11px] leading-4 text-stone-400">
|
| 983 |
+
还没有参考图。点左下角图片按钮上传,上传的图会进「图池」,每条都能选。
|
| 984 |
+
</p>
|
| 985 |
+
) : (
|
| 986 |
+
<div className="flex flex-wrap gap-1.5">
|
| 987 |
+
{referenceImages.map((image, refIndex) => {
|
| 988 |
+
const active = activeBatchItem.refIndexes.includes(refIndex);
|
| 989 |
+
return (
|
| 990 |
+
<button
|
| 991 |
+
key={`${activeBatchItem.id}-ref-${refIndex}`}
|
| 992 |
+
type="button"
|
| 993 |
+
data-testid="batch-ref-option"
|
| 994 |
+
onClick={() => toggleBatchReference(activeBatchItem.id, refIndex)}
|
| 995 |
+
className={cn(
|
| 996 |
+
"relative overflow-hidden rounded-lg border-2 transition",
|
| 997 |
+
active ? "border-stone-900" : "border-transparent hover:border-stone-300",
|
| 998 |
+
)}
|
| 999 |
+
title={image.name}
|
| 1000 |
+
>
|
| 1001 |
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
| 1002 |
+
<img src={image.dataUrl} alt={image.name} className="size-11 object-cover" />
|
| 1003 |
+
{active ? (
|
| 1004 |
+
<span className="absolute right-0 top-0 flex size-4 items-center justify-center rounded-bl-md bg-stone-950 text-white">
|
| 1005 |
+
<Check className="size-2.5" />
|
| 1006 |
+
</span>
|
| 1007 |
+
) : null}
|
| 1008 |
+
</button>
|
| 1009 |
+
);
|
| 1010 |
+
})}
|
| 1011 |
+
</div>
|
| 1012 |
+
)}
|
| 1013 |
+
</div>
|
| 1014 |
+
) : null}
|
| 1015 |
+
|
| 1016 |
+
{activeBatchItem.model || activeBatchItem.size ? (
|
| 1017 |
+
<button
|
| 1018 |
+
type="button"
|
| 1019 |
+
onClick={() => updateBatchItem(activeBatchItem.id, { model: undefined, size: undefined })}
|
| 1020 |
+
className="shrink-0 text-[11px] text-stone-400 underline decoration-dotted hover:text-stone-600"
|
| 1021 |
+
>
|
| 1022 |
+
恢复跟全局
|
| 1023 |
+
</button>
|
| 1024 |
+
) : null}
|
| 1025 |
</div>
|
| 1026 |
+
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1027 |
{isDraggingImage ? (
|
| 1028 |
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-[24px] border-2 border-dashed border-stone-900 bg-white/85 text-sm font-medium text-stone-900 backdrop-blur-[1px] sm:rounded-[32px]">
|
| 1029 |
<div className="flex items-center gap-2 rounded-full bg-stone-950 px-4 py-2 text-white shadow-lg">
|