Ethscriptions commited on
Commit
53b24b2
·
verified ·
1 Parent(s): 01ec901

Upload 5 files

Browse files
Files changed (5) hide show
  1. icon.svg +6 -0
  2. index.html +595 -1188
  3. manifest.json +26 -0
  4. sw.js +44 -37
  5. worker.js +161 -0
icon.svg ADDED
index.html CHANGED
@@ -1,1223 +1,630 @@
1
  <!DOCTYPE html>
2
  <html lang="zh-CN">
3
  <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <meta name="theme-color" content="#4c7dff" />
7
- <meta name="apple-mobile-web-app-capable" content="yes" />
8
- <meta name="apple-mobile-web-app-status-bar-style" content="default" />
9
- <meta name="apple-mobile-web-app-title" content="分摊器" />
10
- <link rel="manifest" href="./manifest.webmanifest" />
11
- <link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
12
- <title>外卖订单收益分摊器</title>
13
- <style>
14
- :root {
15
- --bg-a: #f7fbff;
16
- --bg-b: #fff4fb;
17
- --panel: #ffffff;
18
- --panel-soft: #f4f8ff;
19
- --line: #d7e3ff;
20
- --line-strong: #b8ccff;
21
- --text-main: #253054;
22
- --text-sub: #6b7ba8;
23
- --accent: #4c7dff;
24
- --accent-2: #28d7a7;
25
- --accent-3: #ff7ec7;
26
- --ok: #1fa97f;
27
- --radius: 14px;
28
- --shadow: 0 6px 14px rgba(76, 125, 255, 0.10);
29
- }
30
-
31
- * { box-sizing: border-box; margin: 0; padding: 0; }
32
-
33
- body {
34
- font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
35
- background: linear-gradient(140deg, var(--bg-a), var(--bg-b));
36
- color: var(--text-main);
37
- min-height: 100vh;
38
- overflow: hidden;
39
- user-select: none;
40
- }
41
-
42
- input, textarea { user-select: text; }
43
-
44
- .app {
45
- height: 100vh;
46
- display: flex;
47
- flex-direction: column;
48
- gap: 0;
49
- padding: 10px;
50
- }
51
-
52
- .btn {
53
- border: 1px solid var(--line-strong);
54
- background: #f2f6ff;
55
- color: #2d3f72;
56
- border-radius: 12px;
57
- padding: 10px 14px;
58
- font-size: 16px;
59
- font-weight: 700;
60
- cursor: pointer;
61
- transition: .14s;
62
- }
63
-
64
- .btn:hover { box-shadow: 0 3px 8px rgba(76, 125, 255, 0.12); }
65
- .btn-primary { border: none; background: #4c7dff; color: #fff; }
66
-
67
- .main {
68
- flex: 1;
69
- min-height: 0;
70
- display: grid;
71
- grid-template-columns: 1.15fr 1fr 1.25fr;
72
- gap: 10px;
73
- height: 100%;
74
- }
75
-
76
- .panel {
77
- min-height: 0;
78
- overflow: hidden;
79
- border: 1px solid var(--line);
80
- border-radius: var(--radius);
81
- background: var(--panel);
82
- box-shadow: var(--shadow);
83
- display: flex;
84
- flex-direction: column;
85
- }
86
-
87
- .panel-title {
88
- padding: 12px 14px;
89
- background: linear-gradient(90deg, #edf3ff, #fff2fb);
90
- border-bottom: 1px solid var(--line);
91
- display: flex;
92
- justify-content: space-between;
93
- align-items: center;
94
- gap: 10px;
95
- font-size: 20px;
96
- font-weight: 800;
97
- color: #3450a7;
98
- }
99
-
100
- .title-actions {
101
- display: flex;
102
- align-items: center;
103
- gap: 8px;
104
- flex-wrap: wrap;
105
- justify-content: flex-end;
106
- }
107
-
108
- .panel-title small { font-size: 15px; font-weight: 600; color: var(--text-sub); }
109
-
110
- .controls {
111
- padding: 12px;
112
- border-bottom: 1px solid var(--line);
113
- display: grid;
114
- grid-template-columns: 1fr 1fr;
115
- gap: 10px;
116
- background: var(--panel-soft);
117
- }
118
-
119
- .search-row {
120
- grid-column: span 2;
121
- display: grid;
122
- grid-template-columns: 1fr auto;
123
- gap: 10px;
124
- }
125
-
126
- select, .text-input {
127
- width: 100%;
128
- border: 1px solid var(--line-strong);
129
- border-radius: 10px;
130
- background: #fff;
131
- color: var(--text-main);
132
- padding: 10px;
133
- font-size: 16px;
134
- outline: none;
135
- }
136
-
137
- .product-grid {
138
- flex: 1;
139
- min-height: 0;
140
- overflow: auto;
141
- padding: 12px;
142
- display: grid;
143
- gap: 10px;
144
- align-content: start;
145
- grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
146
- }
147
-
148
- .card {
149
- border: 1px solid #d8e3ff;
150
- border-radius: 12px;
151
- padding: 10px;
152
- min-height: 102px;
153
- cursor: pointer;
154
- display: flex;
155
- flex-direction: column;
156
- justify-content: space-between;
157
- background: linear-gradient(140deg, #f7faff, #fff5fb);
158
- transition: .14s;
159
- }
160
-
161
- .card:hover { transform: translateY(-1px); border-color: #9db9ff; box-shadow: 0 8px 16px rgba(76, 125, 255, 0.16); }
162
- .card .name { font-size: 16px; line-height: 1.35; color: #2f3f72; word-break: break-all; }
163
- .card .price { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; color: #1fbe8d; font-size: 24px; }
164
-
165
- .order-wrap {
166
- flex: 1;
167
- min-height: 0;
168
- overflow: auto;
169
- padding: 12px;
170
- display: flex;
171
- flex-direction: column;
172
- gap: 10px;
173
- }
174
-
175
- .order-item {
176
- border: 1px solid #d5e1ff;
177
- border-radius: 11px;
178
- padding: 10px;
179
- display: grid;
180
- grid-template-columns: 1fr auto;
181
- gap: 10px;
182
- align-items: center;
183
- background: #fbfdff;
184
- }
185
-
186
- .order-name { font-size: 17px; line-height: 1.35; color: #2c3b67; font-weight: 700; }
187
-
188
- .order-prices {
189
- margin-top: 6px;
190
- display: grid;
191
- grid-template-columns: 1fr;
192
- gap: 8px;
193
- font-size: 14px;
194
- color: var(--text-sub);
195
- }
196
-
197
- .order-prices .mono { font-size: 17px; color: #3656b4; font-weight: 800; }
198
-
199
- .order-origin-input {
200
- width: 100%;
201
- border: 1px solid #bfd2ff;
202
- border-radius: 9px;
203
- background: linear-gradient(135deg, #ffffff, #f6f9ff);
204
- color: #2f5de0;
205
- padding: 8px 10px;
206
- font-size: 18px;
207
- font-weight: 800;
208
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
209
- cursor: text;
210
- outline: none;
211
- }
212
-
213
- .order-origin-input:focus {
214
- border-color: #7ea3ff;
215
- box-shadow: 0 0 0 3px rgba(76, 125, 255, 0.14);
216
- }
217
-
218
- .qty-box { display: flex; align-items: center; gap: 8px; }
219
-
220
- .mini-btn {
221
- width: 34px;
222
- height: 34px;
223
- border-radius: 8px;
224
- border: 1px solid #bad0ff;
225
- background: #f1f6ff;
226
- color: #3152b2;
227
- cursor: pointer;
228
- font-weight: 700;
229
- font-size: 20px;
230
- }
231
-
232
- .qty-text {
233
- min-width: 30px;
234
- text-align: center;
235
- color: #2f437d;
236
- font-size: 18px;
237
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
238
- }
239
-
240
- .sum-line {
241
- margin-top: auto;
242
- border-top: 1px dashed #cedcff;
243
- padding-top: 10px;
244
- display: flex;
245
- justify-content: space-between;
246
- align-items: center;
247
- gap: 8px;
248
- color: #4761ad;
249
- font-size: 24px;
250
- font-weight: 800;
251
- position: sticky;
252
- bottom: 0;
253
- z-index: 1;
254
- background: linear-gradient(180deg, rgba(255,255,255,0.95), #fff);
255
- padding-bottom: 6px;
256
- }
257
-
258
- .sum-line strong {
259
- color: var(--ok);
260
- font-size: 34px;
261
- line-height: 1;
262
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
263
- }
264
-
265
- .calc-wrap {
266
- padding: 12px;
267
- border-bottom: 1px solid var(--line);
268
- background: var(--panel-soft);
269
- }
270
-
271
- .calc-label { color: var(--text-sub); font-size: 15px; margin-bottom: 8px; }
272
- .calc-row { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
273
-
274
- .amount-input {
275
- border: 1px solid #b9ccff;
276
- border-radius: 10px;
277
- background: #fff;
278
- color: #2f5de0;
279
- font-size: 34px;
280
- text-align: right;
281
- padding: 10px;
282
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
283
- outline: none;
284
- }
285
-
286
- .amount-input::placeholder { color: #9fb1df; }
287
-
288
- .result-wrap {
289
- flex: 1;
290
- min-height: 0;
291
- overflow: auto;
292
- padding: 12px;
293
- display: flex;
294
- flex-direction: column;
295
- gap: 10px;
296
- }
297
-
298
- .result-head {
299
- border: 1px solid #d4e1ff;
300
- border-radius: 12px;
301
- padding: 10px;
302
- background: #fff;
303
- font-size: 16px;
304
- color: #536aa2;
305
- line-height: 1.6;
306
- }
307
-
308
- .result-item,
309
- .check-box {
310
- border: 1px solid #d4e1ff;
311
- border-radius: 12px;
312
- padding: 12px;
313
- background: #fbfdff;
314
- }
315
-
316
- .result-top {
317
- font-size: 19px;
318
- font-weight: 800;
319
- color: #3455b4;
320
- margin-bottom: 10px;
321
- }
322
-
323
- .price-pair {
324
- display: grid;
325
- grid-template-columns: 1fr 1fr;
326
- gap: 10px;
327
- }
328
-
329
- .price-box {
330
- border: 1px solid #dce6ff;
331
- border-radius: 10px;
332
- background: #fff;
333
- padding: 10px;
334
- }
335
-
336
- .price-box .label {
337
- font-size: 15px;
338
- color: #5e72a6;
339
- margin-bottom: 6px;
340
- display: block;
341
- font-weight: 700;
342
- }
343
-
344
- .price-box .value {
345
- font-size: 28px;
346
- color: #2248b7;
347
- font-weight: 800;
348
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
349
- line-height: 1.1;
350
- }
351
-
352
- .price-box .value.actual { color: var(--ok); }
353
-
354
- .check-box { font-size: 16px; color: #536aa2; line-height: 1.75; }
355
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
356
-
357
- .empty {
358
- border: 1px dashed #cadbff;
359
- border-radius: 11px;
360
- text-align: center;
361
- padding: 22px 12px;
362
- color: #8a9bc8;
363
- font-size: 17px;
364
- background: #fbfdff;
365
- line-height: 1.6;
366
- }
367
-
368
- .modal {
369
- position: fixed;
370
- inset: 0;
371
- display: none;
372
- justify-content: center;
373
- align-items: center;
374
- z-index: 20;
375
- padding: 16px;
376
- background: rgba(94, 121, 190, 0.25);
377
- backdrop-filter: blur(3px);
378
- }
379
-
380
- .modal.active { display: flex; }
381
-
382
- .modal-box {
383
- width: 700px;
384
- max-width: 100%;
385
- border: 1px solid #d7e2ff;
386
- border-radius: 12px;
387
- overflow: hidden;
388
- background: #fff;
389
- box-shadow: var(--shadow);
390
- }
391
-
392
- .modal-head {
393
- background: linear-gradient(90deg, #edf3ff, #fff2fb);
394
- border-bottom: 1px solid #d7e2ff;
395
- padding: 12px 14px;
396
- font-size: 20px;
397
- font-weight: 800;
398
- color: #3757b8;
399
- }
400
-
401
- .modal-body { padding: 14px; }
402
- .modal-desc { color: #7587b6; font-size: 15px; line-height: 1.6; margin-bottom: 10px; }
403
-
404
- textarea {
405
- width: 100%;
406
- min-height: 300px;
407
- border: 1px solid #c9d8ff;
408
- border-radius: 10px;
409
- background: #fff;
410
- color: #33457a;
411
- padding: 12px;
412
- font-size: 16px;
413
- line-height: 1.6;
414
- resize: vertical;
415
- outline: none;
416
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
417
- }
418
-
419
- .modal-foot {
420
- padding: 12px 14px;
421
- border-top: 1px solid #d7e2ff;
422
- display: flex;
423
- justify-content: flex-end;
424
- gap: 10px;
425
- background: #f9fbff;
426
- }
427
-
428
- .numpad {
429
- position: fixed;
430
- right: 16px;
431
- bottom: 16px;
432
- width: 360px;
433
- border: 1px solid #c5d7ff;
434
- border-radius: 14px;
435
- background: #fff;
436
- box-shadow: var(--shadow);
437
- padding: 12px;
438
- z-index: 15;
439
- display: none;
440
- }
441
-
442
- .numpad.active { display: block; }
443
-
444
- .numpad-grid {
445
- display: grid;
446
- grid-template-columns: repeat(3, 1fr);
447
- gap: 10px;
448
- }
449
-
450
- .key {
451
- border: 1px solid #c5d6ff;
452
- border-radius: 10px;
453
- background: #f4f8ff;
454
- color: #3954a8;
455
- font-weight: 700;
456
- font-size: 24px;
457
- padding: 12px 0;
458
- cursor: pointer;
459
- }
460
-
461
- .key:active { transform: scale(0.98); }
462
-
463
- .key-fn { font-size: 16px; background: #eef3ff; color: #5a71b0; }
464
- .key-ac { color: #b84a6a; background: #fff3f7; border-color: #ffd2e1; }
465
- .key-enter { border: none; color: #fff; background: linear-gradient(135deg, var(--accent), var(--accent-2)); }
466
-
467
- ::-webkit-scrollbar { width: 6px; height: 6px; }
468
- ::-webkit-scrollbar-thumb { background: #cfddff; border-radius: 6px; }
469
-
470
- @media (max-width: 1260px) {
471
- .main { grid-template-columns: 1fr; }
472
- body { overflow: auto; }
473
- .app { height: auto; min-height: 100vh; }
474
- .numpad { width: calc(100% - 24px); right: 12px; }
475
- .price-pair { grid-template-columns: 1fr; }
476
- .order-prices { grid-template-columns: 1fr; }
477
- }
478
- </style>
479
  </head>
480
- <body>
481
- <div class="app">
482
- <main class="main">
483
- <section class="panel">
484
- <div class="panel-title">
485
- <span>商品陈列</span>
486
- <div class="title-actions">
487
- <button class="btn btn-primary" id="openImportBtn">📥 导入商品</button>
488
- <small id="productCountText">0 / 0 个商品</small>
489
- </div>
490
- </div>
491
- <div class="controls">
492
- <select id="sortSelect">
493
- <option value="default">默认排序</option>
494
- <option value="priceAsc">价格从低到高</option>
495
- <option value="priceDesc">价格从高到低</option>
496
- <option value="name">按名称排序</option>
497
- </select>
498
- <select id="priceFilterSelect">
499
- <option value="all">全部价格分类</option>
500
- </select>
501
- <div class="search-row">
502
- <input id="searchInput" class="text-input" placeholder="搜索:商品名称" />
503
- <button class="btn" id="resetFilterBtn">重置筛选</button>
504
- </div>
505
  </div>
506
- <div class="product-grid" id="productGrid"></div>
507
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
 
509
- <section class="panel">
510
- <div class="panel-title">
511
- <span>当前订单</span>
512
- <button class="btn" id="clearOrderBtn">清空订单</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  </div>
514
- <div class="order-wrap" id="orderWrap"></div>
515
- </section>
516
 
517
- <section class="panel">
518
- <div class="panel-title">
519
- <span>结算结果</span>
520
- <button class="btn" id="clearResultBtn">清空结果</button>
521
- </div>
522
- <div class="calc-wrap">
523
- <div class="calc-label">输入外卖平台订单优惠后总金额:</div>
524
- <div class="calc-row">
525
- <input id="payInput" class="amount-input" type="text" inputmode="decimal" placeholder="0.00" />
526
- <button class="btn btn-primary" id="calcBtn">开始计算</button>
527
- </div>
 
 
 
 
528
  </div>
529
- <div class="result-wrap" id="resultWrap"></div>
530
- </section>
531
  </main>
532
- </div>
533
-
534
- <div class="modal" id="importModal">
535
- <div class="modal-box">
536
- <div class="modal-head">导入商品(支持 Excel 复制粘贴)</div>
537
- <div class="modal-body">
538
- <p class="modal-desc">
539
- 一行一个商品,最后一列为价格,可空格或 Tab 分隔。<br />
540
- 示例:<code>爆C柠香绿 9.9</code>
541
- </p>
542
- <textarea id="importText"></textarea>
543
- </div>
544
- <div class="modal-foot">
545
- <button class="btn" id="closeImportBtn">取消</button>
546
- <button class="btn btn-primary" id="saveImportBtn">保存并应用</button>
547
- </div>
548
- </div>
549
- </div>
550
-
551
- <div class="numpad" id="numpad">
552
- <div class="numpad-grid">
553
- <button class="key" data-key="7">7</button>
554
- <button class="key" data-key="8">8</button>
555
- <button class="key" data-key="9">9</button>
556
- <button class="key" data-key="4">4</button>
557
- <button class="key" data-key="5">5</button>
558
- <button class="key" data-key="6">6</button>
559
- <button class="key" data-key="1">1</button>
560
- <button class="key" data-key="2">2</button>
561
- <button class="key" data-key="3">3</button>
562
- <button class="key key-ac" data-key="clear">清空</button>
563
- <button class="key" data-key="0">0</button>
564
- <button class="key" data-key=".">.</button>
565
- <button class="key key-fn" data-key="backspace">退格</button>
566
- <button class="key key-fn" data-key="hide">收起</button>
567
- <button class="key key-enter" data-key="calc" id="numpadActionBtn">计算</button>
568
- </div>
569
- </div>
570
-
571
- <script>
572
- const STORAGE_KEYS = { products: 'shop_products_v5' };
573
-
574
- const state = {
575
- products: [],
576
- order: [],
577
- result: null,
578
- priceRanges: []
579
- };
580
-
581
- let activeNumpadInput = null;
582
-
583
- const els = {
584
- productGrid: document.getElementById('productGrid'),
585
- productCountText: document.getElementById('productCountText'),
586
- orderWrap: document.getElementById('orderWrap'),
587
- resultWrap: document.getElementById('resultWrap'),
588
- sortSelect: document.getElementById('sortSelect'),
589
- priceFilterSelect: document.getElementById('priceFilterSelect'),
590
- searchInput: document.getElementById('searchInput'),
591
- payInput: document.getElementById('payInput'),
592
- importModal: document.getElementById('importModal'),
593
- importText: document.getElementById('importText'),
594
- numpad: document.getElementById('numpad'),
595
- numpadActionBtn: document.getElementById('numpadActionBtn')
596
- };
597
-
598
- function formatMoney(v) { return Number(v).toFixed(2); }
599
-
600
- function escapeHtml(input) {
601
- return String(input)
602
- .replace(/&/g, '&amp;')
603
- .replace(/</g, '&lt;')
604
- .replace(/>/g, '&gt;')
605
- .replace(/"/g, '&quot;')
606
- .replace(/'/g, '&#39;');
607
- }
608
 
609
- function normalizeInputValue(val) {
610
- const normalized = String(val).replace(/[。.。]/g, '.');
611
- const only = normalized.replace(/[^\d.]/g, '');
612
- if (!only) return '';
613
- const parts = only.split('.');
614
- const intPart = parts[0] || '0';
615
- if (parts.length === 1) return intPart;
616
- return `${intPart}.${parts.slice(1).join('').slice(0, 2)}`;
617
- }
618
-
619
- function normalizeProduct(item) {
620
- const name = String(item.name || '').trim();
621
- const price = Number(item.price);
622
- return { name, price };
623
- }
624
-
625
- function loadProducts() {
626
- try {
627
- const raw = localStorage.getItem(STORAGE_KEYS.products);
628
- if (!raw) {
629
- state.products = [];
630
- return;
631
  }
632
- const parsed = JSON.parse(raw);
633
- const list = Array.isArray(parsed)
634
- ? parsed
635
- .map(normalizeProduct)
636
- .filter((it) => it.name && isFinite(it.price) && it.price > 0)
637
- : [];
638
- state.products = list;
639
- } catch {
640
- state.products = [];
641
- }
642
- }
643
-
644
- function saveProducts() {
645
- localStorage.setItem(STORAGE_KEYS.products, JSON.stringify(state.products));
646
- }
647
-
648
- function parseProductsText(text) {
649
- const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
650
- const list = [];
651
-
652
- for (const line of lines) {
653
- const m = line.match(/^(.*?)[\s\t]+(-?\d+(?:\.\d+)?)$/);
654
- if (!m) continue;
655
- const name = m[1].trim();
656
- const price = Number(m[2]);
657
- if (!name || !isFinite(price) || price <= 0) continue;
658
- list.push({ name, price });
659
- }
660
-
661
- return list;
662
- }
663
-
664
- function getPriceRanges(products) {
665
- if (!products.length) return [];
666
- const prices = products.map((p) => p.price).filter((n) => isFinite(n)).sort((a, b) => a - b);
667
- if (!prices.length) return [];
668
-
669
- const min = prices[0];
670
- const max = prices[prices.length - 1];
671
- if (min === max) {
672
- return [{ id: 'r0', min, max, label: `¥${formatMoney(min)}(同一价格)` }];
673
- }
674
-
675
- const bucketCount = 4;
676
- const span = (max - min) / bucketCount;
677
- const ranges = [];
678
 
679
- for (let i = 0; i < bucketCount; i += 1) {
680
- const start = min + span * i;
681
- const end = i === bucketCount - 1 ? max : min + span * (i + 1);
682
- const label = i === bucketCount - 1
683
- ? `¥${formatMoney(start)} ~ ¥${formatMoney(end)}`
684
- : `¥${formatMoney(start)} ~ ¥${formatMoney(end)}`;
685
- ranges.push({ id: `r${i}`, min: start, max: end, label });
686
- }
687
-
688
- return ranges;
689
- }
690
-
691
- function renderPriceFilterOptions() {
692
- const prev = els.priceFilterSelect.value || 'all';
693
- state.priceRanges = getPriceRanges(state.products);
694
-
695
- const options = ['<option value="all">全部价格分类</option>'];
696
- state.priceRanges.forEach((r) => {
697
- options.push(`<option value="${r.id}">${r.label}</option>`);
698
- });
699
- els.priceFilterSelect.innerHTML = options.join('');
700
-
701
- const canKeep = prev === 'all' || state.priceRanges.some((r) => r.id === prev);
702
- els.priceFilterSelect.value = canKeep ? prev : 'all';
703
- }
704
-
705
- function openImportModal() {
706
- els.importText.value = state.products.map((p) => `${p.name}\t${p.price}`).join('\n');
707
- els.importModal.classList.add('active');
708
- }
709
-
710
- function closeImportModal() {
711
- els.importModal.classList.remove('active');
712
- }
713
-
714
- function saveImport() {
715
- const parsed = parseProductsText(els.importText.value);
716
- if (!parsed.length) {
717
- alert('未识别到有效商品,请检查格式:商品名 + 价格。');
718
- return;
719
- }
720
- state.products = parsed;
721
- renderPriceFilterOptions();
722
- saveProducts();
723
- renderProducts();
724
- closeImportModal();
725
- alert(`导入成功,共 ${parsed.length} 个商品。`);
726
- }
727
-
728
- function filteredProducts() {
729
- const keyword = els.searchInput.value.trim().toLowerCase();
730
- const sort = els.sortSelect.value;
731
- const priceFilter = els.priceFilterSelect.value;
732
 
733
- let list = state.products.filter((p) => {
734
- if (keyword) {
735
- const byName = p.name.toLowerCase().includes(keyword);
736
- if (!byName) return false;
 
 
737
  }
738
 
739
- if (priceFilter !== 'all') {
740
- const range = state.priceRanges.find((r) => r.id === priceFilter);
741
- if (!range) return true;
742
- const isLast = range.id === state.priceRanges[state.priceRanges.length - 1]?.id;
743
- const inRange = isLast
744
- ? p.price >= range.min && p.price <= range.max
745
- : p.price >= range.min && p.price < range.max;
746
- if (!inRange) return false;
747
  }
748
 
749
- return true;
750
- });
 
 
751
 
752
- if (sort === 'priceAsc') list.sort((a, b) => a.price - b.price);
753
- else if (sort === 'priceDesc') list.sort((a, b) => b.price - a.price);
754
- else if (sort === 'name') list.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
 
 
 
 
 
 
 
 
 
 
755
 
756
- return list;
757
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
758
 
759
- function resetProductFilters() {
760
- els.sortSelect.value = 'default';
761
- els.priceFilterSelect.value = 'all';
762
- els.searchInput.value = '';
763
- renderProducts();
764
- }
 
765
 
766
- function addProductToOrder(product) {
767
- const found = state.order.find((it) => it.name === product.name && it.price === product.price);
768
- if (found) {
769
- found.qty += 1;
770
- } else {
771
- state.order.push({
772
- name: product.name,
773
- price: product.price,
774
- qty: 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
775
  });
776
- }
777
- state.result = null;
778
- renderOrder();
779
- }
780
-
781
- function updateQty(index, delta) {
782
- const item = state.order[index];
783
- if (!item) return;
784
- item.qty += delta;
785
- if (item.qty <= 0) state.order.splice(index, 1);
786
- state.result = null;
787
- renderOrder();
788
- }
789
-
790
- function updateOrderPrice(index, value) {
791
- const item = state.order[index];
792
- if (!item) return;
793
- const v = Number(value);
794
- if (!isFinite(v) || v <= 0) return;
795
- const nextPrice = Math.round(v * 100) / 100;
796
- if (item.price === nextPrice) return;
797
- item.price = nextPrice;
798
- state.result = null;
799
- renderOrderFooter();
800
- renderResult();
801
- }
802
-
803
- function clearOrder() {
804
- state.order = [];
805
- state.result = null;
806
- renderOrder();
807
- }
808
-
809
- function clearResult() {
810
- state.result = null;
811
- renderResult();
812
- }
813
-
814
- function getOrderSummary() {
815
- const count = state.order.reduce((sum, item) => sum + item.qty, 0);
816
- const total = state.order.reduce((sum, item) => sum + item.price * item.qty, 0);
817
- return { count, total };
818
- }
819
-
820
- function renderOrderFooter() {
821
- const sumEl = els.orderWrap.querySelector('.sum-line');
822
- if (!sumEl) return;
823
- const { count, total } = getOrderSummary();
824
- sumEl.innerHTML = `<span>${count}件商品原价总和</span><strong>¥ ${formatMoney(total)}</strong>`;
825
- }
826
-
827
- function renderProducts() {
828
- const displayList = filteredProducts();
829
- els.productCountText.textContent = `${displayList.length} / ${state.products.length} 个商品`;
830
-
831
- if (!state.products.length) {
832
- els.productGrid.innerHTML = '<div class="empty">当前没有商品,请点击上方“导入商品”</div>';
833
- return;
834
- }
835
 
836
- if (!displayList.length) {
837
- els.productGrid.innerHTML = '<div class="empty">没有匹配商品,请按商品名称搜索</div>';
838
- return;
839
- }
840
-
841
- const frag = document.createDocumentFragment();
842
- displayList.forEach((p) => {
843
- const card = document.createElement('div');
844
- card.className = 'card';
845
- card.innerHTML = `
846
- <div class="name">${escapeHtml(p.name)}</div>
847
- <div class="price">¥ ${formatMoney(p.price)}</div>
848
- `;
849
- card.addEventListener('click', () => addProductToOrder(p));
850
- frag.appendChild(card);
851
- });
852
-
853
- els.productGrid.innerHTML = '';
854
- els.productGrid.appendChild(frag);
855
- }
856
-
857
- function renderOrder() {
858
- const frag = document.createDocumentFragment();
859
-
860
- if (!state.order.length) {
861
- const empty = document.createElement('div');
862
- empty.className = 'empty';
863
- empty.textContent = '请先从左侧商品陈列中点击添加商品';
864
- frag.appendChild(empty);
865
- } else {
866
- state.order.forEach((item, index) => {
867
- const subtotal = item.price * item.qty;
868
- const row = document.createElement('div');
869
- row.className = 'order-item';
870
- row.innerHTML = `
871
- <div>
872
- <div class="order-name">${escapeHtml(item.name)}</div>
873
- <div class="order-prices">
874
- <div>原价:<input class="order-origin-input" data-price-index="${index}" value="${formatMoney(item.price)}" /></div>
875
- <div>小计:<span class="mono">¥ ${formatMoney(subtotal)}</span></div>
876
- </div>
877
- </div>
878
- <div class="qty-box">
879
- <button class="mini-btn" data-action="minus" data-index="${index}">-</button>
880
- <span class="qty-text">${item.qty}</span>
881
- <button class="mini-btn" data-action="plus" data-index="${index}">+</button>
882
- </div>
883
- `;
884
- frag.appendChild(row);
885
  });
886
- }
887
-
888
- const { count, total } = getOrderSummary();
889
- const sum = document.createElement('div');
890
- sum.className = 'sum-line';
891
- sum.innerHTML = `<span>${count}件商品原价总和</span><strong>¥ ${formatMoney(total)}</strong>`;
892
-
893
- els.orderWrap.innerHTML = '';
894
- els.orderWrap.appendChild(frag);
895
- els.orderWrap.appendChild(sum);
896
- renderResult();
897
- }
898
-
899
- function getAllocations(orderItems, payTotal) {
900
- const totalOriginal = orderItems.reduce((sum, item) => sum + item.price * item.qty, 0);
901
- const expanded = [];
902
 
903
- orderItems.forEach((item) => {
904
- for (let i = 0; i < item.qty; i += 1) {
905
- expanded.push({ name: item.name, originalPrice: item.price, price: item.price });
906
  }
907
- });
908
-
909
- const totalCount = expanded.length;
910
- const rawAlloc = expanded.map((it) => (it.price / totalOriginal) * payTotal);
911
- const rounded = rawAlloc.map((v) => Math.round(v * 100) / 100);
912
- const roundedSum = rounded.reduce((a, b) => a + b, 0);
913
- const firstDiff = Math.round((payTotal - roundedSum) * 100) / 100;
914
- if (rounded.length && firstDiff !== 0) {
915
- rounded[rounded.length - 1] = Math.round((rounded[rounded.length - 1] + firstDiff) * 100) / 100;
916
- }
917
-
918
- const seqMap = new Map();
919
- const rows = expanded.map((item, idx) => {
920
- const seq = (seqMap.get(item.name) || 0) + 1;
921
- seqMap.set(item.name, seq);
922
- return {
923
- name: item.name,
924
- seq,
925
- originalPrice: item.originalPrice,
926
- allocatedPrice: rounded[idx]
927
- };
928
- });
929
-
930
- const allocatedTotal = rows.reduce((sum, row) => sum + row.allocatedPrice, 0);
931
- return {
932
- rows,
933
- totalOriginal,
934
- totalCount,
935
- allocatedTotal,
936
- diff: Math.round((payTotal - allocatedTotal) * 100) / 100
937
- };
938
- }
939
-
940
- function calculate() {
941
- if (!state.order.length) {
942
- alert('请先添加订单商品。');
943
- return;
944
- }
945
-
946
- const payTotal = Number(els.payInput.value);
947
- if (!isFinite(payTotal) || payTotal <= 0) {
948
- alert('请输入有效的订单优惠后总金额。');
949
- els.payInput.focus();
950
- openNumpad();
951
- return;
952
- }
953
-
954
- state.result = { payTotal, ...getAllocations(state.order, payTotal) };
955
- renderResult();
956
- closeNumpad();
957
- }
958
-
959
- function renderResult() {
960
- if (!state.result || !state.order.length) {
961
- if (lastResultEmptyState === true) return;
962
- lastResultEmptyState = true;
963
- els.resultWrap.innerHTML = '<div class="empty">计算后将完整展示每一件商品:原价 与 订单价格(优惠后)</div>';
964
- return;
965
- }
966
-
967
- lastResultEmptyState = false;
968
-
969
- const { payTotal, totalOriginal, totalCount, rows, allocatedTotal, diff } = state.result;
970
- const frag = document.createDocumentFragment();
971
 
972
- const head = document.createElement('div');
973
- head.className = 'result-head';
974
- head.innerHTML = `
975
- <div>结果条数:<strong class="mono">${rows.length}</strong>(逐件)</div>
976
- <div>订单优惠后总金额:<strong class="mono">¥ ${formatMoney(payTotal)}</strong> | ${totalCount}件商品原价总和:<strong class="mono">¥ ${formatMoney(totalOriginal)}</strong></div>
977
- `;
978
- frag.appendChild(head);
979
-
980
- rows.forEach((row) => {
981
- const item = document.createElement('div');
982
- item.className = 'result-item';
983
- item.innerHTML = `
984
- <div class="result-top">${escapeHtml(row.name)}(第${row.seq}件)</div>
985
- <div class="price-pair">
986
- <div class="price-box">
987
- <span class="label">原价</span>
988
- <strong class="value">¥ ${formatMoney(row.originalPrice)}</strong>
989
- </div>
990
- <div class="price-box">
991
- <span class="label">订单价格(优惠后)</span>
992
- <strong class="value actual">¥ ${formatMoney(row.allocatedPrice)}</strong>
993
- </div>
994
- </div>
995
- `;
996
- frag.appendChild(item);
997
- });
998
-
999
- const check = document.createElement('div');
1000
- check.className = 'check-box';
1001
- check.innerHTML = `
1002
- <div>校检:分摊后总和 <strong class="mono">¥ ${formatMoney(allocatedTotal)}</strong> | 订单优惠后总金额 <strong class="mono">¥ ${formatMoney(payTotal)}</strong></div>
1003
- <div>差额:<strong class="mono">¥ ${formatMoney(Math.abs(diff))}</strong></div>
1004
- `;
1005
- frag.appendChild(check);
1006
-
1007
- els.resultWrap.innerHTML = '';
1008
- els.resultWrap.appendChild(frag);
1009
- }
1010
-
1011
- function updateNumpadActionLabel() {
1012
- if (!els.numpadActionBtn) return;
1013
- const isPayInput = !activeNumpadInput || activeNumpadInput === els.payInput;
1014
- els.numpadActionBtn.textContent = isPayInput ? '计算' : '确认';
1015
- }
1016
-
1017
- function handleKeypadPress(key) {
1018
- const targetInput = activeNumpadInput || els.payInput;
1019
- const current = targetInput.value;
1020
-
1021
- if (key === 'hide') {
1022
- closeNumpad();
1023
- return;
1024
- }
1025
-
1026
- if (key === 'calc') {
1027
- if (targetInput !== els.payInput) {
1028
- targetInput.blur();
1029
- closeNumpad();
1030
- return;
1031
  }
1032
- calculate();
1033
- return;
1034
- }
1035
-
1036
- if (key === 'clear') {
1037
- targetInput.value = '';
1038
- targetInput.dispatchEvent(new Event('input', { bubbles: true }));
1039
- return;
1040
- }
1041
-
1042
- if (key === 'backspace') {
1043
- targetInput.value = current.slice(0, -1);
1044
- targetInput.dispatchEvent(new Event('input', { bubbles: true }));
1045
- return;
1046
- }
1047
-
1048
- if (key === '.') {
1049
- if (!current) targetInput.value = '0.';
1050
- else if (!current.includes('.')) targetInput.value += '.';
1051
- targetInput.dispatchEvent(new Event('input', { bubbles: true }));
1052
- return;
1053
- }
1054
-
1055
- if (/^\d$/.test(key)) {
1056
- if (current === '0') targetInput.value = key;
1057
- else targetInput.value = normalizeInputValue(`${current}${key}`);
1058
- targetInput.dispatchEvent(new Event('input', { bubbles: true }));
1059
- }
1060
- }
1061
-
1062
- function openNumpad(targetInput = els.payInput) {
1063
- activeNumpadInput = targetInput;
1064
- updateNumpadActionLabel();
1065
- els.numpad.classList.add('active');
1066
- }
1067
-
1068
- function closeNumpad() {
1069
- els.numpad.classList.remove('active');
1070
- activeNumpadInput = null;
1071
- updateNumpadActionLabel();
1072
- }
1073
-
1074
- function bindEvents() {
1075
- document.getElementById('openImportBtn').addEventListener('click', openImportModal);
1076
- document.getElementById('closeImportBtn').addEventListener('click', closeImportModal);
1077
- document.getElementById('saveImportBtn').addEventListener('click', saveImport);
1078
- document.getElementById('resetFilterBtn').addEventListener('click', resetProductFilters);
1079
-
1080
- document.getElementById('calcBtn').addEventListener('click', calculate);
1081
- document.getElementById('clearOrderBtn').addEventListener('click', clearOrder);
1082
- document.getElementById('clearResultBtn').addEventListener('click', clearResult);
1083
-
1084
- const scheduleRenderProducts = (() => {
1085
- let rafId = 0;
1086
- return () => {
1087
- if (rafId) cancelAnimationFrame(rafId);
1088
- rafId = requestAnimationFrame(() => {
1089
- rafId = 0;
1090
- renderProducts();
1091
- });
1092
- };
1093
- })();
1094
-
1095
- els.sortSelect.addEventListener('change', renderProducts);
1096
- els.priceFilterSelect.addEventListener('change', renderProducts);
1097
- els.searchInput.addEventListener('input', scheduleRenderProducts);
1098
-
1099
- els.productGrid.addEventListener('click', (e) => {
1100
- const target = e.target;
1101
- if (!(target instanceof HTMLElement)) return;
1102
- const card = target.closest('.card');
1103
- if (!(card instanceof HTMLElement)) return;
1104
- const index = Number(card.dataset.productIndex);
1105
- if (Number.isNaN(index)) return;
1106
- const product = lastDisplayedProducts[index];
1107
- if (!product) return;
1108
- addProductToOrder(product);
1109
- });
1110
 
1111
- els.orderWrap.addEventListener('click', (e) => {
1112
- const target = e.target;
1113
- if (!(target instanceof HTMLElement)) return;
1114
- const action = target.dataset.action;
1115
- if (!action) return;
1116
-
1117
- const index = Number(target.dataset.index);
1118
- if (Number.isNaN(index)) return;
1119
-
1120
- if (action === 'minus') updateQty(index, -1);
1121
- if (action === 'plus') updateQty(index, 1);
1122
- });
1123
-
1124
- els.orderWrap.addEventListener('focusin', (e) => {
1125
- const target = e.target;
1126
- if (!(target instanceof HTMLInputElement)) return;
1127
- if (!target.dataset.priceIndex) return;
1128
- openNumpad(target);
1129
- });
1130
-
1131
- els.orderWrap.addEventListener('input', (e) => {
1132
- const target = e.target;
1133
- if (!(target instanceof HTMLInputElement)) return;
1134
- if (!target.dataset.priceIndex) return;
1135
-
1136
- const v = normalizeInputValue(target.value);
1137
- target.value = v;
1138
- const index = Number(target.dataset.priceIndex);
1139
- if (Number.isNaN(index)) return;
1140
- updateOrderPrice(index, v);
1141
- });
1142
-
1143
- els.orderWrap.addEventListener('keydown', (e) => {
1144
- const target = e.target;
1145
- if (!(target instanceof HTMLInputElement)) return;
1146
- if (!target.dataset.priceIndex) return;
1147
- if (e.key !== 'Enter') return;
1148
- e.preventDefault();
1149
- handleKeypadPress('calc');
1150
- });
1151
-
1152
- els.payInput.addEventListener('input', () => {
1153
- els.payInput.value = normalizeInputValue(els.payInput.value);
1154
- });
1155
-
1156
- els.payInput.addEventListener('keydown', (e) => {
1157
- if (e.key !== 'Enter') return;
1158
- e.preventDefault();
1159
- handleKeypadPress('calc');
1160
- });
1161
-
1162
- els.payInput.addEventListener('focus', () => {
1163
- openNumpad(els.payInput);
1164
- });
1165
-
1166
- document.addEventListener('click', (e) => {
1167
- const target = e.target;
1168
- if (!(target instanceof HTMLElement)) return;
1169
-
1170
- const clickInsidePad = els.numpad.contains(target);
1171
- const clickInput = target === els.payInput || !!target.closest('.order-origin-input');
1172
- if (!clickInsidePad && !clickInput) closeNumpad();
1173
-
1174
- if (target === els.importModal) closeImportModal();
1175
- });
1176
-
1177
- document.addEventListener('keydown', (e) => {
1178
- if (e.key !== 'Enter') return;
1179
- if (!els.numpad.classList.contains('active')) return;
1180
- const focused = document.activeElement;
1181
- if (!(focused instanceof HTMLElement)) return;
1182
- if (!els.numpad.contains(focused)) return;
1183
- e.preventDefault();
1184
- handleKeypadPress('calc');
1185
- });
1186
-
1187
- els.numpad.addEventListener('mousedown', (e) => {
1188
- const target = e.target;
1189
- if (!(target instanceof HTMLElement)) return;
1190
- if (!target.closest('.key')) return;
1191
- e.preventDefault();
1192
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1193
 
1194
- els.numpad.addEventListener('click', (e) => {
1195
- const target = e.target;
1196
- if (!(target instanceof HTMLElement)) return;
1197
- const key = target.dataset.key;
1198
- if (!key) return;
1199
- handleKeypadPress(key);
1200
- });
1201
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1202
 
1203
- function registerServiceWorker() {
1204
- if (!('serviceWorker' in navigator)) return;
1205
- window.addEventListener('load', () => {
1206
- navigator.serviceWorker.register('./sw.js').catch(() => {});
1207
- });
1208
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
1209
 
1210
- function init() {
1211
- loadProducts();
1212
- renderPriceFilterOptions();
1213
- bindEvents();
1214
- resetProductFilters();
1215
- renderOrder();
1216
- renderResult();
1217
- registerServiceWorker();
1218
- }
 
 
 
 
 
1219
 
1220
- init();
1221
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1222
  </body>
1223
- </html>
 
1
  <!DOCTYPE html>
2
  <html lang="zh-CN">
3
  <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>图片水印批量生成工具</title>
7
+ <!-- PWA -->
8
+ <link rel="manifest" href="manifest.json">
9
+ <meta name="theme-color" content="#ffffff">
10
+ <link rel="icon" type="image/svg+xml" href="icon.svg">
11
+ <meta name="apple-mobile-web-app-capable" content="yes">
12
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
13
+ <link rel="apple-touch-icon" href="icon.svg">
14
+ <!-- Tailwind CSS -->
15
+ <script src="https://cdn.tailwindcss.com"></script>
16
+ <!-- Google Fonts -->
17
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
18
+ <!-- Libraries -->
19
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
20
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
21
+ <style>
22
+ body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
23
+ /* Custom scrollbar for better look */
24
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
25
+ ::-webkit-scrollbar-track { background: #f1f1f1; }
26
+ ::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 4px; }
27
+ ::-webkit-scrollbar-thumb:hover { background: #a8a8a8; }
28
+ .range-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: #3b82f6; border-radius: 50%; cursor: pointer; }
29
+ .range-slider::-moz-range-thumb { width: 16px; height: 16px; background: #3b82f6; border-radius: 50%; cursor: pointer; }
30
+ </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  </head>
32
+ <body class="bg-gray-50 text-gray-800 min-h-screen">
33
+
34
+ <!-- Navbar -->
35
+ <nav class="bg-white shadow-sm sticky top-0 z-50">
36
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
37
+ <div class="flex justify-between h-16 items-center">
38
+ <div class="flex items-center gap-2">
39
+ <svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
40
+ <span class="font-bold text-xl tracking-tight text-gray-900">水印批量助手</span>
41
+ </div>
42
+ <div class="flex gap-3" id="top-actions" style="display: none;">
43
+ <button onclick="exportAll()" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 shadow-sm transition-colors duration-200">
44
+ <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
45
+ 批量导出图片
46
+ </button>
47
+ <button onclick="exportAllZip()" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-500 hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-amber-500 shadow-sm transition-colors duration-200">
48
+ <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
49
+ 打包下载 (ZIP)
50
+ </button>
51
+ </div>
52
+ </div>
 
 
 
 
53
  </div>
54
+ </nav>
55
+
56
+ <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
57
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
58
+
59
+ <!-- Left Sidebar: Global Settings -->
60
+ <div class="lg:col-span-4 xl:col-span-3">
61
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 sticky top-24">
62
+ <div class="flex items-center justify-between mb-6">
63
+ <h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
64
+ <svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
65
+ 全局设置
66
+ </h2>
67
+ <span class="text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded-full">自动保存</span>
68
+ </div>
69
+
70
+ <div class="space-y-5">
71
+ <!-- Text Inputs -->
72
+ <div>
73
+ <label class="block text-sm font-medium text-gray-700 mb-1">影城名称</label>
74
+ <input type="text" id="global-cinema" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入影城名称" value="示例国际影城">
75
+ </div>
76
+ <div>
77
+ <label class="block text-sm font-medium text-gray-700 mb-1">影片名字</label>
78
+ <input type="text" id="global-movie" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入影片名字" value="默认影片名称">
79
+ </div>
80
+ <div>
81
+ <label class="block text-sm font-medium text-gray-700 mb-1">放映时间</label>
82
+ <input type="text" id="global-time" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="YYYY-MM-DD HH:MM" value="2026-03-16 19:30">
83
+ </div>
84
+ <div>
85
+ <label class="block text-sm font-medium text-gray-700 mb-1">共出票 (张)</label>
86
+ <input type="number" id="global-tickets" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入出票数量" value="120">
87
+ </div>
88
+ <div>
89
+ <label class="block text-sm font-medium text-gray-700 mb-1">额外文字1 (选填)</label>
90
+ <input type="text" id="global-extra1" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入额外文字1">
91
+ </div>
92
+ <div>
93
+ <label class="block text-sm font-medium text-gray-700 mb-1">额外文字2 (选填)</label>
94
+ <input type="text" id="global-extra2" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入额外文字2">
95
+ </div>
96
+
97
+ <div class="border-t border-gray-100 my-4"></div>
98
+
99
+ <!-- Style Controls -->
100
+ <div>
101
+ <div class="flex justify-between mb-1">
102
+ <label class="text-sm font-medium text-gray-700">文字大小</label>
103
+ <span class="text-xs text-gray-500" id="size-val">1.0x</span>
104
+ </div>
105
+ <input type="range" id="global-size" min="0.5" max="3.0" step="0.1" value="1.0" class="range-slider w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
106
+ </div>
107
+
108
+ <div>
109
+ <label class="block text-sm font-medium text-gray-700 mb-1">文字颜色</label>
110
+ <div class="flex items-center gap-2">
111
+ <input type="color" id="global-color" value="#ffffff" class="h-8 w-12 p-0 border-0 rounded cursor-pointer">
112
+ <span class="text-xs text-gray-500">点击色块选择颜色</span>
113
+ </div>
114
+ </div>
115
+
116
+ <div class="grid grid-cols-2 gap-4">
117
+ <div>
118
+ <label class="block text-xs font-medium text-gray-500 mb-1">左右微调</label>
119
+ <input type="range" id="global-offsetX" min="-100" max="100" value="0" class="range-slider w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
120
+ </div>
121
+ <div>
122
+ <label class="block text-xs font-medium text-gray-500 mb-1">上下微调</label>
123
+ <input type="range" id="global-offsetY" min="-100" max="100" value="0" class="range-slider w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </div>
129
 
130
+ <!-- Right Content: Upload & Grid -->
131
+ <div class="lg:col-span-8 xl:col-span-9 space-y-6">
132
+
133
+ <!-- Upload Zone -->
134
+ <div id="drop-zone" class="relative border-2 border-dashed border-gray-300 rounded-xl p-10 text-center hover:border-blue-500 hover:bg-blue-50 transition-colors duration-200 cursor-pointer bg-white" onclick="document.getElementById('imageLoader').click()">
135
+ <input type="file" id="imageLoader" accept="image/*" multiple class="hidden">
136
+ <div class="space-y-2">
137
+ <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
138
+ <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
139
+ </svg>
140
+ <div class="flex text-sm text-gray-600 justify-center">
141
+ <span class="relative font-medium text-blue-600 rounded-md hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
142
+ 点击上传图片
143
+ </span>
144
+ <p class="pl-1">或拖拽图片到这里</p>
145
+ </div>
146
+ <p class="text-xs text-gray-500">支持 PNG, JPG, GIF (可多选)</p>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Preview Grid -->
151
+ <div id="preview-container" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
152
+ <!-- Cards will be injected here -->
153
+ </div>
154
+
155
+ <!-- Empty State -->
156
+ <div id="empty-state" class="text-center py-12 hidden">
157
+ <p class="text-gray-500 text-lg">暂无图片,请先上传</p>
158
+ </div>
159
+ </div>
160
  </div>
 
 
161
 
162
+ <!-- Loading Overlay -->
163
+ <div id="loading-overlay" class="fixed inset-0 bg-gray-900 bg-opacity-50 z-[100] hidden items-center justify-center backdrop-blur-sm transition-opacity duration-300">
164
+ <div class="bg-white rounded-2xl shadow-xl p-8 max-w-sm w-full mx-4 transform transition-all">
165
+ <div class="text-center">
166
+ <svg class="animate-spin mx-auto h-12 w-12 text-blue-600 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
167
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
168
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
169
+ </svg>
170
+ <h3 id="loading-title" class="text-lg font-medium text-gray-900 mb-2">处理中</h3>
171
+ <p id="loading-text" class="text-sm text-gray-500 mb-4">请稍候...</p>
172
+ <div class="w-full bg-gray-200 rounded-full h-2.5">
173
+ <div id="loading-progress" class="bg-blue-600 h-2.5 rounded-full transition-all duration-300" style="width: 0%"></div>
174
+ </div>
175
+ </div>
176
+ </div>
177
  </div>
 
 
178
  </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
+ <footer class="bg-white border-t border-gray-200 mt-12 py-8">
181
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-400 text-sm">
182
+ <p>&copy; 2026 图片水印批量生成工具. All rights reserved.</p>
183
+ </div>
184
+ </footer>
185
+
186
+ <script>
187
+ // PWA Registration
188
+ if ('serviceWorker' in navigator) {
189
+ window.addEventListener('load', () => {
190
+ navigator.serviceWorker.register('./sw.js')
191
+ .then(reg => console.log('Service Worker Registered!', reg))
192
+ .catch(err => console.log('Service Worker Failed!', err));
193
+ });
 
 
 
 
 
 
 
 
194
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
+ const imageLoader = document.getElementById('imageLoader');
197
+ const dropZone = document.getElementById('drop-zone');
198
+ const previewContainer = document.getElementById('preview-container');
199
+ const topActions = document.getElementById('top-actions');
200
+ const emptyState = document.getElementById('empty-state');
201
+
202
+ // Loading UI elements
203
+ const loadingOverlay = document.getElementById('loading-overlay');
204
+ const loadingTitle = document.getElementById('loading-title');
205
+ const loadingText = document.getElementById('loading-text');
206
+ const loadingProgress = document.getElementById('loading-progress');
207
+
208
+ let imageItems = []; // Store state
209
+ let watermarkWorker = new Worker('worker.js');
210
+
211
+ // Worker communication
212
+ watermarkWorker.onmessage = function(e) {
213
+ const { type, text, percent, blob, filename, error } = e.data;
214
+ if (type === 'PROGRESS') {
215
+ updateLoading(text, percent);
216
+ } else if (type === 'DONE') {
217
+ saveAs(blob, filename);
218
+ hideLoading();
219
+ } else if (type === 'DONE_SINGLE') {
220
+ saveAs(blob, filename);
221
+ } else if (type === 'DONE_ALL_SINGLE') {
222
+ hideLoading();
223
+ } else if (type === 'ERROR') {
224
+ alert('处理出错: ' + error);
225
+ hideLoading();
226
+ }
227
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
+ function showLoading(title, text) {
230
+ loadingTitle.innerText = title;
231
+ loadingText.innerText = text;
232
+ loadingProgress.style.width = '0%';
233
+ loadingOverlay.classList.remove('hidden');
234
+ loadingOverlay.classList.add('flex');
235
  }
236
 
237
+ function updateLoading(text, percent) {
238
+ loadingText.innerText = text;
239
+ loadingProgress.style.width = `${percent}%`;
 
 
 
 
 
240
  }
241
 
242
+ function hideLoading() {
243
+ loadingOverlay.classList.add('hidden');
244
+ loadingOverlay.classList.remove('flex');
245
+ }
246
 
247
+ // Global Controls
248
+ const globalControls = {
249
+ cinema: document.getElementById('global-cinema'),
250
+ movie: document.getElementById('global-movie'),
251
+ time: document.getElementById('global-time'),
252
+ tickets: document.getElementById('global-tickets'),
253
+ extra1: document.getElementById('global-extra1'),
254
+ extra2: document.getElementById('global-extra2'),
255
+ size: document.getElementById('global-size'),
256
+ color: document.getElementById('global-color'),
257
+ offsetX: document.getElementById('global-offsetX'),
258
+ offsetY: document.getElementById('global-offsetY')
259
+ };
260
 
261
+ // Load Settings
262
+ function loadSettings() {
263
+ const saved = localStorage.getItem('watermarkSettings');
264
+ if (saved) {
265
+ try {
266
+ const settings = JSON.parse(saved);
267
+ Object.keys(settings).forEach(key => {
268
+ if (globalControls[key]) {
269
+ globalControls[key].value = settings[key];
270
+ if(key === 'size') document.getElementById('size-val').innerText = settings[key] + 'x';
271
+ }
272
+ });
273
+ } catch (e) { console.error('Failed to load settings', e); }
274
+ }
275
+ }
276
 
277
+ function saveSettings() {
278
+ const settings = {};
279
+ Object.keys(globalControls).forEach(key => {
280
+ settings[key] = globalControls[key].value;
281
+ });
282
+ localStorage.setItem('watermarkSettings', JSON.stringify(settings));
283
+ }
284
 
285
+ loadSettings();
286
+
287
+ // Bind Global Events
288
+ Object.keys(globalControls).forEach(key => {
289
+ const input = globalControls[key];
290
+ input.addEventListener('input', () => {
291
+ const newValue = input.value;
292
+ saveSettings();
293
+
294
+ if(key === 'size') document.getElementById('size-val').innerText = newValue + 'x';
295
+
296
+ // Use requestAnimationFrame for debouncing UI updates
297
+ requestAnimationFrame(() => {
298
+ imageItems.forEach(item => {
299
+ if (key === 'size') item.data.sizeScale = parseFloat(newValue);
300
+ else if (key === 'offsetX' || key === 'offsetY') item.data[key] = parseInt(newValue);
301
+ else item.data[key] = newValue;
302
+
303
+ const cardInputId = key === 'size' ? `size-${item.id}` : `${key}-${item.id}`;
304
+ const cardInput = document.getElementById(cardInputId);
305
+ if (cardInput) cardInput.value = newValue;
306
+
307
+ drawWatermarkPreview(item.canvas, item.thumbCanvas, item.data, item.originalWidth, item.originalHeight);
308
+ });
309
+ });
310
+ });
311
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
+ // File Upload Handling
314
+ imageLoader.addEventListener('change', handleImageUpload);
315
+
316
+ // Drag and Drop
317
+ dropZone.addEventListener('dragover', (e) => {
318
+ e.preventDefault();
319
+ dropZone.classList.add('border-blue-500', 'bg-blue-50');
320
+ });
321
+ dropZone.addEventListener('dragleave', (e) => {
322
+ e.preventDefault();
323
+ dropZone.classList.remove('border-blue-500', 'bg-blue-50');
324
+ });
325
+ dropZone.addEventListener('drop', (e) => {
326
+ e.preventDefault();
327
+ dropZone.classList.remove('border-blue-500', 'bg-blue-50');
328
+ const files = e.dataTransfer.files;
329
+ processFiles(files);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
 
332
+ function handleImageUpload(e) {
333
+ processFiles(e.target.files);
334
+ e.target.value = ''; // Reset
335
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
 
337
+ function processFiles(files) {
338
+ if (files.length > 0) {
339
+ topActions.style.display = 'flex';
340
+ emptyState.style.display = 'none';
341
+ showLoading('加载图片中', '正在生成预览缩略图...');
342
+ }
343
+
344
+ const validFiles = Array.from(files).filter(file => file.type.match('image.*'));
345
+ let currentIndex = 0;
346
+
347
+ // 串行加载并生成缩略图,避免大量全尺寸图片同时进入内存
348
+ function loadNextImage() {
349
+ if (currentIndex >= validFiles.length) {
350
+ hideLoading();
351
+ return;
352
+ }
353
+
354
+ const file = validFiles[currentIndex];
355
+ const objectUrl = URL.createObjectURL(file);
356
+
357
+ const img = new Image();
358
+ img.onload = () => {
359
+ // Generate thumbnail
360
+ const MAX_WIDTH = 800;
361
+ const scale = img.width > MAX_WIDTH ? MAX_WIDTH / img.width : 1;
362
+ const thumbWidth = img.width * scale;
363
+ const thumbHeight = img.height * scale;
364
+
365
+ const thumbCanvas = document.createElement('canvas');
366
+ thumbCanvas.width = thumbWidth;
367
+ thumbCanvas.height = thumbHeight;
368
+ const tCtx = thumbCanvas.getContext('2d');
369
+ tCtx.drawImage(img, 0, 0, thumbWidth, thumbHeight);
370
+
371
+ updateLoading(`处理 ${currentIndex + 1}/${validFiles.length}`, (currentIndex / validFiles.length) * 100);
372
+
373
+ createImageCard(file, thumbCanvas, file.name, img.width, img.height);
374
+ URL.revokeObjectURL(objectUrl);
375
+
376
+ currentIndex++;
377
+ requestAnimationFrame(() => setTimeout(loadNextImage, 10));
378
+ };
379
+ img.onerror = () => {
380
+ currentIndex++;
381
+ loadNextImage();
382
+ };
383
+ img.src = objectUrl;
384
+ }
385
+
386
+ loadNextImage();
 
 
 
 
 
 
 
 
 
387
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
+ function createImageCard(file, thumbCanvas, filename, originalWidth, originalHeight) {
390
+ const id = Date.now() + Math.random().toString(36).substr(2, 5);
391
+
392
+ const data = {
393
+ cinema: globalControls.cinema.value,
394
+ movie: globalControls.movie.value,
395
+ time: globalControls.time.value,
396
+ tickets: globalControls.tickets.value,
397
+ extra1: globalControls.extra1.value,
398
+ extra2: globalControls.extra2.value,
399
+ color: globalControls.color.value,
400
+ sizeScale: parseFloat(globalControls.size.value),
401
+ offsetX: parseInt(globalControls.offsetX.value),
402
+ offsetY: parseInt(globalControls.offsetY.value)
403
+ };
404
+
405
+ const card = document.createElement('div');
406
+ card.className = 'bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow duration-200 flex flex-col';
407
+ card.id = `card-${id}`;
408
+
409
+ card.innerHTML = `
410
+ <div class="relative bg-gray-100 border-b border-gray-100">
411
+ <canvas id="canvas-${id}" class="w-full h-auto block"></canvas>
412
+ <div class="absolute top-2 right-2">
413
+ <button onclick="removeCard('${id}')" class="p-1 bg-white rounded-full shadow text-gray-400 hover:text-red-500 transition-colors">
414
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
415
+ </button>
416
+ </div>
417
+ </div>
418
+ <div class="p-4 space-y-3 flex-1">
419
+ <div class="grid grid-cols-2 gap-3">
420
+ <div>
421
+ <label class="block text-xs font-medium text-gray-500 mb-1">影城</label>
422
+ <input type="text" id="cinema-${id}" value="${data.cinema}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
423
+ </div>
424
+ <div>
425
+ <label class="block text-xs font-medium text-gray-500 mb-1">影片</label>
426
+ <input type="text" id="movie-${id}" value="${data.movie}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
427
+ </div>
428
+ <div>
429
+ <label class="block text-xs font-medium text-gray-500 mb-1">时间</label>
430
+ <input type="text" id="time-${id}" value="${data.time}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
431
+ </div>
432
+ <div>
433
+ <label class="block text-xs font-medium text-gray-500 mb-1">票数</label>
434
+ <input type="number" id="tickets-${id}" value="${data.tickets}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
435
+ </div>
436
+ <div>
437
+ <label class="block text-xs font-medium text-gray-500 mb-1">额外1</label>
438
+ <input type="text" id="extra1-${id}" value="${data.extra1 || ''}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
439
+ </div>
440
+ <div>
441
+ <label class="block text-xs font-medium text-gray-500 mb-1">额外2</label>
442
+ <input type="text" id="extra2-${id}" value="${data.extra2 || ''}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
443
+ </div>
444
+ </div>
445
+
446
+ <details class="text-xs text-gray-500 cursor-pointer">
447
+ <summary class="hover:text-blue-600 transition-colors font-medium">更多单独微调</summary>
448
+ <div class="mt-3 space-y-3 pt-2 border-t border-gray-100">
449
+ <div>
450
+ <div class="flex justify-between mb-1"><span>大小</span><span class="text-gray-400">倍数</span></div>
451
+ <input type="range" id="size-${id}" min="0.5" max="3.0" step="0.1" value="${data.sizeScale}" class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer">
452
+ </div>
453
+ <div class="flex items-center justify-between">
454
+ <span>颜色</span>
455
+ <input type="color" id="color-${id}" value="${data.color}" class="h-6 w-10 border-0 p-0 rounded">
456
+ </div>
457
+ <div class="grid grid-cols-2 gap-2">
458
+ <div>
459
+ <span class="block mb-1">X轴</span>
460
+ <input type="range" id="offsetX-${id}" min="-100" max="100" value="${data.offsetX}" class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer">
461
+ </div>
462
+ <div>
463
+ <span class="block mb-1">Y轴</span>
464
+ <input type="range" id="offsetY-${id}" min="-100" max="100" value="${data.offsetY}" class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer">
465
+ </div>
466
+ </div>
467
+ </div>
468
+ </details>
469
+ </div>
470
+ <div class="p-4 bg-gray-50 border-t border-gray-100">
471
+ <button onclick="exportSingle('${id}')" class="w-full flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
472
+ <svg class="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
473
+ 导出此图
474
+ </button>
475
+ </div>
476
+ `;
477
+
478
+ previewContainer.appendChild(card);
479
+ const canvas = document.getElementById(`canvas-${id}`);
480
+
481
+ // Event Listeners
482
+ ['cinema', 'movie', 'time', 'tickets', 'extra1', 'extra2'].forEach(key => {
483
+ document.getElementById(`${key}-${id}`).addEventListener('input', (e) => {
484
+ data[key] = e.target.value;
485
+ requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
486
+ });
487
+ });
488
+
489
+ document.getElementById(`size-${id}`).addEventListener('input', (e) => {
490
+ data.sizeScale = parseFloat(e.target.value);
491
+ requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
492
+ });
493
+ document.getElementById(`color-${id}`).addEventListener('input', (e) => {
494
+ data.color = e.target.value;
495
+ requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
496
+ });
497
+ document.getElementById(`offsetX-${id}`).addEventListener('input', (e) => {
498
+ data.offsetX = parseInt(e.target.value);
499
+ requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
500
+ });
501
+ document.getElementById(`offsetY-${id}`).addEventListener('input', (e) => {
502
+ data.offsetY = parseInt(e.target.value);
503
+ requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
504
+ });
505
+
506
+ const item = { id, filename, canvas, thumbCanvas, file, data, originalWidth, originalHeight };
507
+ imageItems.push(item);
508
+ drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight);
509
+ }
510
+
511
+ function removeCard(id) {
512
+ const card = document.getElementById(`card-${id}`);
513
+ if(card) card.remove();
514
+ imageItems = imageItems.filter(item => item.id !== id);
515
+ if(imageItems.length === 0) {
516
+ topActions.style.display = 'none';
517
+ emptyState.style.display = 'block';
518
+ }
519
+ }
520
 
521
+ // Draw Watermark on Thumbnail for Preview
522
+ function drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight) {
523
+ const ctx = canvas.getContext('2d');
524
+ canvas.width = thumbCanvas.width;
525
+ canvas.height = thumbCanvas.height;
526
+
527
+ ctx.drawImage(thumbCanvas, 0, 0);
528
+
529
+ // Scale calculations based on original dimensions so preview matches export
530
+ const ratio = thumbCanvas.width / originalWidth;
531
+
532
+ const baseFontSize = Math.max(30, Math.floor(originalHeight * 0.03));
533
+ const originalFontSize = baseFontSize * (data.sizeScale || 1.0);
534
+ const fontSize = originalFontSize * ratio;
535
+
536
+ ctx.font = `bold ${fontSize}px sans-serif`;
537
+ ctx.textAlign = 'center';
538
+ ctx.fillStyle = data.color || '#ffffff';
539
+ ctx.strokeStyle = '#000000';
540
+ ctx.lineWidth = Math.max(2, Math.floor(fontSize / 15));
541
+
542
+ const movieTitle = data.movie ? `《${data.movie}》` : '';
543
+ const lines = [
544
+ data.cinema,
545
+ movieTitle,
546
+ data.time,
547
+ data.tickets ? `共出票${data.tickets}张` : '',
548
+ data.extra1,
549
+ data.extra2
550
+ ];
551
+
552
+ const validLines = lines.filter(line => line && line.trim() !== '');
553
+
554
+ const originalXOffset = (data.offsetX || 0) * (originalWidth / 1000);
555
+ const originalYOffset = (data.offsetY || 0) * (originalHeight / 1000);
556
+
557
+ const xOffset = originalXOffset * ratio;
558
+ const yOffset = originalYOffset * ratio;
559
+
560
+ const lineHeight = fontSize * 1.5;
561
+ const totalTextHeight = validLines.length * lineHeight;
562
+ const startY = canvas.height - totalTextHeight - (canvas.height * 0.05) + yOffset;
563
+ const x = (canvas.width / 2) + xOffset;
564
+
565
+ validLines.forEach((line, index) => {
566
+ const y = startY + (index * lineHeight) + fontSize;
567
+ ctx.strokeText(line, x, y);
568
+ ctx.fillText(line, x, y);
569
+ });
570
+ }
571
 
572
+ function exportSingle(id) {
573
+ const item = imageItems.find(i => i.id === id);
574
+ if (!item) return;
575
+
576
+ const data = item.data;
577
+ const sanitize = (str) => (str || '').replace(/[\\/:*?"<>|]/g, '_');
578
+ const cinema = sanitize(data.cinema);
579
+ const movie = sanitize(data.movie);
580
+ let formattedTime = data.time.replace(/\s+/g, '_').replace(/:/g, '-');
581
+ const tickets = sanitize(data.tickets);
582
+
583
+ const newName = `${cinema}_${movie}_${formattedTime}_共出票${tickets}张.jpg`;
584
+
585
+ // Dispatch to worker
586
+ watermarkWorker.postMessage({
587
+ type: 'EXPORT_SINGLE',
588
+ payload: { item: { file: item.file, data: item.data }, filename: newName }
589
+ });
590
+ }
591
 
592
+ function exportAll() {
593
+ if (imageItems.length === 0) return;
594
+ showLoading('批量导出中', '准备导出...');
595
+
596
+ const itemsPayload = imageItems.map(item => ({
597
+ file: item.file,
598
+ data: item.data
599
+ }));
600
+
601
+ watermarkWorker.postMessage({
602
+ type: 'EXPORT_ALL_SINGLE',
603
+ payload: { items: itemsPayload }
604
+ });
605
+ }
606
 
607
+ function exportAllZip() {
608
+ if (imageItems.length === 0) return;
609
+
610
+ const cinema = globalControls.cinema.value || '影城';
611
+ const movie = globalControls.movie.value || '影片';
612
+ const safeCinema = cinema.replace(/[\\/:*?"<>|]/g, '_');
613
+ const safeMovie = movie.replace(/[\\/:*?"<>|]/g, '_');
614
+ const zipName = `${safeCinema}_${safeMovie}出票返图.zip`;
615
+
616
+ showLoading('正在打包 ZIP', '初始化...');
617
+
618
+ const itemsPayload = imageItems.map(item => ({
619
+ file: item.file,
620
+ data: item.data
621
+ }));
622
+
623
+ watermarkWorker.postMessage({
624
+ type: 'EXPORT_ZIP',
625
+ payload: { items: itemsPayload, zipName }
626
+ });
627
+ }
628
+ </script>
629
  </body>
630
+ </html>
manifest.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "图片水印批量生成工具",
3
+ "short_name": "水印助手",
4
+ "start_url": "index.html",
5
+ "display": "standalone",
6
+ "background_color": "#ffffff",
7
+ "theme_color": "#ffffff",
8
+ "orientation": "any",
9
+ "icons": [
10
+ {
11
+ "src": "icon.svg",
12
+ "sizes": "any",
13
+ "type": "image/svg+xml"
14
+ },
15
+ {
16
+ "src": "icon.svg",
17
+ "sizes": "192x192",
18
+ "type": "image/svg+xml"
19
+ },
20
+ {
21
+ "src": "icon.svg",
22
+ "sizes": "512x512",
23
+ "type": "image/svg+xml"
24
+ }
25
+ ]
26
+ }
sw.js CHANGED
@@ -1,47 +1,54 @@
1
- const CACHE_NAME = 'shangpin-pwa-v1';
2
- const CORE_ASSETS = [
3
- './',
4
- './index.html',
5
- './manifest.webmanifest',
6
- './icons/icon.svg'
 
 
 
7
  ];
8
 
9
  self.addEventListener('install', (event) => {
10
- event.waitUntil(
11
- caches.open(CACHE_NAME).then((cache) => cache.addAll(CORE_ASSETS))
12
- );
13
- self.skipWaiting();
 
14
  });
15
 
16
  self.addEventListener('activate', (event) => {
17
- event.waitUntil(
18
- caches.keys().then((keys) =>
19
- Promise.all(
20
- keys
21
- .filter((key) => key !== CACHE_NAME)
22
- .map((key) => caches.delete(key))
23
- )
24
- )
25
- );
26
- self.clients.claim();
 
27
  });
28
 
29
  self.addEventListener('fetch', (event) => {
30
- const { request } = event;
31
- if (request.method !== 'GET') return;
32
-
33
- event.respondWith(
34
- caches.match(request).then((cached) => {
35
- if (cached) return cached;
36
- return fetch(request)
37
- .then((networkResponse) => {
38
- if (request.url.startsWith(self.location.origin) && networkResponse && networkResponse.status === 200) {
39
- const responseClone = networkResponse.clone();
40
- caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone));
41
- }
42
- return networkResponse;
 
 
 
 
43
  })
44
- .catch(() => caches.match('./index.html'));
45
- })
46
- );
47
- });
 
1
+ const CACHE_NAME = 'watermark-app-v2';
2
+ const ASSETS_TO_CACHE = [
3
+ './index.html',
4
+ './manifest.json',
5
+ './icon.svg',
6
+ 'https://cdn.tailwindcss.com',
7
+ 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
8
+ 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js',
9
+ 'https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js'
10
  ];
11
 
12
  self.addEventListener('install', (event) => {
13
+ event.waitUntil(
14
+ caches.open(CACHE_NAME).then((cache) => {
15
+ return cache.addAll(ASSETS_TO_CACHE);
16
+ })
17
+ );
18
  });
19
 
20
  self.addEventListener('activate', (event) => {
21
+ event.waitUntil(
22
+ caches.keys().then((cacheNames) => {
23
+ return Promise.all(
24
+ cacheNames.map((cacheName) => {
25
+ if (cacheName !== CACHE_NAME) {
26
+ return caches.delete(cacheName);
27
+ }
28
+ })
29
+ );
30
+ })
31
+ );
32
  });
33
 
34
  self.addEventListener('fetch', (event) => {
35
+ event.respondWith(
36
+ caches.match(event.request).then((response) => {
37
+ // Return cached response if found
38
+ if (response) {
39
+ return response;
40
+ }
41
+ // Otherwise, fetch from network
42
+ return fetch(event.request).then((networkResponse) => {
43
+ // Optionally cache new requests dynamically
44
+ // if (event.request.url.startsWith('http')) {
45
+ // const responseToCache = networkResponse.clone();
46
+ // caches.open(CACHE_NAME).then((cache) => {
47
+ // cache.put(event.request, responseToCache);
48
+ // });
49
+ // }
50
+ return networkResponse;
51
+ });
52
  })
53
+ );
54
+ });
 
 
worker.js ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ importScripts('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js');
2
+
3
+ self.onmessage = async function(e) {
4
+ const { type, payload } = e.data;
5
+
6
+ if (type === 'EXPORT_ZIP') {
7
+ const { items, zipName } = payload;
8
+
9
+ try {
10
+ const zip = new JSZip();
11
+ const usedNames = new Set();
12
+
13
+ for (let i = 0; i < items.length; i++) {
14
+ const item = items[i];
15
+
16
+ self.postMessage({ type: 'PROGRESS', text: `处理图片 ${i + 1}/${items.length}`, percent: (i / items.length) * 50 });
17
+
18
+ // Decode image efficiently
19
+ const bmp = await createImageBitmap(item.file);
20
+
21
+ const canvas = new OffscreenCanvas(bmp.width, bmp.height);
22
+ const ctx = canvas.getContext('2d');
23
+
24
+ ctx.drawImage(bmp, 0, 0);
25
+
26
+ // Draw Watermark
27
+ drawWatermarkWorker(ctx, canvas.width, canvas.height, item.data);
28
+
29
+ // Convert to Blob
30
+ const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
31
+ bmp.close(); // Free memory
32
+
33
+ // Prepare filename
34
+ const data = item.data;
35
+ const sanitize = (str) => (str || '').replace(/[\\/:*?"<>|]/g, '_');
36
+
37
+ const cinema = sanitize(data.cinema);
38
+ const movie = sanitize(data.movie);
39
+ let formattedTime = data.time.replace(/\s+/g, '_').replace(/:/g, '-');
40
+ const tickets = sanitize(data.tickets);
41
+
42
+ const baseName = `${cinema}_${movie}_${formattedTime}_共出票${tickets}张`;
43
+
44
+ let finalName = baseName;
45
+ let counter = 1;
46
+ while (usedNames.has(finalName)) {
47
+ counter++;
48
+ finalName = `${baseName}_${counter}`;
49
+ }
50
+ usedNames.add(finalName);
51
+
52
+ zip.file(`${finalName}.jpg`, blob);
53
+ }
54
+
55
+ self.postMessage({ type: 'PROGRESS', text: '正在生成 ZIP 文件...', percent: 50 });
56
+
57
+ const content = await zip.generateAsync({
58
+ type: "blob",
59
+ compression: "STORE" // Faster than DEFLATE, fine for JPEGs
60
+ }, (metadata) => {
61
+ self.postMessage({
62
+ type: 'PROGRESS',
63
+ text: `正在压缩... ${metadata.percent.toFixed(0)}%`,
64
+ percent: 50 + (metadata.percent * 0.5)
65
+ });
66
+ });
67
+
68
+ self.postMessage({ type: 'DONE', blob: content, filename: zipName });
69
+
70
+ } catch (error) {
71
+ self.postMessage({ type: 'ERROR', error: error.message });
72
+ }
73
+ } else if (type === 'EXPORT_SINGLE') {
74
+ const { item, filename } = payload;
75
+ try {
76
+ self.postMessage({ type: 'PROGRESS', text: '处理中...', percent: 50 });
77
+ const bmp = await createImageBitmap(item.file);
78
+ const canvas = new OffscreenCanvas(bmp.width, bmp.height);
79
+ const ctx = canvas.getContext('2d');
80
+
81
+ ctx.drawImage(bmp, 0, 0);
82
+ drawWatermarkWorker(ctx, canvas.width, canvas.height, item.data);
83
+
84
+ const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
85
+ bmp.close();
86
+
87
+ self.postMessage({ type: 'DONE_SINGLE', blob, filename });
88
+ } catch(error) {
89
+ self.postMessage({ type: 'ERROR', error: error.message });
90
+ }
91
+ } else if (type === 'EXPORT_ALL_SINGLE') {
92
+ const { items } = payload;
93
+ try {
94
+ for (let i = 0; i < items.length; i++) {
95
+ const item = items[i];
96
+ self.postMessage({ type: 'PROGRESS', text: `导出图片 ${i + 1}/${items.length}`, percent: (i / items.length) * 100 });
97
+
98
+ const bmp = await createImageBitmap(item.file);
99
+ const canvas = new OffscreenCanvas(bmp.width, bmp.height);
100
+ const ctx = canvas.getContext('2d');
101
+
102
+ ctx.drawImage(bmp, 0, 0);
103
+ drawWatermarkWorker(ctx, canvas.width, canvas.height, item.data);
104
+
105
+ const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
106
+ bmp.close();
107
+
108
+ const data = item.data;
109
+ const sanitize = (str) => (str || '').replace(/[\\/:*?"<>|]/g, '_');
110
+ const cinema = sanitize(data.cinema);
111
+ const movie = sanitize(data.movie);
112
+ let formattedTime = data.time.replace(/\s+/g, '_').replace(/:/g, '-');
113
+ const tickets = sanitize(data.tickets);
114
+ const newName = `${cinema}_${movie}_${formattedTime}_共出票${tickets}张_${i+1}.jpg`;
115
+
116
+ self.postMessage({ type: 'DONE_SINGLE', blob, filename: newName });
117
+ }
118
+ self.postMessage({ type: 'DONE_ALL_SINGLE' });
119
+ } catch(error) {
120
+ self.postMessage({ type: 'ERROR', error: error.message });
121
+ }
122
+ }
123
+ };
124
+
125
+ function drawWatermarkWorker(ctx, width, height, data) {
126
+ const baseFontSize = Math.max(30, Math.floor(height * 0.03));
127
+ const fontSize = baseFontSize * (data.sizeScale || 1.0);
128
+
129
+ ctx.font = `bold ${fontSize}px sans-serif`;
130
+ ctx.textAlign = 'center';
131
+ ctx.fillStyle = data.color || '#ffffff';
132
+ ctx.strokeStyle = '#000000';
133
+ ctx.lineWidth = Math.max(2, Math.floor(fontSize / 15));
134
+
135
+ const movieTitle = data.movie ? `《${data.movie}》` : '';
136
+ const lines = [
137
+ data.cinema,
138
+ movieTitle,
139
+ data.time,
140
+ data.tickets ? `共出票${data.tickets}张` : '',
141
+ data.extra1,
142
+ data.extra2
143
+ ];
144
+
145
+ const validLines = lines.filter(line => line && line.trim() !== '');
146
+
147
+ const xOffset = (data.offsetX || 0) * (width / 1000);
148
+ const yOffset = (data.offsetY || 0) * (height / 1000);
149
+
150
+ const lineHeight = fontSize * 1.5;
151
+ const totalTextHeight = validLines.length * lineHeight;
152
+ const startY = height - totalTextHeight - (height * 0.05) + yOffset;
153
+ const x = (width / 2) + xOffset;
154
+
155
+ for (let index = 0; index < validLines.length; index++) {
156
+ const line = validLines[index];
157
+ const y = startY + (index * lineHeight) + fontSize;
158
+ ctx.strokeText(line, x, y);
159
+ ctx.fillText(line, x, y);
160
+ }
161
+ }