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 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, Trash2, X } from "lucide-react";
 
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
- setBatchItems((current) => {
 
 
 
315
  // 把已经敲在输入框里的提示词带进第 1 条,不清空用户内容。
316
- const pending = prompt.trim();
317
- if (!pending) return current;
318
- if (current.length === 1 && !current[0].prompt.trim()) {
319
- return [{ ...current[0], prompt }];
320
- }
321
- return current;
322
- });
323
  };
324
  const exitBatchMode = () => {
325
  setIsBatchMode(false);
326
  setBatchPickerId(null);
327
- setBatchExpandedIds([]);
 
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
- setBatchItems((current) => [...current, createBatchItem()]);
 
 
334
  setBatchPickerId(null);
335
  };
336
  const removeBatchItem = (id: string) => {
337
- setBatchItems((current) => (current.length <= 1 ? current : current.filter((item) => item.id !== id)));
338
- };
339
- const toggleBatchExpanded = (id: string) => {
340
- setBatchExpandedIds((current) => (
341
- current.includes(id) ? current.filter((value) => value !== id) : [...current, id]
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-1 pt-3 sm:px-4" data-testid="batch-editor">
634
- <div className="mb-2 flex items-center justify-between gap-2 px-0.5">
635
- <span className="text-xs font-medium text-stone-500">每条一个提示词 · 张数与参考图各自独立</span>
 
 
 
636
  <span className="shrink-0 text-[11px] tabular-nums text-stone-400">
637
- {batchItemCounts.valid} 条有效 · 共 {batchTotalCount} 张
638
  </span>
639
  </div>
640
- <div className="max-h-[46dvh] space-y-1.5 overflow-y-auto pr-0.5">
 
641
  {batchItems.map((item, index) => {
642
- const isPickerOpen = batchPickerId === item.id;
643
- const isExpanded = batchExpandedIds.includes(item.id);
644
  return (
645
- <div key={item.id} className="rounded-2xl border border-stone-200 bg-white/80" data-testid="batch-row">
646
- <div className="flex items-start gap-1.5 p-1.5">
647
- <span className="mt-2.5 w-4 shrink-0 text-center text-[11px] tabular-nums text-stone-400">{index + 1}</span>
648
- <textarea
649
- data-testid="batch-row-input"
650
- value={item.prompt}
651
- rows={1}
652
- placeholder="输入这条提示词"
653
- onChange={(event) => updateBatchItem(item.id, { prompt: event.target.value })}
654
- className="min-h-9 flex-1 resize-none overflow-hidden rounded-xl border border-stone-200 bg-white px-2.5 py-2 text-sm leading-5 text-stone-800 outline-none placeholder:text-stone-400 focus:border-stone-400"
655
- />
656
- <button
657
- type="button"
658
- data-testid="batch-row-ref"
659
- onClick={() => setBatchPickerId(isPickerOpen ? null : item.id)}
660
- className={cn(
661
- "h-9 shrink-0 rounded-full border px-2.5 text-[11px] font-medium transition",
662
- item.refIndexes.length > 0
663
- ? "border-stone-900 bg-stone-950 text-white hover:bg-stone-800"
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
- "h-9 shrink-0 rounded-full border px-2 text-[11px] font-medium transition",
703
- isExpanded ? "border-stone-400 bg-stone-100 text-stone-800" : "border-stone-200 bg-white text-stone-500 hover:bg-stone-100",
704
  )}
705
- title="这一条的模型 / 比例(默认跟随全局)"
706
  >
707
- 参数
708
- </button>
709
- <button
710
- type="button"
711
- onClick={() => removeBatchItem(item.id)}
712
- disabled={batchItems.length <= 1}
713
- className="flex size-9 shrink-0 items-center justify-center rounded-full text-stone-300 transition hover:bg-rose-50 hover:text-rose-500 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-stone-300"
714
- aria-label="删除这条"
 
 
 
715
  >
716
- <Trash2 className="size-3.5" />
717
- </button>
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
- {isExpanded ? (
761
- <div className="flex flex-wrap items-center gap-1.5 border-t border-stone-100 px-2.5 py-2">
762
- <select
763
- value={item.model ?? imageModel}
764
- onChange={(event) => updateBatchItem(item.id, { model: event.target.value })}
765
- className="h-8 rounded-lg border border-stone-200 bg-white px-2 text-xs text-stone-700 outline-none"
766
- aria-label="这条的模型"
767
- >
768
- {modelOptions.map((option) => (
769
- <option key={option.key} value={option.id}>{option.id}</option>
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-add-row"
798
- onClick={addBatchItem}
799
- className="mt-1.5 inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium text-stone-500 transition hover:bg-stone-100 hover:text-stone-800"
 
 
 
 
 
 
800
  >
801
- <Plus className="size-3.5" />
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">