mjpic 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.trae/documents/mjpic-prd.md +111 -0
  2. package/.trae/documents/mjpic-technical-architecture.md +234 -0
  3. package/README.md +57 -0
  4. package/api/app.ts +60 -0
  5. package/api/cli.ts +61 -0
  6. package/api/index.ts +19 -0
  7. package/api/routes/auth.ts +33 -0
  8. package/api/routes/image.ts +27 -0
  9. package/api/server.ts +45 -0
  10. package/dist/cli/app.js +43 -0
  11. package/dist/cli/cli.js +49 -0
  12. package/dist/cli/index.js +13 -0
  13. package/dist/cli/routes/auth.js +28 -0
  14. package/dist/cli/routes/image.js +21 -0
  15. package/dist/cli/server.js +38 -0
  16. package/dist/client/assets/index-BUIYLOn-.js +197 -0
  17. package/dist/client/assets/index-BoiS81Ei.css +1 -0
  18. package/dist/client/favicon.svg +4 -0
  19. package/dist/client/index.html +354 -0
  20. package/eslint.config.js +28 -0
  21. package/index.html +24 -0
  22. package/nodemon.json +10 -0
  23. package/package.json +68 -0
  24. package/postcss.config.js +10 -0
  25. package/public/favicon.svg +4 -0
  26. package/src/App.tsx +13 -0
  27. package/src/assets/react.svg +1 -0
  28. package/src/components/Empty.tsx +8 -0
  29. package/src/components/dialogs/AspectRatioDialog.tsx +218 -0
  30. package/src/components/dialogs/SaveDialog.tsx +150 -0
  31. package/src/components/layout/CanvasArea.tsx +874 -0
  32. package/src/components/layout/Header.tsx +156 -0
  33. package/src/components/layout/RightPanel.tsx +886 -0
  34. package/src/components/layout/Sidebar.tsx +36 -0
  35. package/src/components/layout/StatusBar.tsx +44 -0
  36. package/src/hooks/useDebounce.ts +17 -0
  37. package/src/hooks/useTheme.ts +29 -0
  38. package/src/i18n/index.ts +26 -0
  39. package/src/i18n/locales/en.json +56 -0
  40. package/src/i18n/locales/zh.json +59 -0
  41. package/src/index.css +14 -0
  42. package/src/lib/utils.ts +73 -0
  43. package/src/main.tsx +11 -0
  44. package/src/pages/Home.tsx +72 -0
  45. package/src/store/useImageStore.ts +316 -0
  46. package/src/store/usePresetStore.ts +65 -0
  47. package/src/store/useUIStore.ts +17 -0
  48. package/src/vite-env.d.ts +1 -0
  49. package/tailwind.config.js +13 -0
  50. package/tmp/guangxi.jpg +0 -0
  51. package/tsconfig.json +40 -0
  52. package/tsconfig.server.json +15 -0
  53. package/vercel.json +12 -0
  54. package/vite.config.ts +50 -0
  55. package//345/217/202/350/200/203/345/233/276/347/211/207//346/210/252/345/261/2172026-02-18 16.47.45_/345/233/276/347/211/207/345/260/272/345/257/270/350/260/203/350/212/202_/351/242/204/350/256/276/345/260/272/345/257/270.jpg +0 -0
  56. package//345/217/202/350/200/203/345/233/276/347/211/207//346/210/252/345/261/2172026-02-18 16.47.51_/345/233/276/347/211/207/345/260/272/345/257/270/350/260/203/350/212/202_/346/211/213/345/267/245/350/276/223/345/205/245/345/260/272/345/257/270.jpg +0 -0
  57. package//345/217/202/350/200/203/345/233/276/347/211/207//346/210/252/345/261/2172026-02-18 16.54.56_/345/233/276/347/211/207/345/260/272/345/257/270/350/260/203/350/212/202_/346/267/273/345/212/240/345/270/270/347/224/250/345/260/272/345/257/270.jpg +0 -0
  58. package//345/217/202/350/200/203/345/233/276/347/211/207//346/210/252/345/261/2172026-02-18 16.55.11_/345/233/276/347/211/207/345/260/272/345/257/270/350/260/203/350/212/202_/345/210/240/351/231/244/345/270/270/347/224/250/345/260/272/345/257/270.jpg +0 -0
@@ -0,0 +1,886 @@
1
+ import { useUIStore } from '@/store/useUIStore';
2
+ import { useImageStore } from '@/store/useImageStore';
3
+ import { usePresetStore } from '@/store/usePresetStore';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { useState, useEffect } from 'react';
6
+ import { AspectRatioDialog } from '@/components/dialogs/AspectRatioDialog';
7
+ import { Settings, Ruler } from 'lucide-react';
8
+
9
+ // Reusable slider component with local state and debounce
10
+ const SliderControl = ({
11
+ label,
12
+ value,
13
+ min,
14
+ max,
15
+ step = 1,
16
+ onChange,
17
+ unit = ''
18
+ }: {
19
+ label: string;
20
+ value: number;
21
+ min: number;
22
+ max: number;
23
+ step?: number;
24
+ onChange: (val: number) => void;
25
+ unit?: string;
26
+ }) => {
27
+ const [localValue, setLocalValue] = useState(value);
28
+
29
+ // Sync local value when prop value changes (e.g. undo/redo)
30
+ useEffect(() => {
31
+ setLocalValue(value);
32
+ }, [value]);
33
+
34
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
35
+ const newValue = parseFloat(e.target.value);
36
+ setLocalValue(newValue);
37
+ };
38
+
39
+ const handleCommit = () => {
40
+ if (localValue !== value) {
41
+ onChange(localValue);
42
+ }
43
+ };
44
+
45
+ const handleIncrement = () => {
46
+ let nextVal = localValue + step;
47
+ if (nextVal > max) nextVal = max;
48
+ // Fix floating point issues
49
+ if (step < 1) {
50
+ nextVal = parseFloat(nextVal.toFixed(1));
51
+ }
52
+
53
+ setLocalValue(nextVal);
54
+ onChange(nextVal);
55
+ };
56
+
57
+ const handleDecrement = () => {
58
+ let nextVal = localValue - step;
59
+ if (nextVal < min) nextVal = min;
60
+ // Fix floating point issues
61
+ if (step < 1) {
62
+ nextVal = parseFloat(nextVal.toFixed(1));
63
+ }
64
+
65
+ setLocalValue(nextVal);
66
+ onChange(nextVal);
67
+ };
68
+
69
+ return (
70
+ <div className="space-y-2">
71
+ <div className="flex justify-between text-xs text-zinc-400">
72
+ <span>{label}</span>
73
+ <span>{localValue.toFixed(step < 1 ? 1 : 0)}{unit}</span>
74
+ </div>
75
+ <div className="flex items-center gap-2">
76
+ <button
77
+ onClick={handleDecrement}
78
+ className="w-6 h-6 flex items-center justify-center bg-zinc-800 text-zinc-300 rounded hover:bg-zinc-700 text-lg leading-none pb-1"
79
+ >
80
+ -
81
+ </button>
82
+ <input
83
+ type="range" min={min} max={max} step={step}
84
+ value={localValue}
85
+ onChange={handleChange}
86
+ onMouseUp={handleCommit} // Only update store on release
87
+ onTouchEnd={handleCommit} // For touch devices
88
+ className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
89
+ />
90
+ <button
91
+ onClick={handleIncrement}
92
+ className="w-6 h-6 flex items-center justify-center bg-zinc-800 text-zinc-300 rounded hover:bg-zinc-700 text-lg leading-none pb-1"
93
+ >
94
+ +
95
+ </button>
96
+ </div>
97
+ </div>
98
+ );
99
+ };
100
+
101
+ export const RightPanel = () => {
102
+ const { activeTool, isStraightenToolActive, setStraightenToolActive } = useUIStore();
103
+ const { config, updateConfig, originalWidth, originalHeight, applyCrop, cropRect } = useImageStore();
104
+ const { aspectRatios } = usePresetStore();
105
+ const { t } = useTranslation();
106
+
107
+ // Dialog state for aspect ratio manager
108
+ const [isAspectRatioManagerOpen, setIsAspectRatioManagerOpen] = useState(false);
109
+
110
+ // 计算基于原始图片比例的尺寸
111
+ const calculateSize = (targetValue: number, mode: 'width' | 'height'): { width: number; height: number } => {
112
+ if (originalWidth === 0 || originalHeight === 0) {
113
+ // 如果没有原始尺寸信息,使用默认比例 4:3
114
+ return mode === 'width'
115
+ ? { width: targetValue, height: Math.round(targetValue * 0.75) }
116
+ : { width: Math.round(targetValue * 1.333), height: targetValue };
117
+ }
118
+
119
+ const aspectRatio = originalWidth / originalHeight;
120
+
121
+ if (mode === 'width') {
122
+ return {
123
+ width: targetValue,
124
+ height: Math.round(targetValue / aspectRatio)
125
+ };
126
+ } else {
127
+ return {
128
+ width: Math.round(targetValue * aspectRatio),
129
+ height: targetValue
130
+ };
131
+ }
132
+ };
133
+
134
+ // 格式化尺寸显示字符串
135
+ const formatSizeDisplay = (targetValue: number, mode: 'width' | 'height'): string => {
136
+ const calculated = calculateSize(targetValue, mode);
137
+
138
+ if (mode === 'width') {
139
+ return `${targetValue} × [${calculated.height}]`;
140
+ } else {
141
+ return `[${calculated.width}] × ${targetValue}`;
142
+ }
143
+ };
144
+
145
+ // 解析预设尺寸字符串为宽高对象
146
+ const parseSizeString = (sizeStr: string): { width: number; height: number } | null => {
147
+ const [width, height] = sizeStr.split('x').map(Number);
148
+ if (isNaN(width) || isNaN(height)) return null;
149
+ return { width, height };
150
+ };
151
+
152
+ // 对话框状态
153
+ const [isAddSizeDialogOpen, setIsAddSizeDialogOpen] = useState(false);
154
+ const [isDeleteSizeDialogOpen, setIsDeleteSizeDialogOpen] = useState(false);
155
+
156
+ // 添加尺寸对话框状态
157
+ const [addSizeMode, setAddSizeMode] = useState<'width' | 'height'>('width');
158
+ const [addSizeValue, setAddSizeValue] = useState('');
159
+
160
+ // 删除尺寸对话框状态
161
+ const [deleteSizeMode, setDeleteSizeMode] = useState<'width' | 'height'>('width');
162
+ const [selectedSizeToDelete, setSelectedSizeToDelete] = useState('');
163
+
164
+ // 预设尺寸列表
165
+ type SizeMode = 'width' | 'height';
166
+ type PresetSize = { width: number; height: number; mode: SizeMode };
167
+
168
+ const [presetSizes, setPresetSizes] = useState<PresetSize[]>([
169
+ { width: 450, height: 338, mode: 'width' },
170
+ { width: 600, height: 450, mode: 'width' },
171
+ { width: 850, height: 638, mode: 'width' },
172
+ { width: 1440, height: 1080, mode: 'width' },
173
+ { width: 1920, height: 1440, mode: 'width' },
174
+ { width: 2400, height: 1800, mode: 'width' },
175
+ { width: 3840, height: 2880, mode: 'width' },
176
+ { width: 5120, height: 3840, mode: 'width' }
177
+ ]);
178
+
179
+ // 获取当前选中的预设尺寸的显示文本
180
+ const getSelectedSizeDisplay = (): string => {
181
+ if (!config.resize?.width || !config.resize?.height) return '';
182
+
183
+ const selectedSize = presetSizes.find(
184
+ (size) => size.width === config.resize?.width && size.height === config.resize?.height
185
+ );
186
+
187
+ if (selectedSize) {
188
+ return formatSizeDisplay(selectedSize.mode === 'width' ? selectedSize.width : selectedSize.height, selectedSize.mode);
189
+ }
190
+
191
+ return `${config.resize.width} × ${config.resize.height}`;
192
+ };
193
+
194
+ const handleReset = () => {
195
+ switch (activeTool) {
196
+ case 'enhance':
197
+ updateConfig({
198
+ enhancements: {
199
+ autoEnhance: false,
200
+ fillLight: false,
201
+ autoWhiteBalance: false
202
+ }
203
+ });
204
+ break;
205
+ case 'adjust':
206
+ updateConfig({
207
+ brightness: 0,
208
+ contrast: 0,
209
+ sharpness: 0
210
+ });
211
+ break;
212
+ case 'resize':
213
+ updateConfig({
214
+ resize: {
215
+ width: 0,
216
+ height: 0,
217
+ maintainAspectRatio: true
218
+ }
219
+ });
220
+ break;
221
+ case 'crop':
222
+ updateConfig({
223
+ crop: undefined
224
+ });
225
+ break;
226
+ case 'rotate':
227
+ updateConfig({
228
+ rotation: 0
229
+ });
230
+ break;
231
+ case 'border':
232
+ updateConfig({
233
+ border: undefined
234
+ });
235
+ break;
236
+ }
237
+ };
238
+
239
+ if (!activeTool) return null;
240
+
241
+ return (
242
+ <div className="w-[280px] bg-zinc-900 border-l border-zinc-800 p-4 flex flex-col gap-4 overflow-y-auto shrink-0 z-10">
243
+ <div className="flex justify-between items-center mb-2">
244
+ <h2 className="text-zinc-100 font-semibold capitalize">{t(`sidebar.${activeTool}`)}</h2>
245
+ <button
246
+ onClick={handleReset}
247
+ className="text-xs text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 px-2 py-1 rounded transition-colors"
248
+ >
249
+ {t('common.reset')}
250
+ </button>
251
+ </div>
252
+
253
+ {activeTool === 'enhance' && (
254
+ <div className="flex flex-col gap-4">
255
+ <label className="flex items-center justify-between text-zinc-300 text-sm cursor-pointer p-2 hover:bg-zinc-800 rounded">
256
+ <span>{t('panels.autoEnhance')}</span>
257
+ <input
258
+ type="checkbox"
259
+ checked={config.enhancements.autoEnhance}
260
+ onChange={(e) => updateConfig({ enhancements: { ...config.enhancements, autoEnhance: e.target.checked } })}
261
+ className="accent-blue-500"
262
+ />
263
+ </label>
264
+ <label className="flex items-center justify-between text-zinc-300 text-sm cursor-pointer p-2 hover:bg-zinc-800 rounded">
265
+ <span>{t('panels.fillLight')}</span>
266
+ <input
267
+ type="checkbox"
268
+ checked={config.enhancements.fillLight}
269
+ onChange={(e) => updateConfig({ enhancements: { ...config.enhancements, fillLight: e.target.checked } })}
270
+ className="accent-blue-500"
271
+ />
272
+ </label>
273
+ <label className="flex items-center justify-between text-zinc-300 text-sm cursor-pointer p-2 hover:bg-zinc-800 rounded">
274
+ <span>{t('panels.autoWhiteBalance')}</span>
275
+ <input
276
+ type="checkbox"
277
+ checked={config.enhancements.autoWhiteBalance}
278
+ onChange={(e) => updateConfig({ enhancements: { ...config.enhancements, autoWhiteBalance: e.target.checked } })}
279
+ className="accent-blue-500"
280
+ />
281
+ </label>
282
+ </div>
283
+ )}
284
+
285
+ {activeTool === 'adjust' && (
286
+ <div className="flex flex-col gap-6">
287
+ <SliderControl
288
+ label={t('panels.brightness')}
289
+ value={config.brightness}
290
+ min={-100} max={100}
291
+ onChange={(val) => updateConfig({ brightness: val })}
292
+ />
293
+
294
+ <SliderControl
295
+ label={t('panels.contrast')}
296
+ value={config.contrast}
297
+ min={-100} max={100}
298
+ onChange={(val) => updateConfig({ contrast: val })}
299
+ />
300
+
301
+ <SliderControl
302
+ label={t('panels.sharpness')}
303
+ value={config.sharpness}
304
+ min={0} max={100}
305
+ onChange={(val) => updateConfig({ sharpness: val })}
306
+ />
307
+ </div>
308
+ )}
309
+
310
+ {activeTool === 'resize' && (
311
+ <div className="flex flex-col gap-4">
312
+ {/* 预设尺寸 */}
313
+ <div className="flex flex-col gap-2">
314
+ <label className="text-xs text-zinc-400">{t('common.presetSizes')}</label>
315
+ <div className="relative">
316
+ <select
317
+ className="w-full bg-zinc-800 text-zinc-100 p-2 rounded border border-zinc-700 focus:border-blue-500 outline-none text-sm appearance-none cursor-pointer"
318
+ value={`${config.resize?.width || ''}x${config.resize?.height || ''}`}
319
+ onChange={(e) => {
320
+ const selectedSize = presetSizes.find(
321
+ (size) => `${size.width}x${size.height}` === e.target.value
322
+ );
323
+ if (selectedSize) {
324
+ // 根据当前图片的比例重新计算尺寸
325
+ const calculated = calculateSize(
326
+ selectedSize.mode === 'width' ? selectedSize.width : selectedSize.height,
327
+ selectedSize.mode
328
+ );
329
+ updateConfig({ resize: { width: calculated.width, height: calculated.height, maintainAspectRatio: true } });
330
+ } else {
331
+ // 清空尺寸
332
+ updateConfig({ resize: { width: 0, height: 0, maintainAspectRatio: true } });
333
+ }
334
+ }}
335
+ >
336
+ <option value="">选择预设尺寸</option>
337
+ {presetSizes.map((size) => (
338
+ <option key={`${size.width}x${size.height}`} value={`${size.width}x${size.height}`}>
339
+ {formatSizeDisplay(size.mode === 'width' ? size.width : size.height, size.mode)}
340
+ </option>
341
+ ))}
342
+ </select>
343
+ <div className="absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none">
344
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-zinc-400"><path d="m6 9 6 6 6-6"/></svg>
345
+ </div>
346
+ </div>
347
+ <div className="flex flex-col gap-1 mt-1">
348
+ <button
349
+ className="w-full text-left text-xs text-zinc-400 hover:text-zinc-300 py-1 px-2 rounded hover:bg-zinc-800"
350
+ onClick={() => {
351
+ setAddSizeMode('width');
352
+ setAddSizeValue('');
353
+ setIsAddSizeDialogOpen(true);
354
+ }}
355
+ >
356
+ 添加常用尺寸
357
+ </button>
358
+ <button
359
+ className="w-full text-left text-xs text-zinc-400 hover:text-zinc-300 py-1 px-2 rounded hover:bg-zinc-800"
360
+ onClick={() => {
361
+ setDeleteSizeMode('width');
362
+ setSelectedSizeToDelete('');
363
+ setIsDeleteSizeDialogOpen(true);
364
+ }}
365
+ >
366
+ 删除常用尺寸
367
+ </button>
368
+ </div>
369
+ </div>
370
+
371
+ {/* 手工输入尺寸 */}
372
+ <div className="flex flex-col gap-4 p-4 bg-zinc-850 rounded-lg">
373
+ <div className="flex flex-col gap-3">
374
+ <div className="flex justify-between items-center">
375
+ <div className="flex flex-col">
376
+ <label className="text-xs text-zinc-400">{t('common.width')}</label>
377
+ <input
378
+ type="number"
379
+ className="bg-zinc-800 text-zinc-100 p-2 rounded border border-zinc-700 focus:border-blue-500 outline-none text-sm w-32"
380
+ value={config.resize?.width || ''}
381
+ onChange={(e) => {
382
+ const width = parseInt(e.target.value) || 0;
383
+ let height = config.resize?.height || 0;
384
+
385
+ if (config.resize?.maintainAspectRatio && width > 0 && originalWidth > 0 && originalHeight > 0) {
386
+ const aspectRatio = originalWidth / originalHeight;
387
+ height = Math.round(width / aspectRatio);
388
+ }
389
+
390
+ updateConfig({ resize: { ...config.resize, width, height, maintainAspectRatio: config.resize?.maintainAspectRatio ?? true } });
391
+ }}
392
+ />
393
+ </div>
394
+ <span className="text-zinc-400 self-end mb-6">像素</span>
395
+ </div>
396
+
397
+ <div className="flex justify-between items-center">
398
+ <div className="flex flex-col">
399
+ <label className="text-xs text-zinc-400">{t('common.height')}</label>
400
+ <input
401
+ type="number"
402
+ className="bg-zinc-800 text-zinc-100 p-2 rounded border border-zinc-700 focus:border-blue-500 outline-none text-sm w-32"
403
+ value={config.resize?.height || ''}
404
+ onChange={(e) => {
405
+ const height = parseInt(e.target.value) || 0;
406
+ let width = config.resize?.width || 0;
407
+
408
+ if (config.resize?.maintainAspectRatio && height > 0 && originalWidth > 0 && originalHeight > 0) {
409
+ const aspectRatio = originalWidth / originalHeight;
410
+ width = Math.round(height * aspectRatio);
411
+ }
412
+
413
+ updateConfig({ resize: { ...config.resize, width, height, maintainAspectRatio: config.resize?.maintainAspectRatio ?? true } });
414
+ }}
415
+ />
416
+ </div>
417
+ <span className="text-zinc-400 self-end mb-6">像素</span>
418
+ </div>
419
+
420
+ <div className="flex items-center gap-2">
421
+ <input
422
+ type="checkbox"
423
+ id="maintainAspectRatio"
424
+ checked={config.resize?.maintainAspectRatio ?? true}
425
+ onChange={(e) => updateConfig({ resize: { ...config.resize, maintainAspectRatio: e.target.checked } })}
426
+ className="accent-blue-500"
427
+ />
428
+ <label htmlFor="maintainAspectRatio" className="text-xs text-zinc-400 cursor-pointer">{t('common.lockAspectRatio')}</label>
429
+ </div>
430
+ </div>
431
+
432
+ <div className="flex gap-3 mt-2">
433
+ <button
434
+ onClick={() => {
435
+ // 确定按钮的逻辑
436
+ // 这里可以添加额外的验证或处理
437
+ }}
438
+ className="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-2 rounded text-sm font-medium"
439
+ >
440
+ {t('common.confirm')}
441
+ </button>
442
+ <button
443
+ onClick={() => {
444
+ // 取消按钮的逻辑
445
+ // 可以重置为之前的尺寸或保持不变
446
+ }}
447
+ className="flex-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 py-2 rounded text-sm"
448
+ >
449
+ {t('common.cancel')}
450
+ </button>
451
+ </div>
452
+ </div>
453
+ </div>
454
+ )}
455
+
456
+ {activeTool === 'border' && (
457
+ <div className="flex flex-col gap-6">
458
+ <SliderControl
459
+ label={t('panels.borderSize')}
460
+ value={config.border?.size ?? 0}
461
+ min={0} max={300}
462
+ onChange={(val) => updateConfig({
463
+ border: {
464
+ color: config.border?.color || '#ffffff',
465
+ applyHorizontal: config.border?.applyHorizontal ?? true,
466
+ applyVertical: config.border?.applyVertical ?? false,
467
+ ...config.border,
468
+ size: val
469
+ }
470
+ })}
471
+ unit="%"
472
+ />
473
+
474
+ <div className="flex gap-4">
475
+ <label className="flex items-center gap-2 text-xs text-zinc-300 cursor-pointer">
476
+ <input
477
+ type="checkbox"
478
+ checked={config.border?.applyHorizontal ?? true}
479
+ onChange={(e) => updateConfig({
480
+ border: {
481
+ color: config.border?.color || '#ffffff',
482
+ size: config.border?.size ?? 0,
483
+ applyVertical: config.border?.applyVertical ?? false,
484
+ ...config.border,
485
+ applyHorizontal: e.target.checked
486
+ }
487
+ })}
488
+ className="rounded bg-zinc-700 border-zinc-600"
489
+ />
490
+ {t('panels.borderHorizontal')}
491
+ </label>
492
+ <label className="flex items-center gap-2 text-xs text-zinc-300 cursor-pointer">
493
+ <input
494
+ type="checkbox"
495
+ checked={config.border?.applyVertical ?? false}
496
+ onChange={(e) => updateConfig({
497
+ border: {
498
+ color: config.border?.color || '#ffffff',
499
+ size: config.border?.size ?? 0,
500
+ applyHorizontal: config.border?.applyHorizontal ?? true,
501
+ ...config.border,
502
+ applyVertical: e.target.checked
503
+ }
504
+ })}
505
+ className="rounded bg-zinc-700 border-zinc-600"
506
+ />
507
+ {t('panels.borderVertical')}
508
+ </label>
509
+ </div>
510
+
511
+ <div className="space-y-2">
512
+ <span className="text-xs text-zinc-400 block">{t('panels.borderColor')}</span>
513
+ <div className="flex items-center gap-2">
514
+ <input
515
+ type="color"
516
+ value={config.border?.color ?? '#ffffff'}
517
+ onChange={(e) => updateConfig({
518
+ border: {
519
+ size: config.border?.size ?? 0,
520
+ applyHorizontal: config.border?.applyHorizontal ?? true,
521
+ applyVertical: config.border?.applyVertical ?? false,
522
+ ...config.border,
523
+ color: e.target.value
524
+ }
525
+ })}
526
+ className="w-8 h-8 rounded cursor-pointer bg-transparent border-0 p-0"
527
+ />
528
+ <input
529
+ type="text"
530
+ value={config.border?.color ?? '#ffffff'}
531
+ onChange={(e) => updateConfig({
532
+ border: {
533
+ size: config.border?.size ?? 0,
534
+ applyHorizontal: config.border?.applyHorizontal ?? true,
535
+ applyVertical: config.border?.applyVertical ?? false,
536
+ ...config.border,
537
+ color: e.target.value
538
+ }
539
+ })}
540
+ className="flex-1 bg-zinc-800 text-zinc-300 px-2 py-1.5 rounded text-xs border border-zinc-700"
541
+ />
542
+ </div>
543
+ </div>
544
+ </div>
545
+ )}
546
+
547
+ {activeTool === 'crop' && (
548
+ <div className="flex flex-col gap-4">
549
+ <div className="flex justify-between items-center">
550
+ <div className="text-xs text-zinc-400">{t('panels.aspectRatio')}</div>
551
+ <button
552
+ onClick={() => setIsAspectRatioManagerOpen(true)}
553
+ className="text-zinc-400 hover:text-zinc-200 p-1 rounded hover:bg-zinc-800 transition-colors"
554
+ title={t('common.manageRatios')}
555
+ >
556
+ <Settings size={14} />
557
+ </button>
558
+ </div>
559
+ <div className="grid grid-cols-3 gap-2">
560
+ {aspectRatios.map(ratio => (
561
+ <button
562
+ key={ratio.id}
563
+ className={`py-2 rounded text-xs border ${
564
+ config.crop?.aspectRatio === ratio.value
565
+ ? 'bg-blue-600 border-blue-500 text-white'
566
+ : 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:bg-zinc-700'
567
+ }`}
568
+ onClick={() => updateConfig({ crop: { ...config.crop, aspectRatio: ratio.value, x: 0, y: 0, width: 100, height: 100 } })}
569
+ >
570
+ {ratio.value === 'Free' ? t('panels.free') : ratio.label}
571
+ </button>
572
+ ))}
573
+ </div>
574
+
575
+ <button
576
+ className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded text-sm font-medium mt-4"
577
+ onClick={() => {
578
+ if (cropRect.width > 0 && cropRect.height > 0) {
579
+ applyCrop();
580
+ } else {
581
+ alert('请先选择裁剪区域');
582
+ }
583
+ }}
584
+ >
585
+ 应用裁剪
586
+ </button>
587
+
588
+ <button
589
+ className="w-full bg-zinc-800 hover:bg-zinc-700 text-zinc-300 py-2 rounded text-sm"
590
+ onClick={() => {
591
+ updateConfig({ crop: undefined });
592
+ }}
593
+ >
594
+ 取消裁剪
595
+ </button>
596
+
597
+ <p className="text-[10px] text-zinc-500 mt-2">
598
+ * {t('panels.cropWarning')}
599
+ </p>
600
+
601
+ <AspectRatioDialog
602
+ isOpen={isAspectRatioManagerOpen}
603
+ onClose={() => setIsAspectRatioManagerOpen(false)}
604
+ />
605
+ </div>
606
+ )}
607
+
608
+ {activeTool === 'rotate' && (
609
+ <div className="flex flex-col gap-6">
610
+ <div className="flex justify-between gap-2">
611
+ <button
612
+ onClick={() => {
613
+ // Normalize to -180 ~ 180
614
+ let newRot = (config.rotation - 90) % 360;
615
+ if (newRot > 180) newRot -= 360;
616
+ if (newRot <= -180) newRot += 360;
617
+ updateConfig({ rotation: newRot });
618
+ }}
619
+ className="flex-1 bg-zinc-800 text-zinc-300 py-2 rounded text-sm hover:bg-zinc-700"
620
+ >
621
+ {t('panels.rotateLeft')}
622
+ </button>
623
+ <button
624
+ onClick={() => {
625
+ // Normalize to -180 ~ 180
626
+ let newRot = (config.rotation + 90) % 360;
627
+ if (newRot > 180) newRot -= 360;
628
+ if (newRot <= -180) newRot += 360;
629
+ updateConfig({ rotation: newRot });
630
+ }}
631
+ className="flex-1 bg-zinc-800 text-zinc-300 py-2 rounded text-sm hover:bg-zinc-700"
632
+ >
633
+ {t('panels.rotateRight')}
634
+ </button>
635
+ </div>
636
+
637
+ <button
638
+ className={`flex items-center justify-center gap-2 py-2 rounded text-sm transition-colors border ${
639
+ isStraightenToolActive
640
+ ? 'text-blue-500 bg-zinc-800 border-blue-500/50'
641
+ : 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700 border-transparent'
642
+ }`}
643
+ onClick={() => setStraightenToolActive(!isStraightenToolActive)}
644
+ >
645
+ <Ruler size={16} />
646
+ {t('panels.straighten')}
647
+ </button>
648
+
649
+ {isStraightenToolActive && (
650
+ <p className="text-[10px] text-zinc-500">
651
+ * {t('panels.straightenTip')}
652
+ </p>
653
+ )}
654
+
655
+ <SliderControl
656
+ label={t('panels.rotationAngle')}
657
+ value={config.rotation}
658
+ min={-180} max={180} step={0.5}
659
+ onChange={(val) => updateConfig({ rotation: val })}
660
+ unit="°"
661
+ />
662
+ </div>
663
+ )}
664
+
665
+ {/* 添加常用尺寸对话框 */}
666
+ {isAddSizeDialogOpen && (
667
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
668
+ <div className="bg-zinc-900 border border-zinc-800 rounded-lg p-5 w-80">
669
+ <div className="flex justify-between items-center mb-4">
670
+ <h3 className="text-zinc-100 font-medium">添加常用尺寸</h3>
671
+ <button
672
+ onClick={() => setIsAddSizeDialogOpen(false)}
673
+ className="text-zinc-400 hover:text-zinc-200"
674
+ >
675
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
676
+ </button>
677
+ </div>
678
+
679
+ <div className="space-y-4">
680
+ <div className="space-y-2">
681
+ <div className="flex items-center gap-4">
682
+ <label className="flex items-center gap-2 cursor-pointer">
683
+ <input
684
+ type="radio"
685
+ name="addSizeMode"
686
+ value="width"
687
+ checked={addSizeMode === 'width'}
688
+ onChange={() => setAddSizeMode('width')}
689
+ className="accent-blue-500"
690
+ />
691
+ <span className="text-zinc-300 text-sm">按宽度</span>
692
+ </label>
693
+ <label className="flex items-center gap-2 cursor-pointer">
694
+ <input
695
+ type="radio"
696
+ name="addSizeMode"
697
+ value="height"
698
+ checked={addSizeMode === 'height'}
699
+ onChange={() => setAddSizeMode('height')}
700
+ className="accent-blue-500"
701
+ />
702
+ <span className="text-zinc-300 text-sm">按高度</span>
703
+ </label>
704
+ </div>
705
+ </div>
706
+
707
+ {addSizeMode === 'width' ? (
708
+ <div className="flex items-center gap-2">
709
+ <input
710
+ type="number"
711
+ placeholder="宽度"
712
+ className="flex-1 bg-zinc-800 text-zinc-100 p-2 rounded border border-zinc-700 focus:border-blue-500 outline-none text-sm"
713
+ value={addSizeValue}
714
+ onChange={(e) => setAddSizeValue(e.target.value)}
715
+ />
716
+ <span className="text-zinc-400">×</span>
717
+ <span className="text-zinc-400">对应高度</span>
718
+ </div>
719
+ ) : (
720
+ <div className="flex items-center gap-2">
721
+ <span className="text-zinc-400">对应宽度</span>
722
+ <span className="text-zinc-400">×</span>
723
+ <input
724
+ type="number"
725
+ placeholder="高度"
726
+ className="flex-1 bg-zinc-800 text-zinc-100 p-2 rounded border border-zinc-700 focus:border-blue-500 outline-none text-sm"
727
+ value={addSizeValue}
728
+ onChange={(e) => setAddSizeValue(e.target.value)}
729
+ />
730
+ </div>
731
+ )}
732
+
733
+ <div className="flex gap-3 mt-6">
734
+ <button
735
+ onClick={() => {
736
+ // 添加尺寸逻辑
737
+ const value = parseInt(addSizeValue);
738
+ if (!isNaN(value) && value > 0) {
739
+ // 根据原始图片比例计算尺寸
740
+ const calculated = calculateSize(value, addSizeMode);
741
+ const newSize = {
742
+ width: calculated.width,
743
+ height: calculated.height,
744
+ mode: addSizeMode
745
+ };
746
+
747
+ // 检查尺寸是否已存在
748
+ const isDuplicate = presetSizes.some(
749
+ (size) => size.width === newSize.width && size.height === newSize.height
750
+ );
751
+
752
+ if (!isDuplicate) {
753
+ // 添加新尺寸并按宽度从小到大排序
754
+ const updatedSizes = [...presetSizes, newSize].sort((a, b) => a.width - b.width);
755
+ setPresetSizes(updatedSizes);
756
+ }
757
+
758
+ // 重置状态并关闭对话框
759
+ setAddSizeValue('');
760
+ setIsAddSizeDialogOpen(false);
761
+ }
762
+ }}
763
+ className="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-2 rounded text-sm font-medium"
764
+ >
765
+ 确定
766
+ </button>
767
+ <button
768
+ onClick={() => {
769
+ // 取消逻辑
770
+ setAddSizeValue('');
771
+ setIsAddSizeDialogOpen(false);
772
+ }}
773
+ className="flex-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 py-2 rounded text-sm"
774
+ >
775
+ 取消
776
+ </button>
777
+ </div>
778
+ </div>
779
+ </div>
780
+ </div>
781
+ )}
782
+
783
+ {/* 删除常用尺寸对话框 */}
784
+ {isDeleteSizeDialogOpen && (
785
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
786
+ <div className="bg-zinc-900 border border-zinc-800 rounded-lg p-5 w-80">
787
+ <div className="flex justify-between items-center mb-4">
788
+ <h3 className="text-zinc-100 font-medium">删除常用尺寸</h3>
789
+ <button
790
+ onClick={() => setIsDeleteSizeDialogOpen(false)}
791
+ className="text-zinc-400 hover:text-zinc-200"
792
+ >
793
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
794
+ </button>
795
+ </div>
796
+
797
+ <div className="space-y-4">
798
+ <div className="space-y-2">
799
+ <div className="flex items-center gap-4">
800
+ <label className="flex items-center gap-2 cursor-pointer">
801
+ <input
802
+ type="radio"
803
+ name="deleteSizeMode"
804
+ value="width"
805
+ checked={deleteSizeMode === 'width'}
806
+ onChange={() => {
807
+ setDeleteSizeMode('width');
808
+ setSelectedSizeToDelete('');
809
+ }}
810
+ className="accent-blue-500"
811
+ />
812
+ <span className="text-zinc-300 text-sm">按宽度</span>
813
+ </label>
814
+ <label className="flex items-center gap-2 cursor-pointer">
815
+ <input
816
+ type="radio"
817
+ name="deleteSizeMode"
818
+ value="height"
819
+ checked={deleteSizeMode === 'height'}
820
+ onChange={() => {
821
+ setDeleteSizeMode('height');
822
+ setSelectedSizeToDelete('');
823
+ }}
824
+ className="accent-blue-500"
825
+ />
826
+ <span className="text-zinc-300 text-sm">按高度</span>
827
+ </label>
828
+ </div>
829
+ </div>
830
+
831
+ <div>
832
+ <select
833
+ className="w-full bg-zinc-800 text-zinc-100 p-2 rounded border border-zinc-700 focus:border-blue-500 outline-none text-sm"
834
+ value={selectedSizeToDelete}
835
+ onChange={(e) => setSelectedSizeToDelete(e.target.value)}
836
+ >
837
+ <option value="">选择要删除的尺寸</option>
838
+ {presetSizes
839
+ .filter((size) => size.mode === deleteSizeMode)
840
+ .map((size) => (
841
+ <option key={`${size.width}x${size.height}`} value={`${size.width}x${size.height}`}>
842
+ {formatSizeDisplay(size.mode === 'width' ? size.width : size.height, size.mode)}
843
+ </option>
844
+ ))}
845
+ </select>
846
+ </div>
847
+
848
+ <div className="flex gap-3 mt-6">
849
+ <button
850
+ onClick={() => {
851
+ // 删除尺寸逻辑
852
+ if (selectedSizeToDelete) {
853
+ const [width, height] = selectedSizeToDelete.split('x').map(Number);
854
+ setPresetSizes(
855
+ presetSizes.filter(
856
+ (size) => size.width !== width || size.height !== height
857
+ )
858
+ );
859
+
860
+ // 重置状态并关闭对话框
861
+ setSelectedSizeToDelete('');
862
+ setIsDeleteSizeDialogOpen(false);
863
+ }
864
+ }}
865
+ className="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-2 rounded text-sm font-medium"
866
+ >
867
+ 确定
868
+ </button>
869
+ <button
870
+ onClick={() => {
871
+ // 取消逻辑
872
+ setSelectedSizeToDelete('');
873
+ setIsDeleteSizeDialogOpen(false);
874
+ }}
875
+ className="flex-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 py-2 rounded text-sm"
876
+ >
877
+ 取消
878
+ </button>
879
+ </div>
880
+ </div>
881
+ </div>
882
+ </div>
883
+ )}
884
+ </div>
885
+ );
886
+ };