dsh-niuma 0.1.1 → 0.2.1

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.
@@ -5,9 +5,16 @@ import {
5
5
  earnedToday,
6
6
  formatDuration,
7
7
  formatMoney,
8
+ monthSummary,
8
9
  normalizeConfig,
10
+ yearSummary,
9
11
  } from '../core.mjs';
10
- import { HOLIDAY_SOURCE } from '../holidays.mjs';
12
+ import {
13
+ HOLIDAY_BLOCKS,
14
+ HOLIDAY_BLOCKS_2027_ESTIMATED,
15
+ HOLIDAY_ESTIMATE_2027,
16
+ HOLIDAY_SOURCE,
17
+ } from '../holidays.mjs';
11
18
 
12
19
  /**
13
20
  * dsh-niuma 浏览器半侧:DSH 侧栏「牛马」入口 + 中栏工资实时计数器面板。
@@ -58,6 +65,17 @@ const CARD = {
58
65
  /** 数字:等宽数位 + 不换行(每秒跳动时宽度不抖)。 */
59
66
  const NUM = { fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' };
60
67
 
68
+ /** 金色金额:渐变金属质感 + 单色回退(不支持 background-clip:text 时显示金色)。 */
69
+ const GOLD = {
70
+ color: '#D9A441',
71
+ backgroundImage: 'linear-gradient(180deg, #FFE9A8 0%, #F2C14E 40%, #D79A2B 72%, #B87A16 100%)',
72
+ WebkitBackgroundClip: 'text',
73
+ backgroundClip: 'text',
74
+ WebkitTextFillColor: 'transparent',
75
+ textShadow: '0 1px 0 rgba(0,0,0,.05)',
76
+ };
77
+ const GOLD_TINT = '#E7B84D';
78
+
61
79
  /** 状态徽标配色:颜色与语义一致,深浅色都靠主题变量。 */
62
80
  const STATUS_META = {
63
81
  rest: { key: 'statusRest', color: C.label3 },
@@ -98,28 +116,76 @@ const zh = {
98
116
  'paidHoursPerDay': '日有效工时',
99
117
  'todayTarget': '今日目标',
100
118
  'monthEarned': '本月已赚',
101
- 'holidaySource': '节假日来源',
119
+ 'monthWorkedDays': '本月已工作',
120
+ 'monthRemainingDays': '剩余工作日',
102
121
  'holidaySourceLink': '国务院办公厅 2026 年节假日安排',
103
122
  'configLoading': '正在读取设置…',
104
123
  'configFailed': '设置读取失败,使用默认值',
105
124
  'retry': '重试',
106
125
  'settings': '设置',
107
126
  'settingsHint': '改完点「保存」生效;格式不对会在下方红字提示,不影响计时。',
108
- 'salaryField': '实收月薪(元/月)',
127
+ 'salaryField': '实收月薪',
128
+ 'groupSalary': '工资',
129
+ 'groupWorkTime': '工作时间',
130
+ 'groupLunch': '午休',
131
+ 'salaryUnit': '元 / 月',
109
132
  'workStartField': '上班时间',
110
133
  'workEndField': '下班时间',
111
134
  'lunchEnabledField': '启用午休',
135
+ 'lunchEnabledShort': '启用',
112
136
  'lunchStartField': '午休开始',
113
137
  'lunchEndField': '午休结束',
114
138
  'lunchPaidField': '午休算工时',
115
- 'holidaysField': '节假日(一行一个 YYYY-MM-DD)',
116
- 'extraWorkdaysField': '调休上班日(一行一个 YYYY-MM-DD)',
139
+ 'lunchPaidShort': '算工时',
140
+ 'holidaysCardTitle': '节假日安排',
141
+ 'holidays2026Label': '2026 年(官方)',
142
+ 'holidays2027Label': '2027 年(预估)',
143
+ 'holidayEstimateTag': '预估',
144
+ 'holidayEstimateNote': '官方尚未公布,以上为按规则与惯例推算,仅供参考,以国务院通知为准。',
145
+ 'holidayEstimateShort': '2027 为预估值',
146
+ 'settingsDataNote': '节假日与调休数据见上方「节假日安排」卡片(2027 年为预估值,不可在本页编辑)。',
147
+
148
+ 'holidaysCardSub': '法定假日 {days} 天 · 调休上班 {extra} 天',
149
+ 'holidaysDaysShort': '{days} 天',
150
+ 'holidaysExtraNote': '调休上班日 {extra} 天(周末补班,按工作日计)',
151
+ 'holidayNewYear': '元旦',
152
+ 'holidaySpring': '春节',
153
+ 'holidayQingming': '清明',
154
+ 'holidayLabour': '劳动节',
155
+ 'holidayDragon': '端午',
156
+ 'holidayMidAutumn': '中秋',
157
+ 'holidayNational': '国庆',
117
158
  'save': '保存',
118
159
  'saving': '保存中…',
119
160
  'saved': '已保存',
120
161
  'saveFailed': '保存失败:',
121
162
  'dayUnit': '天',
122
163
  'hourUnit': '小时',
164
+ 'minuteUnit': '分钟',
165
+ 'viewLabel': '查看',
166
+ 'viewCurrent': '本月',
167
+ 'viewYear': '全年平均',
168
+ 'monthUnit': '月',
169
+ 'holidayShort': '假',
170
+ 'currentHint': '按每月 {days} 个工作日、每天 {hours} 小时计',
171
+ 'monthHint': '{year} 年 {month} 月 · 工作日 {workdays} 天 · 法定假日 {holidays} 天',
172
+ 'yearHint': '{year} 全年 · {workdays} 个工作日 · 按年收入折算',
173
+ 'viewOnlyHint': '仅换算,不影响今日计数器',
174
+ 'yearWorkdays': '全年工作日',
175
+ 'yearIncome': '全年收入',
176
+ 'averageDaily': '平均日薪',
177
+ 'averageHourly': '平均时薪',
178
+ 'averageMinute': '平均分薪',
179
+ 'averageSecond': '平均秒薪',
180
+ 'monthWorkdaysCard': '工作日',
181
+ 'maxHolidayBadge': '全年假日最多',
182
+ 'maxHolidayNote': '全年假日最多:{month} 月(法定假日 {days} 天)',
183
+ 'maxHolidayThis': '全年假日最多的月份(法定假日 {days} 天)',
184
+ 'sectionRates': '工资换算',
185
+ 'sectionRatesHint': '按每月 {days} 个工作日、每天 {hours} 小时计',
186
+ 'sectionDetail': '今日口径明细',
187
+ 'restHint': '今天休息,好好躺平',
188
+ 'liveNow': '实时',
123
189
  };
124
190
 
125
191
  const en = {
@@ -150,28 +216,76 @@ const en = {
150
216
  'paidHoursPerDay': 'Paid hours / day',
151
217
  'todayTarget': "Today's target",
152
218
  'monthEarned': 'Earned this month',
153
- 'holidaySource': 'Holiday data',
219
+ 'monthWorkedDays': 'Worked this month',
220
+ 'monthRemainingDays': 'Workdays left',
154
221
  'holidaySourceLink': 'State Council 2026 holiday schedule',
155
222
  'configLoading': 'Loading settings…',
156
223
  'configFailed': 'Failed to load settings, using defaults',
157
224
  'retry': 'Retry',
158
225
  'settings': 'Settings',
159
226
  'settingsHint': 'Click Save to apply. Invalid input is shown in red below; the ticker keeps running.',
160
- 'salaryField': 'Net monthly salary (CNY)',
227
+ 'salaryField': 'Net monthly salary',
228
+ 'groupSalary': 'Salary',
229
+ 'groupWorkTime': 'Working hours',
230
+ 'groupLunch': 'Lunch break',
231
+ 'salaryUnit': 'CNY / month',
161
232
  'workStartField': 'Work start',
162
233
  'workEndField': 'Work end',
163
234
  'lunchEnabledField': 'Enable lunch break',
235
+ 'lunchEnabledShort': 'On',
164
236
  'lunchStartField': 'Lunch start',
165
237
  'lunchEndField': 'Lunch end',
166
238
  'lunchPaidField': 'Lunch is paid',
167
- 'holidaysField': 'Holidays (one YYYY-MM-DD per line)',
168
- 'extraWorkdaysField': 'Make-up workdays (one YYYY-MM-DD per line)',
239
+ 'lunchPaidShort': 'Paid',
240
+ 'holidaysCardTitle': 'Holiday calendar',
241
+ 'holidays2026Label': '2026 (official)',
242
+ 'holidays2027Label': '2027 (estimated)',
243
+ 'holidayEstimateTag': 'estimate',
244
+ 'holidayEstimateNote': 'Not announced officially yet — estimated from the rules and past practice, for reference only.',
245
+ 'holidayEstimateShort': '2027 is estimated',
246
+ 'settingsDataNote': 'Holiday data lives in the calendar card above (2027 values are estimates and not editable here).',
247
+
248
+ 'holidaysCardSub': '{days} statutory days · {extra} make-up workdays',
249
+ 'holidaysDaysShort': '{days} d',
250
+ 'holidaysExtraNote': '{extra} make-up workdays (weekend shifts, counted as workdays)',
251
+ 'holidayNewYear': "New Year's Day",
252
+ 'holidaySpring': 'Spring Festival',
253
+ 'holidayQingming': 'Qingming',
254
+ 'holidayLabour': 'Labour Day',
255
+ 'holidayDragon': 'Dragon Boat',
256
+ 'holidayMidAutumn': 'Mid-Autumn',
257
+ 'holidayNational': 'National Day',
169
258
  'save': 'Save',
170
259
  'saving': 'Saving…',
171
260
  'saved': 'Saved',
172
261
  'saveFailed': 'Save failed: ',
173
262
  'dayUnit': 'd',
174
263
  'hourUnit': 'h',
264
+ 'minuteUnit': 'min',
265
+ 'viewLabel': 'View',
266
+ 'viewCurrent': 'This month',
267
+ 'viewYear': 'Year average',
268
+ 'monthUnit': 'M',
269
+ 'holidayShort': 'hol.',
270
+ 'currentHint': '{days} workdays / month · {hours} h / day',
271
+ 'monthHint': '{year}-{month} · {workdays} workdays · {holidays} statutory holidays',
272
+ 'yearHint': '{year} · {workdays} workdays · averaged over the year',
273
+ 'viewOnlyHint': 'Conversion only — today\'s ticker is unaffected',
274
+ 'yearWorkdays': 'Workdays / year',
275
+ 'yearIncome': 'Annual income',
276
+ 'averageDaily': 'Avg per day',
277
+ 'averageHourly': 'Avg per hour',
278
+ 'averageMinute': 'Avg per minute',
279
+ 'averageSecond': 'Avg per second',
280
+ 'monthWorkdaysCard': 'Workdays',
281
+ 'maxHolidayBadge': 'Most holidays',
282
+ 'maxHolidayNote': 'Most holidays: month {month} ({days} statutory days)',
283
+ 'maxHolidayThis': 'The month with the most holidays ({days} statutory days)',
284
+ 'sectionRates': 'Pay rates',
285
+ 'sectionRatesHint': '{days} workdays / month · {hours} h / day',
286
+ 'sectionDetail': "Today's basis",
287
+ 'restHint': 'Day off — enjoy it',
288
+ 'liveNow': 'LIVE',
175
289
  };
176
290
 
177
291
  /** 组件在没有 locale 注入 t 时(测试 / 兜底)直接读中文表,绝不白屏。 */
@@ -180,7 +294,7 @@ const fallbackTranslate = (key) => (zh[key] != null ? zh[key] : key);
180
294
  /* ------------------------------------------------------------------ *
181
295
  * 格式化小工具
182
296
  * ------------------------------------------------------------------ */
183
- const money = (value, digits = 2) => `¥${formatMoney(value, digits)}`;
297
+ const money = (value, digits = 2) => `${formatMoney(value, digits)} 元`;
184
298
 
185
299
  /** 8.00 -> '8',8.50 -> '8.5'(工时展示用) */
186
300
  function trimNumber(value, digits = 2) {
@@ -188,13 +302,6 @@ function trimNumber(value, digits = 2) {
188
302
  return fixed.replace(/\.?0+$/, '') || '0';
189
303
  }
190
304
 
191
- function splitDateLines(text) {
192
- return String(text == null ? '' : text)
193
- .split(/\r?\n/)
194
- .map((line) => line.trim())
195
- .filter((line) => line.length > 0);
196
- }
197
-
198
305
  /** 配置 -> 设置表单草稿(所有输入框都是字符串 / 布尔,编辑中不打断计时)。 */
199
306
  function formFromConfig(config) {
200
307
  return {
@@ -205,8 +312,6 @@ function formFromConfig(config) {
205
312
  lunchStart: String(config.lunchStart == null ? '' : config.lunchStart),
206
313
  lunchEnd: String(config.lunchEnd == null ? '' : config.lunchEnd),
207
314
  lunchPaid: config.lunchPaid === true,
208
- holidaysText: (config.holidays || []).join('\n'),
209
- extraWorkdaysText: (config.extraWorkdays || []).join('\n'),
210
315
  };
211
316
  }
212
317
 
@@ -221,8 +326,6 @@ function patchFromForm(form) {
221
326
  lunchStart: String(form.lunchStart || '').trim(),
222
327
  lunchEnd: String(form.lunchEnd || '').trim(),
223
328
  lunchPaid: Boolean(form.lunchPaid),
224
- holidays: splitDateLines(form.holidaysText),
225
- extraWorkdays: splitDateLines(form.extraWorkdaysText),
226
329
  };
227
330
  }
228
331
 
@@ -309,137 +412,218 @@ function NiumaIcon(props) {
309
412
  ]);
310
413
  }
311
414
 
415
+ /** 全局小样式:只放动效 keyframes(内联 style 写不了 keyframes)。组件内联样式仍是主体。 */
416
+ const PANEL_CSS = `
417
+ @keyframes niumaPulse { 0%,100% { opacity: .95; transform: scale(1); } 50% { opacity: .3; transform: scale(.68); } }
418
+ @media (prefers-reduced-motion: reduce) { .niuma-live-dot { animation: none !important; } }
419
+ `;
420
+
421
+ function StyleTag() {
422
+ return React.createElement('style', null, PANEL_CSS);
423
+ }
424
+
312
425
  /** 状态徽标:休息日 / 未上班 / 搬砖中 / 午休中 / 已下班。 */
313
426
  function StatusBadge(props) {
314
427
  const t = props.t;
315
428
  const meta = STATUS_META[props.status] || STATUS_META.rest;
429
+ const live = props.status === 'working';
316
430
  return React.createElement('span', {
317
431
  style: {
318
432
  display: 'inline-flex',
319
433
  alignItems: 'center',
320
434
  gap: 6,
321
- padding: '3px 10px',
435
+ padding: '4px 11px 4px 9px',
322
436
  borderRadius: 999,
323
437
  fontSize: 12,
324
438
  fontWeight: 600,
325
439
  lineHeight: 1.5,
326
440
  color: meta.color,
327
- border: `1px solid ${meta.color}`,
328
- background: C.layer1,
441
+ border: `1px solid color-mix(in srgb, ${meta.color} 42%, transparent)`,
442
+ background: `color-mix(in srgb, ${meta.color} 12%, transparent)`,
329
443
  whiteSpace: 'nowrap',
330
444
  },
331
445
  }, [
332
446
  React.createElement('i', {
333
447
  key: 'dot',
448
+ className: live ? 'niuma-live-dot' : undefined,
334
449
  style: { width: 6, height: 6, borderRadius: '50%', background: meta.color, display: 'inline-block' },
335
450
  }),
336
451
  t(meta.key),
337
452
  ]);
338
453
  }
339
454
 
340
- /** 今日已赚:超大数字,每秒跳动。 */
455
+ /** 小节标题:左标题 + 右口径提示(同一行,窄屏自动换行)。 */
456
+ function SectionTitle(props) {
457
+ return React.createElement('div', {
458
+ style: {
459
+ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
460
+ gap: 8, flexWrap: 'wrap', margin: '2px 1px -1px',
461
+ },
462
+ }, [
463
+ React.createElement('span', {
464
+ key: 't',
465
+ style: { fontSize: 12.5, fontWeight: 700, color: C.label2, letterSpacing: '.2px' },
466
+ }, props.title),
467
+ props.hint
468
+ ? React.createElement('span', { key: 'h', style: { fontSize: 10.5, color: C.label3, ...NUM } }, props.hint)
469
+ : null,
470
+ ]);
471
+ }
472
+
473
+ /** 今日已赚主卡:渐变底 + 超大金额 + 进度条(视觉焦点只有这一处)。 */
341
474
  function HeroCard(props) {
342
475
  const t = props.t;
343
476
  const snap = props.snap;
344
- return React.createElement('div', { style: { ...CARD, padding: '13px 15px 12px' } }, [
477
+ const rates = snap.rates;
478
+ const progress = Math.max(0, Math.min(1, Number(snap.todayProgress) || 0));
479
+ const percentText = `${(progress * 100).toFixed(1)}%`;
480
+ const live = snap.status === 'working';
481
+ const rest = snap.status === 'rest';
482
+ const heroBackground = 'linear-gradient(135deg, '
483
+ + 'color-mix(in srgb, ' + C.brand + ' 17%, ' + C.layer1 + ') 0%, '
484
+ + C.layer1 + ' 58%, ' + C.layer1 + ' 100%)';
485
+ return React.createElement('div', {
486
+ style: {
487
+ position: 'relative',
488
+ overflow: 'hidden',
489
+ borderRadius: 18,
490
+ padding: '16px 18px 14px',
491
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 34%, ${C.border1})`,
492
+ background: heroBackground,
493
+ boxShadow: '0 12px 30px rgba(0,0,0,.10)',
494
+ boxSizing: 'border-box',
495
+ minWidth: 0,
496
+ },
497
+ }, [
345
498
  React.createElement('div', {
346
- key: 'head',
499
+ key: 'glow',
500
+ 'aria-hidden': 'true',
347
501
  style: {
348
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
349
- gap: 8, flexWrap: 'wrap', fontSize: 12, color: C.label2,
502
+ position: 'absolute', right: -52, top: -72, width: 188, height: 188, borderRadius: '50%',
503
+ background: `radial-gradient(circle, color-mix(in srgb, ${GOLD_TINT} 30%, transparent) 0%, transparent 68%)`,
504
+ pointerEvents: 'none',
350
505
  },
506
+ }),
507
+ React.createElement('div', {
508
+ key: 'head',
509
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' },
351
510
  }, [
352
- React.createElement('span', { key: 'a' }, t('earnedToday')),
511
+ React.createElement('span', {
512
+ key: 'a',
513
+ style: { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12.5, fontWeight: 600, color: C.label2 },
514
+ }, [
515
+ live
516
+ ? React.createElement('i', {
517
+ key: 'dot',
518
+ className: 'niuma-live-dot',
519
+ style: { width: 7, height: 7, borderRadius: '50%', background: C.ok, display: 'inline-block' },
520
+ })
521
+ : null,
522
+ t('earnedToday'),
523
+ ]),
353
524
  React.createElement('span', { key: 'b', style: { fontSize: 11, color: C.label3, ...NUM } },
354
525
  `${t('todayOn')} ${snap.dateKey}`),
355
526
  ]),
356
527
  React.createElement('div', {
357
528
  key: 'amt',
358
- style: { display: 'flex', alignItems: 'baseline', gap: 6, marginTop: 1, flexWrap: 'wrap' },
529
+ style: { display: 'flex', alignItems: 'baseline', gap: 7, marginTop: 4, flexWrap: 'wrap' },
359
530
  }, [
360
- React.createElement('span', {
361
- key: 'cur',
362
- style: { fontSize: 22, fontWeight: 700, color: C.label2, ...NUM },
363
- }, '¥'),
364
531
  React.createElement('span', {
365
532
  key: 'val',
366
533
  style: {
367
- fontSize: 40, lineHeight: 1.15, fontWeight: 760, letterSpacing: '-.5px',
368
- color: C.ok, ...NUM,
534
+ fontSize: 46, lineHeight: 1.12, fontWeight: 800, letterSpacing: '-1.2px',
535
+ ...GOLD,
536
+ ...NUM,
537
+ ...(rest ? { opacity: 0.62 } : {}),
369
538
  },
370
539
  }, formatMoney(snap.todayEarned, 4)),
540
+ React.createElement('span', {
541
+ key: 'unit',
542
+ style: { fontSize: 18, fontWeight: 700, color: C.label2, ...NUM },
543
+ }, '元'),
371
544
  ]),
372
545
  React.createElement('div', {
373
- key: 'sub',
374
- style: { fontSize: 11, color: C.label3, marginTop: 2, ...NUM },
375
- }, `${t('perSecond')} ${money(snap.rates.secondWage, 4)}`),
376
- ]);
377
- }
378
-
379
- /** 今日进度条 + 距上班 / 距下班倒计时。 */
380
- function ProgressCard(props) {
381
- const t = props.t;
382
- const snap = props.snap;
383
- const progress = Math.max(0, Math.min(1, Number(snap.todayProgress) || 0));
384
- const percentText = `${(progress * 100).toFixed(1)}%`;
385
- const countdownChip = (key, label, seconds) => React.createElement('div', {
386
- key,
387
- style: { ...CARD, flex: '1 1 130px', minWidth: 118, padding: '8px 10px' },
388
- }, [
389
- React.createElement('div', { key: 'l', style: { fontSize: 11, color: C.label3 } }, label),
390
- React.createElement('div', {
391
- key: 'v',
392
- style: { fontSize: 14, fontWeight: 650, color: C.label, marginTop: 2, ...NUM },
393
- }, Number(seconds) > 0 ? formatDuration(seconds) : '—'),
394
- ]);
395
- return React.createElement('div', { style: CARD }, [
396
- React.createElement('div', {
397
- key: 'top',
398
- style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' },
546
+ key: 'chips',
547
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 5, flexWrap: 'wrap' },
399
548
  }, [
400
- React.createElement('span', { key: 'l', style: { fontSize: 12, color: C.label2 } }, t('todayProgress')),
401
549
  React.createElement('span', {
402
- key: 'p',
403
- style: { fontSize: 13, fontWeight: 650, color: C.brand, ...NUM },
404
- }, percentText),
550
+ key: 'per',
551
+ style: {
552
+ display: 'inline-flex', alignItems: 'center', gap: 4,
553
+ fontSize: 11.5, fontWeight: 600, color: C.label2,
554
+ background: `color-mix(in srgb, ${C.brand} 11%, transparent)`,
555
+ border: `1px solid color-mix(in srgb, ${C.brand} 22%, transparent)`,
556
+ borderRadius: 999, padding: '2px 9px', ...NUM,
557
+ },
558
+ }, `${t('perSecond')} +${money(rates.secondWage, 4)}`),
559
+ React.createElement('span', { key: 'target', style: { fontSize: 11, color: C.label3, ...NUM } },
560
+ rest ? t('restHint') : `${t('todayTarget')} ${money(snap.todayTarget, 2)}`),
561
+ ]),
562
+ React.createElement('div', {
563
+ key: 'progTop',
564
+ style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginTop: 13, flexWrap: 'wrap' },
565
+ }, [
566
+ React.createElement('span', { key: 'l', style: { fontSize: 11.5, color: C.label3 } }, t('todayProgress')),
567
+ React.createElement('span', { key: 'p', style: { fontSize: 12.5, fontWeight: 700, color: C.brand, ...NUM } }, percentText),
405
568
  ]),
406
569
  React.createElement('div', {
407
570
  key: 'bar',
408
- style: {
409
- marginTop: 6, height: 8, borderRadius: 999, background: C.layer2,
410
- overflow: 'hidden',
411
- },
571
+ style: { marginTop: 5, height: 9, borderRadius: 999, background: `color-mix(in srgb, ${C.label} 9%, transparent)`, overflow: 'hidden' },
412
572
  }, [
413
573
  React.createElement('div', {
414
574
  key: 'fill',
415
575
  style: {
416
- width: `${(progress * 100).toFixed(2)}%`,
417
- height: '100%',
418
- borderRadius: 999,
419
- background: C.brand,
420
- transition: 'width .4s linear',
576
+ width: `${(progress * 100).toFixed(2)}%`, height: '100%', borderRadius: 999,
577
+ background: `linear-gradient(90deg, ${C.brand} 0%, color-mix(in srgb, ${C.brand} 55%, ${C.ok}) 100%)`,
578
+ transition: 'width .35s linear',
421
579
  },
422
580
  }),
423
581
  ]),
424
582
  React.createElement('div', {
425
- key: 'nums',
426
- style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginTop: 5, flexWrap: 'wrap', fontSize: 11, color: C.label3 },
583
+ key: 'progNum',
584
+ style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginTop: 5, flexWrap: 'wrap', fontSize: 10.5, color: C.label3 },
427
585
  }, [
428
586
  React.createElement('span', { key: 'a' }, t('progressOfTarget')),
429
- React.createElement('span', { key: 'b', style: { ...NUM } },
587
+ React.createElement('span', { key: 'b', style: { color: C.label2, fontWeight: 600, ...NUM } },
430
588
  `${money(snap.todayEarned, 2)} / ${money(snap.todayTarget, 2)}`),
431
589
  ]),
432
- React.createElement('div', {
433
- key: 'cd',
434
- style: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 },
435
- }, [
436
- countdownChip('start', t('toStart'), snap.secondsToStart),
437
- countdownChip('end', t('toEnd'), snap.secondsToEnd),
438
- ]),
439
590
  ]);
440
591
  }
441
592
 
442
- /** 六宫格:秒 / 分 / 时 / 日 / 周 / 月工资。 */
593
+ /** 倒计时两张小卡:距上班 / 距下班。 */
594
+ function CountdownRow(props) {
595
+ const t = props.t;
596
+ const snap = props.snap;
597
+ const chip = (key, label, value, accent) => React.createElement('div', {
598
+ key,
599
+ style: {
600
+ flex: '1 1 120px', minWidth: 116,
601
+ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8,
602
+ padding: '9px 12px', borderRadius: 12,
603
+ background: accent ? `color-mix(in srgb, ${GOLD_TINT} 9%, ${C.layer1})` : C.layer1,
604
+ border: `1px solid ${accent ? `color-mix(in srgb, ${GOLD_TINT} 38%, ${C.border1})` : C.border1}`,
605
+ boxSizing: 'border-box',
606
+ },
607
+ }, [
608
+ React.createElement('span', {
609
+ key: 'l',
610
+ style: { fontSize: 11, color: accent ? C.label2 : C.label3, whiteSpace: 'nowrap' },
611
+ }, label),
612
+ React.createElement('span', {
613
+ key: 'v',
614
+ style: { fontSize: 13.5, fontWeight: 700, color: C.label, ...NUM },
615
+ }, value),
616
+ ]);
617
+ return React.createElement('div', {
618
+ style: { display: 'flex', flexWrap: 'wrap', gap: 9 },
619
+ }, [
620
+ chip('start', t('toStart'), Number(snap.secondsToStart) > 0 ? formatDuration(snap.secondsToStart) : '—'),
621
+ chip('end', t('toEnd'), Number(snap.secondsToEnd) > 0 ? formatDuration(snap.secondsToEnd) : '—'),
622
+ chip('month', t('monthEarned'), money(snap.monthEarned, 2), true),
623
+ ]);
624
+ }
625
+
626
+ /** 六宫格:秒 / 分 / 时 / 日 / 周 / 月工资;第一张「秒工资」用品牌色强调。 */
443
627
  const RATE_CARDS = [
444
628
  { key: 'secondWage', label: 'secondWage', digits: 4 },
445
629
  { key: 'minuteWage', label: 'minuteWage', digits: 4 },
@@ -449,68 +633,233 @@ const RATE_CARDS = [
449
633
  { key: 'monthlyWage', label: 'monthlyWage', digits: 2 },
450
634
  ];
451
635
 
636
+ /** 工资换算网格:由调用方给出 {key,label,value,accent} 卡片(本月 / 指定月 / 全年平均三种视角)。 */
452
637
  function RatesGrid(props) {
453
- const t = props.t;
454
- const rates = props.rates;
638
+ const cards = Array.isArray(props.cards) ? props.cards : [];
455
639
  return React.createElement('div', {
456
640
  style: { display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8 },
457
- }, RATE_CARDS.map((spec, index) => React.createElement('div', {
458
- key: spec.key,
459
- 'data-stat': `stat-${index}`,
460
- style: { ...CARD, padding: '9px 10px' },
641
+ }, cards.map((card, index) => {
642
+ const accent = card.accent === true;
643
+ return React.createElement('div', {
644
+ key: card.key,
645
+ 'data-stat': `stat-${index}`,
646
+ style: {
647
+ ...CARD,
648
+ padding: '10px 11px',
649
+ borderRadius: 12,
650
+ ...(accent ? {
651
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 46%, ${C.border1})`,
652
+ background: `color-mix(in srgb, ${GOLD_TINT} 9%, ${C.layer1})`,
653
+ } : {}),
654
+ },
655
+ }, [
656
+ React.createElement('div', {
657
+ key: 'l',
658
+ style: { fontSize: 11, color: accent ? C.label2 : C.label3, fontWeight: accent ? 600 : 400 },
659
+ }, card.label),
660
+ React.createElement('div', {
661
+ key: 'v',
662
+ style: {
663
+ fontSize: 15.5, fontWeight: 700, letterSpacing: '-.2px',
664
+ color: accent ? C.label : C.label, marginTop: 2, ...NUM,
665
+ },
666
+ }, card.value),
667
+ ]);
668
+ }));
669
+ }
670
+
671
+ /** 月份 / 全年视角选择器(原生 select,深浅色都用主题变量)。 */
672
+ function PeriodSelect(props) {
673
+ const t = props.t;
674
+ const year = props.year;
675
+ const months = props.months;
676
+ const style = {
677
+ appearance: 'none',
678
+ WebkitAppearance: 'none',
679
+ border: `1px solid ${C.border}`,
680
+ background: C.layer2,
681
+ color: C.label,
682
+ borderRadius: 8,
683
+ padding: '4px 9px',
684
+ fontSize: 12,
685
+ fontWeight: 600,
686
+ cursor: 'pointer',
687
+ outline: 'none',
688
+ maxWidth: 220,
689
+ };
690
+ return React.createElement('select', {
691
+ value: props.value,
692
+ 'aria-label': t('viewLabel'),
693
+ onChange: (event) => props.onChange(event.target.value),
694
+ style,
461
695
  }, [
462
- React.createElement('div', { key: 'l', style: { fontSize: 11, color: C.label3 } }, t(spec.label)),
463
- React.createElement('div', {
464
- key: 'v',
465
- style: { fontSize: 15, fontWeight: 650, color: C.label, marginTop: 2, ...NUM },
466
- }, money(rates[spec.key], spec.digits)),
467
- ])));
696
+ React.createElement('option', { key: 'current', value: 'current' }, t('viewCurrent')),
697
+ ...months.map((month) => React.createElement('option', {
698
+ key: `m${month.month}`,
699
+ value: `m${month.month}`,
700
+ }, `${month.month}${t('monthUnit')} · ${month.workdays}${t('dayUnit')} · ${t('holidayShort')}${month.holidayDays}`)),
701
+ React.createElement('option', { key: 'year', value: 'year' }, `${year} ${t('viewYear')}`),
702
+ ]);
468
703
  }
469
704
 
470
- /** 明细脚注:月薪 / 当月工作日 / 日有效工时 / 今日目标 / 本月已赚 + 节假日来源。 */
705
+ /** 口径明细:月薪 / 工作日 / 日工时 / 今日目标 / 本月已赚(节假日来源统一放在节假日卡片)。 */
471
706
  function DetailsFooter(props) {
472
707
  const t = props.t;
473
708
  const snap = props.snap;
474
709
  const rates = snap.rates;
475
- const source = props.holidaySource || HOLIDAY_SOURCE;
476
710
  const items = [
477
711
  { key: 'salary', label: t('monthlySalaryLabel'), value: money(rates.monthlySalary, 2) },
478
712
  { key: 'workdays', label: t('workdaysThisMonth'), value: `${rates.monthlyWorkdays}${t('dayUnit')}` },
479
713
  { key: 'hours', label: t('paidHoursPerDay'), value: `${trimNumber(rates.paidHours, 2)}${t('hourUnit')}` },
480
714
  { key: 'target', label: t('todayTarget'), value: money(snap.todayTarget, 2) },
481
715
  { key: 'month', label: t('monthEarned'), value: money(snap.monthEarned, 2) },
716
+ { key: 'worked', label: t('monthWorkedDays'), value: `${snap.monthWorkdaysDone}${t('dayUnit')}` },
717
+ { key: 'left', label: t('monthRemainingDays'), value: `${snap.monthWorkdaysLeft}${t('dayUnit')}` },
482
718
  ];
483
719
  return React.createElement('div', {
484
- style: { ...CARD, padding: '9px 12px', fontSize: 11, color: C.label3, lineHeight: 1.7 },
720
+ style: {
721
+ background: C.layer1, border: `1px solid ${C.border1}`, borderRadius: 12,
722
+ padding: '10px 12px', fontSize: 11, color: C.label3, lineHeight: 1.6,
723
+ },
485
724
  }, [
486
725
  React.createElement('div', {
487
726
  key: 'items',
488
- style: { display: 'flex', flexWrap: 'wrap', gap: '2px 14px' },
489
- }, items.map((item) => React.createElement('span', {
727
+ style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(118px, 1fr))', gap: '7px 12px' },
728
+ }, items.map((item) => React.createElement('div', {
490
729
  key: item.key,
491
- style: { display: 'inline-flex', alignItems: 'baseline', gap: 4, whiteSpace: 'nowrap' },
730
+ style: { minWidth: 0 },
492
731
  }, [
493
- React.createElement('span', { key: 'l' }, `${item.label}`),
494
- React.createElement('span', {
732
+ React.createElement('div', { key: 'l', style: { fontSize: 10.5, color: C.label3 } }, item.label),
733
+ React.createElement('div', {
495
734
  key: 'v',
496
- style: { color: C.label2, fontWeight: 600, ...NUM },
735
+ style: { fontSize: 13, fontWeight: 650, color: C.label2, ...NUM },
497
736
  }, item.value),
498
737
  ]))),
738
+ ]);
739
+ }
740
+
741
+ /** 节假日卡片:2026 官方 / 2027 预估两段,七段假日区段 + 调休上班日统计。 */
742
+ const HOLIDAY_LABEL_KEYS = {
743
+ newYear: 'holidayNewYear',
744
+ spring: 'holidaySpring',
745
+ qingming: 'holidayQingming',
746
+ labour: 'holidayLabour',
747
+ dragon: 'holidayDragon',
748
+ midAutumn: 'holidayMidAutumn',
749
+ national: 'holidayNational',
750
+ };
751
+
752
+ function HolidaysCard(props) {
753
+ const t = props.t;
754
+ const source = props.holidaySource || HOLIDAY_SOURCE;
755
+ const extra = Array.isArray(props.extraWorkdays) ? props.extraWorkdays : [];
756
+ const countYear = (year) => extra.filter((key) => String(key).startsWith(`${year}-`)).length;
757
+ const shortDate = (key) => key.slice(5).replace('-', '/');
758
+ const sections = [
759
+ {
760
+ key: 'official',
761
+ label: t('holidays2026Label'),
762
+ blocks: HOLIDAY_BLOCKS,
763
+ estimated: false,
764
+ extraCount: countYear(2026),
765
+ tag: null,
766
+ },
767
+ {
768
+ key: 'estimate',
769
+ label: t('holidays2027Label'),
770
+ blocks: HOLIDAY_BLOCKS_2027_ESTIMATED,
771
+ estimated: true,
772
+ extraCount: countYear(2027),
773
+ tag: t('holidayEstimateTag'),
774
+ },
775
+ ];
776
+ const sumDays = (blocks) => blocks.reduce((sum, block) => sum + block.days, 0);
777
+ const renderSection = (section) => React.createElement('div', { key: section.key, style: { marginTop: section.key === 'official' ? 8 : 12 } }, [
778
+ React.createElement('div', {
779
+ key: 'head',
780
+ style: { display: 'flex', alignItems: 'baseline', flexWrap: 'wrap', gap: 8 },
781
+ }, [
782
+ React.createElement('span', { key: 'l', style: { fontSize: 11.5, fontWeight: 700, color: C.label2 } }, section.label),
783
+ section.tag
784
+ ? React.createElement('span', {
785
+ key: 'tag',
786
+ style: {
787
+ fontSize: 9.5, fontWeight: 700, color: C.label2,
788
+ border: `1px dashed color-mix(in srgb, ${GOLD_TINT} 60%, transparent)`,
789
+ borderRadius: 999, padding: '0 6px', lineHeight: 1.6,
790
+ },
791
+ }, section.tag)
792
+ : null,
793
+ React.createElement('span', { key: 's', style: { marginLeft: 'auto', fontSize: 10, color: C.label3, ...NUM } },
794
+ t('holidaysCardSub').replace('{days}', String(sumDays(section.blocks))).replace('{extra}', String(section.extraCount))),
795
+ ]),
796
+ React.createElement('div', {
797
+ key: 'grid',
798
+ style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(118px, 1fr))', gap: 7, marginTop: 6 },
799
+ }, section.blocks.map((block) => React.createElement('div', {
800
+ key: block.id,
801
+ style: {
802
+ border: section.estimated
803
+ ? `1px dashed color-mix(in srgb, ${GOLD_TINT} 40%, ${C.border1})`
804
+ : `1px solid ${C.border1}`,
805
+ borderRadius: 9,
806
+ padding: '6px 8px',
807
+ background: `color-mix(in srgb, ${GOLD_TINT} ${section.estimated ? 3 : 6}%, transparent)`,
808
+ },
809
+ }, [
810
+ React.createElement('div', {
811
+ key: 'row',
812
+ style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 6 },
813
+ }, [
814
+ React.createElement('span', { key: 'n', style: { fontSize: 11.5, fontWeight: 650, color: C.label } },
815
+ t(HOLIDAY_LABEL_KEYS[block.id])),
816
+ React.createElement('span', {
817
+ key: 'd',
818
+ style: {
819
+ fontSize: 10, fontWeight: 700, color: C.label2,
820
+ background: `color-mix(in srgb, ${GOLD_TINT} 16%, transparent)`,
821
+ borderRadius: 999, padding: '0 6px', ...NUM,
822
+ },
823
+ }, t('holidaysDaysShort').replace('{days}', String(block.days))),
824
+ ]),
825
+ React.createElement('div', { key: 'r', style: { fontSize: 10, color: C.label3, marginTop: 2, ...NUM } },
826
+ `${shortDate(block.from)} – ${shortDate(block.to)}`),
827
+ ]))),
828
+ section.estimated
829
+ ? React.createElement('div', {
830
+ key: 'est-note',
831
+ style: { marginTop: 6, fontSize: 10.5, color: C.label3, lineHeight: 1.5 },
832
+ }, `⚠ ${t('holidayEstimateNote')}`)
833
+ : null,
834
+ ]);
835
+ return React.createElement('div', {
836
+ style: {
837
+ background: C.layer1,
838
+ border: `1px solid ${C.border1}`,
839
+ borderRadius: 12,
840
+ padding: '10px 12px',
841
+ },
842
+ }, [
499
843
  React.createElement('div', {
500
- key: 'src',
501
- style: { display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 4, marginTop: 2 },
844
+ key: 'title',
845
+ style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' },
502
846
  }, [
503
- React.createElement('span', { key: 'l' }, `${t('holidaySource')}:`),
847
+ React.createElement('span', { key: 't', style: { fontSize: 12.5, fontWeight: 700, color: C.label2, letterSpacing: '.2px' } },
848
+ t('holidaysCardTitle')),
504
849
  React.createElement('a', {
505
850
  key: 'a',
506
851
  href: source.url,
507
852
  target: '_blank',
508
853
  rel: 'noreferrer',
509
854
  title: source.title,
510
- style: { color: C.brand, textDecoration: 'underline' },
511
- }, `${source.year} · ${t('holidaySourceLink')} ↗`),
512
- React.createElement('span', { key: 'n', style: { color: C.label3 } }, `(${source.title})`),
855
+ style: { fontSize: 10.5, color: C.brand, textDecoration: 'none', fontWeight: 600 },
856
+ }, `${source.year} ${t('holidaySourceLink')} ↗`),
513
857
  ]),
858
+ sections.map(renderSection),
859
+ React.createElement('div', {
860
+ key: 'foot',
861
+ style: { marginTop: 8, fontSize: 10.5, color: C.label3 },
862
+ }, t('holidaysExtraNote').replace('{extra}', String(extra.length))),
514
863
  ]);
515
864
  }
516
865
 
@@ -525,102 +874,111 @@ function SettingsSection(props) {
525
874
  const save = props.save;
526
875
  const onSave = props.onSave;
527
876
 
877
+ // E 方案:行式短控件 —— 标签固定左列,控件都按内容定宽(不拉满)
878
+ const rowStyle = {
879
+ display: 'flex', alignItems: 'center', gap: 10,
880
+ minHeight: 33, padding: '2px 0', borderTop: `1px solid ${C.border1}`,
881
+ };
882
+ const rowKeyStyle = { width: 62, flex: '0 0 62px', fontSize: 11.5, color: C.label3 };
883
+ const rowValStyle = { display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap', minWidth: 0 };
528
884
  const inputStyle = {
529
- width: '100%',
530
- boxSizing: 'border-box',
531
- padding: '5px 8px',
532
- borderRadius: 8,
533
- border: `1px solid ${C.border}`,
534
- background: C.layer2,
535
- color: C.label,
536
- fontSize: 13,
537
- fontVariantNumeric: 'tabular-nums',
538
- outline: 'none',
885
+ boxSizing: 'border-box', width: 88, minHeight: 28, padding: '4px 8px',
886
+ borderRadius: 7, border: `1px solid ${C.border}`, background: C.layer2,
887
+ color: C.label, fontSize: 12.5, fontVariantNumeric: 'tabular-nums', outline: 'none',
539
888
  };
540
- const labelStyle = { fontSize: 11, color: C.label3 };
889
+ const selectStyle = {
890
+ boxSizing: 'border-box', width: 48, minHeight: 28, padding: '4px 2px 4px 6px',
891
+ borderRadius: 7, border: `1px solid ${C.border}`, background: C.layer2,
892
+ color: C.label, fontSize: 12.5, fontVariantNumeric: 'tabular-nums', outline: 'none', cursor: 'pointer',
893
+ };
894
+ const unitStyle = { fontSize: 10.5, color: C.label3, whiteSpace: 'nowrap' };
895
+ const dashStyle = { fontSize: 11, color: C.label3, margin: '0 1px' };
896
+ const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
897
+ const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
541
898
 
542
- const textField = (field, labelKey, placeholder) => React.createElement('label', {
543
- key: field,
544
- style: { display: 'flex', flexDirection: 'column', gap: 3, flex: '1 1 130px', minWidth: 112 },
899
+ const row = (key, label, values) => React.createElement('div', { key, style: rowStyle }, [
900
+ React.createElement('span', { key: 'k', style: rowKeyStyle }, label),
901
+ React.createElement('div', { key: 'v', style: rowValStyle }, values),
902
+ ]);
903
+
904
+ /** 24 小时制时间:小时/分钟两个下拉,各 46px(不受浏览器 locale 影响)。 */
905
+ const timeSelects = (field, label, disabled = false) => {
906
+ const [rawHour = '00', rawMinute = '00'] = String(form[field] || '00:00').split(':');
907
+ const hour = HOUR_OPTIONS.includes(rawHour) ? rawHour : '00';
908
+ const minute = MINUTE_OPTIONS.includes(rawMinute) ? rawMinute : '00';
909
+ const commit = (nextHour, nextMinute) => setField(field, `${nextHour}:${nextMinute}`);
910
+ const style = { ...selectStyle, opacity: disabled ? 0.45 : 1, cursor: disabled ? 'default' : 'pointer' };
911
+ return React.createElement('span', { key: field, style: { display: 'inline-flex', alignItems: 'center', gap: 2 } }, [
912
+ React.createElement('select', {
913
+ key: 'h', value: hour, disabled, 'aria-label': `${label} ${t('hourUnit')}`,
914
+ onChange: (event) => commit(event.target.value, minute), style,
915
+ }, HOUR_OPTIONS.map((value) => React.createElement('option', { key: value, value }, value))),
916
+ React.createElement('span', { key: 'c', style: { color: C.label3, fontWeight: 700, fontSize: 12 } }, ':'),
917
+ React.createElement('select', {
918
+ key: 'm', value: minute, disabled, 'aria-label': `${label} ${t('minuteUnit')}`,
919
+ onChange: (event) => commit(hour, event.target.value), style,
920
+ }, MINUTE_OPTIONS.map((value) => React.createElement('option', { key: value, value }, value))),
921
+ ]);
922
+ };
923
+
924
+ const timeRange = (startField, endField, label, disabled) => React.createElement('span', {
925
+ key: `${startField}-${endField}`,
926
+ style: { display: 'inline-flex', alignItems: 'center', gap: 2 },
545
927
  }, [
546
- React.createElement('span', { key: 'l', style: labelStyle }, t(labelKey)),
547
- React.createElement('input', {
548
- key: 'i',
549
- type: 'text',
550
- value: form[field],
551
- placeholder: placeholder || 'HH:mm',
552
- onChange: (event) => setField(field, event.target.value),
553
- style: inputStyle,
554
- }),
928
+ timeSelects(startField, label, disabled),
929
+ React.createElement('span', { key: 'dash', style: dashStyle }, '–'),
930
+ timeSelects(endField, label, disabled),
555
931
  ]);
556
932
 
557
- const checkField = (field, labelKey) => React.createElement('label', {
933
+ const toggle = (field, labelKey, disabled) => React.createElement('label', {
558
934
  key: field,
559
935
  style: {
560
- display: 'flex', alignItems: 'center', gap: 6, flex: '1 1 140px',
561
- fontSize: 12, color: C.label2, cursor: 'pointer', minHeight: 30,
936
+ display: 'inline-flex', alignItems: 'center', gap: 5,
937
+ padding: '2px 6px', borderRadius: 8,
938
+ border: `1px solid ${C.border1}`, background: C.layer2,
939
+ fontSize: 10.5, color: disabled ? C.label3 : C.label2, whiteSpace: 'nowrap',
940
+ cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.55 : 1,
562
941
  },
563
942
  }, [
564
943
  React.createElement('input', {
565
- key: 'i',
566
- type: 'checkbox',
567
- checked: Boolean(form[field]),
944
+ key: 'i', type: 'checkbox', checked: Boolean(form[field]), disabled,
568
945
  onChange: (event) => setCheck(field, event.target.checked),
946
+ style: { accentColor: GOLD_TINT, width: 13, height: 13, margin: 0, cursor: 'inherit' },
569
947
  }),
570
948
  React.createElement('span', { key: 'l' }, t(labelKey)),
571
949
  ]);
572
950
 
573
- const textareaField = (field, labelKey) => React.createElement('label', {
574
- key: field,
575
- style: { display: 'flex', flexDirection: 'column', gap: 3, flex: '1 1 220px', minWidth: 200 },
576
- }, [
577
- React.createElement('span', { key: 'l', style: labelStyle }, t(labelKey)),
578
- React.createElement('textarea', {
579
- key: 'i',
580
- rows: 4,
581
- value: form[field],
582
- spellCheck: false,
583
- onChange: (event) => setField(field, event.target.value),
584
- style: {
585
- ...inputStyle,
586
- resize: 'vertical',
587
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
588
- lineHeight: 1.5,
589
- },
590
- }),
591
- ]);
951
+ const lunchDisabled = !form.lunchEnabled;
592
952
 
593
- return React.createElement('details', { style: { ...CARD, padding: '10px 12px' } }, [
953
+ return React.createElement('details', {
954
+ open: true,
955
+ style: { background: C.layer1, border: `1px solid ${C.border1}`, borderRadius: 12, padding: '10px 12px' },
956
+ }, [
594
957
  React.createElement('summary', {
595
958
  key: 'sum',
596
- style: { cursor: 'pointer', fontSize: 13, fontWeight: 600, color: C.label, userSelect: 'none' },
959
+ style: { cursor: 'pointer', fontSize: 12.5, fontWeight: 650, color: C.label, userSelect: 'none' },
597
960
  }, t('settings')),
598
- React.createElement('div', {
599
- key: 'hint',
600
- style: { fontSize: 11, color: C.label3, margin: '4px 0 9px' },
601
- }, t('settingsHint')),
602
- React.createElement('div', {
603
- key: 'fields',
604
- style: { display: 'flex', flexWrap: 'wrap', gap: 8 },
605
- }, [
606
- textField('monthlySalary', 'salaryField', '10000'),
607
- textField('workStart', 'workStartField', '09:00'),
608
- textField('workEnd', 'workEndField', '18:00'),
609
- textField('lunchStart', 'lunchStartField', '12:00'),
610
- textField('lunchEnd', 'lunchEndField', '13:00'),
611
- checkField('lunchEnabled', 'lunchEnabledField'),
612
- checkField('lunchPaid', 'lunchPaidField'),
613
- ]),
614
- React.createElement('div', {
615
- key: 'lists',
616
- style: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 },
617
- }, [
618
- textareaField('holidaysText', 'holidaysField'),
619
- textareaField('extraWorkdaysText', 'extraWorkdaysField'),
961
+ React.createElement('div', { key: 'body', style: { marginTop: 7 } }, [
962
+ row('salary', t('salaryField'), [
963
+ React.createElement('input', {
964
+ key: 'i', type: 'text', inputMode: 'decimal', value: form.monthlySalary, placeholder: '10000',
965
+ onChange: (event) => setField('monthlySalary', event.target.value), style: inputStyle,
966
+ }),
967
+ React.createElement('span', { key: 'u', style: unitStyle }, t('salaryUnit')),
968
+ ]),
969
+ row('worktime', t('groupWorkTime'), [
970
+ timeRange('workStart', 'workEnd', t('groupWorkTime'), false),
971
+ ]),
972
+ row('lunch', t('groupLunch'), [
973
+ // 时间在前、两个开关在后(放到行尾)
974
+ timeRange('lunchStart', 'lunchEnd', t('groupLunch'), lunchDisabled),
975
+ toggle('lunchEnabled', 'lunchEnabledShort', false),
976
+ toggle('lunchPaid', 'lunchPaidShort', lunchDisabled),
977
+ ]),
620
978
  ]),
621
979
  React.createElement('div', {
622
980
  key: 'actions',
623
- style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginTop: 10 },
981
+ style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 7, paddingTop: 7, borderTop: `1px solid ${C.border1}` },
624
982
  }, [
625
983
  React.createElement('button', {
626
984
  key: 'save',
@@ -628,23 +986,29 @@ function SettingsSection(props) {
628
986
  onClick: onSave,
629
987
  disabled: save.status === 'saving',
630
988
  style: {
631
- border: `1px solid ${C.brand}`,
632
- color: C.brand,
633
- background: 'transparent',
989
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 72%, rgba(0,0,0,.18))`,
990
+ color: '#3A2A05',
991
+ background: save.status === 'saving'
992
+ ? `color-mix(in srgb, ${GOLD_TINT} 52%, #fff)`
993
+ : 'linear-gradient(180deg, #F8DC86 0%, #EFC258 52%, #D99D2C 100%)',
634
994
  borderRadius: 8,
635
- padding: '6px 16px',
636
- fontSize: 13,
637
- fontWeight: 600,
995
+ padding: '5px 16px',
996
+ fontSize: 12.5,
997
+ fontWeight: 700,
998
+ letterSpacing: '.3px',
638
999
  cursor: save.status === 'saving' ? 'default' : 'pointer',
1000
+ opacity: save.status === 'saving' ? 0.72 : 1,
1001
+ boxShadow: `0 2px 9px color-mix(in srgb, ${GOLD_TINT} 34%, transparent), inset 0 1px 0 rgba(255,255,255,.45)`,
1002
+ textShadow: '0 1px 0 rgba(255,255,255,.28)',
639
1003
  },
640
1004
  }, save.status === 'saving' ? t('saving') : t('save')),
641
1005
  save.status === 'ok'
642
- ? React.createElement('span', { key: 'ok', style: { fontSize: 12, color: C.ok } }, save.message || t('saved'))
1006
+ ? React.createElement('span', { key: 'ok', style: { fontSize: 11.5, color: C.ok } }, save.message || t('saved'))
643
1007
  : null,
644
1008
  save.status === 'error'
645
1009
  ? React.createElement('span', {
646
1010
  key: 'err',
647
- style: { fontSize: 12, color: C.error, flex: '1 1 220px', whiteSpace: 'pre-wrap' },
1011
+ style: { fontSize: 11.5, color: C.error, flex: '1 1 200px', whiteSpace: 'pre-wrap' },
648
1012
  }, `${t('saveFailed')}${save.errors.join(';')}`)
649
1013
  : null,
650
1014
  ]),
@@ -677,6 +1041,70 @@ function NiumaPanel(props) {
677
1041
  const rates = snap && snap.rates ? snap.rates : computeRates(cfg, snap && snap.dateKey);
678
1042
  const baseForm = React.useMemo(() => formFromConfig(cfg), [cfg]);
679
1043
 
1044
+ /**
1045
+ * 「工资换算」视角:本月(跟随今日口径)/ 指定月份 / 全年平均。
1046
+ * 只影响换算展示,不影响顶部「今日已赚」计数器(计数器永远按今天算)。
1047
+ */
1048
+ const viewYear = Number(String(snap && snap.dateKey ? snap.dateKey : '').slice(0, 4)) || new Date().getFullYear();
1049
+ const yearEstimated = viewYear === HOLIDAY_ESTIMATE_2027.year;
1050
+ const yearData = React.useMemo(() => yearSummary(cfg, viewYear), [cfg, viewYear]);
1051
+ const [view, setView] = React.useState('current');
1052
+ const selectedMonth = view.startsWith('m') ? yearData.months[Number(view.slice(1)) - 1] : null;
1053
+ const maxHolidayMonth = yearData.maxHolidayMonth;
1054
+ const maxHolidayText = t('maxHolidayNote')
1055
+ .replace('{month}', String(maxHolidayMonth.month))
1056
+ .replace('{days}', String(maxHolidayMonth.holidayDays));
1057
+ let viewCards;
1058
+ let viewHint;
1059
+ let viewNote;
1060
+ if (view === 'year') {
1061
+ // 与「本月」保持同一阅读顺序:秒 → 分 → 时 → 日,再放该视角的汇总项
1062
+ viewCards = [
1063
+ { key: 'avgSecond', label: t('averageSecond'), value: money(yearData.averageSecondWage, 4), accent: true },
1064
+ { key: 'avgMinute', label: t('averageMinute'), value: money(yearData.averageMinuteWage, 4) },
1065
+ { key: 'avgHourly', label: t('averageHourly'), value: money(yearData.averageHourlyWage, 2) },
1066
+ { key: 'avgDaily', label: t('averageDaily'), value: money(yearData.averageDailyWage, 2) },
1067
+ { key: 'yearWorkdays', label: t('yearWorkdays'), value: `${yearData.totalWorkdays}${t('dayUnit')}` },
1068
+ { key: 'yearIncome', label: t('yearIncome'), value: money(yearData.annualIncome, 2) },
1069
+ ];
1070
+ viewHint = `${t('yearHint').replace('{year}', String(viewYear)).replace('{workdays}', String(yearData.totalWorkdays))} · ${t('viewOnlyHint')}`
1071
+ + (yearEstimated ? ` · ${t('holidayEstimateShort')}` : '');
1072
+ viewNote = yearEstimated ? t('holidayEstimateNote') : maxHolidayText;
1073
+ } else if (selectedMonth) {
1074
+ // 与「本月」保持同一阅读顺序:秒 → 分 → 时 → 日 → 月 → 工作日
1075
+ viewCards = [
1076
+ { key: 'secondWage', label: t('secondWage'), value: money(selectedMonth.secondWage, 4), accent: true },
1077
+ { key: 'minuteWage', label: t('minuteWage'), value: money(selectedMonth.minuteWage, 4) },
1078
+ { key: 'hourlyWage', label: t('hourlyWage'), value: money(selectedMonth.hourlyWage, 2) },
1079
+ { key: 'dailyWage', label: t('dailyWage'), value: money(selectedMonth.dailyWage, 2) },
1080
+ { key: 'monthlyWage', label: t('monthlyWage'), value: money(selectedMonth.monthlySalary, 2) },
1081
+ { key: 'monthWorkdays', label: t('monthWorkdaysCard'), value: `${selectedMonth.workdays}${t('dayUnit')}` },
1082
+ ];
1083
+ viewHint = `${t('monthHint')
1084
+ .replace('{year}', String(viewYear))
1085
+ .replace('{month}', String(selectedMonth.month))
1086
+ .replace('{workdays}', String(selectedMonth.workdays))
1087
+ .replace('{holidays}', String(selectedMonth.holidayDays))} · ${t('viewOnlyHint')}`
1088
+ + (yearEstimated ? ` · ${t('holidayEstimateShort')}` : '');
1089
+ viewNote = yearEstimated
1090
+ ? t('holidayEstimateNote')
1091
+ : (selectedMonth.month === maxHolidayMonth.month
1092
+ ? `★ ${t('maxHolidayThis').replace('{days}', String(maxHolidayMonth.holidayDays))}`
1093
+ : maxHolidayText);
1094
+ } else {
1095
+ // 统一阅读顺序:秒 → 分 → 时 → 日 → 周 → 月(所有视角都从「秒」开始、从小到大)
1096
+ viewCards = RATE_CARDS.map((spec) => ({
1097
+ key: spec.key,
1098
+ label: t(spec.label),
1099
+ value: money(rates[spec.key], spec.digits),
1100
+ accent: spec.key === 'secondWage',
1101
+ }));
1102
+ viewHint = t('currentHint')
1103
+ .replace('{days}', String(rates.monthlyWorkdays))
1104
+ .replace('{hours}', trimNumber(rates.paidHours, 2));
1105
+ viewNote = maxHolidayText;
1106
+ }
1107
+
680
1108
  // 设置表单草稿:null = 还没改过,直接显示当前配置。
681
1109
  const [draft, setDraft] = React.useState(null);
682
1110
  const [save, setSave] = React.useState({ status: 'idle', errors: [], message: '' });
@@ -742,30 +1170,60 @@ function NiumaPanel(props) {
742
1170
 
743
1171
  return React.createElement('div', {
744
1172
  style: {
745
- display: 'flex',
746
- flexDirection: 'column',
747
- gap: 9,
748
- minWidth: 0,
749
- padding: '2px 1px 18px',
1173
+ // DSH 的 main slot 给的是定高容器:插件必须自己当滚动容器,否则展开设置后底部会被裁掉。
1174
+ // 注意:这里用 block + 内层 flex 的写法 —— 如果根直接 display:flex,子项会在定高容器里
1175
+ // 被 flex-shrink 压扁(展开设置时整张主卡高度被压缩、内容被 overflow 裁掉)。
1176
+ height: '100%',
1177
+ width: '100%',
1178
+ overflowY: 'auto',
1179
+ overflowX: 'hidden',
1180
+ overscrollBehavior: 'contain',
1181
+ scrollbarGutter: 'stable',
1182
+ boxSizing: 'border-box',
750
1183
  fontSize: 13,
751
1184
  lineHeight: 1.45,
752
1185
  color: C.label,
753
1186
  fontFamily: 'inherit',
754
1187
  },
755
1188
  }, [
756
- // 标题 + 状态徽标
1189
+ React.createElement(StyleTag, { key: 'css' }),
1190
+ React.createElement('div', {
1191
+ key: 'inner',
1192
+ style: {
1193
+ display: 'flex',
1194
+ flexDirection: 'column',
1195
+ gap: 11,
1196
+ width: '100%',
1197
+ maxWidth: 820,
1198
+ margin: '0 auto',
1199
+ padding: '16px 18px 40px',
1200
+ boxSizing: 'border-box',
1201
+ minWidth: 0,
1202
+ },
1203
+ }, [
1204
+ // 品牌头:图标块 + 标题/副标题 + 状态徽标
757
1205
  React.createElement('div', {
758
1206
  key: 'head',
759
- style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
1207
+ style: { display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap' },
760
1208
  }, [
1209
+ React.createElement('div', {
1210
+ key: 'icon',
1211
+ style: {
1212
+ width: 38, height: 38, borderRadius: 12, flex: '0 0 auto',
1213
+ display: 'grid', placeItems: 'center',
1214
+ color: C.brand,
1215
+ background: `color-mix(in srgb, ${C.brand} 13%, transparent)`,
1216
+ border: `1px solid color-mix(in srgb, ${C.brand} 26%, transparent)`,
1217
+ },
1218
+ }, React.createElement(NiumaIcon, { size: 21 })),
761
1219
  React.createElement('div', { key: 'tt', style: { marginRight: 'auto', minWidth: 0 } }, [
762
1220
  React.createElement('div', {
763
1221
  key: 't',
764
- style: { fontSize: 16, fontWeight: 700, color: C.label },
1222
+ style: { fontSize: 17.5, fontWeight: 800, letterSpacing: '-.3px', color: C.label },
765
1223
  }, t('title')),
766
1224
  React.createElement('div', {
767
1225
  key: 's',
768
- style: { fontSize: 11, color: C.label3 },
1226
+ style: { fontSize: 11, color: C.label3, marginTop: 1 },
769
1227
  }, t('subtitle')),
770
1228
  ]),
771
1229
  React.createElement(StatusBadge, { key: 'badge', t, status: snap.status }),
@@ -777,8 +1235,9 @@ function NiumaPanel(props) {
777
1235
  key: 'banner',
778
1236
  style: {
779
1237
  display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
780
- fontSize: 12, color: banner.color, padding: '5px 9px',
781
- borderRadius: 8, border: `1px solid ${banner.color}`, background: C.layer1,
1238
+ fontSize: 12, color: banner.color, padding: '6px 10px',
1239
+ borderRadius: 9, border: `1px solid color-mix(in srgb, ${banner.color} 45%, transparent)`,
1240
+ background: `color-mix(in srgb, ${banner.color} 10%, transparent)`,
782
1241
  },
783
1242
  }, [
784
1243
  React.createElement('span', { key: 'txt', style: { flex: '1 1 160px' } }, banner.text),
@@ -797,9 +1256,57 @@ function NiumaPanel(props) {
797
1256
  : null,
798
1257
 
799
1258
  React.createElement(HeroCard, { key: 'hero', t, snap }),
800
- React.createElement(ProgressCard, { key: 'progress', t, snap }),
801
- React.createElement(RatesGrid, { key: 'rates', t, rates }),
1259
+ React.createElement(CountdownRow, { key: 'countdown', t, snap }),
1260
+ React.createElement('div', {
1261
+ key: 'ratesHead',
1262
+ style: {
1263
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
1264
+ gap: 8, flexWrap: 'wrap', margin: '2px 1px -1px',
1265
+ },
1266
+ }, [
1267
+ React.createElement('span', {
1268
+ key: 't',
1269
+ style: { fontSize: 12.5, fontWeight: 700, color: C.label2, letterSpacing: '.2px' },
1270
+ }, t('sectionRates')),
1271
+ React.createElement(PeriodSelect, {
1272
+ key: 'period',
1273
+ t,
1274
+ value: view,
1275
+ onChange: setView,
1276
+ year: viewYear,
1277
+ months: yearData.months,
1278
+ }),
1279
+ ]),
1280
+ React.createElement('div', {
1281
+ key: 'ratesHint',
1282
+ style: { fontSize: 10.5, color: C.label3, ...NUM },
1283
+ }, viewHint),
1284
+ React.createElement(RatesGrid, { key: 'rates', cards: viewCards }),
1285
+ viewNote
1286
+ ? React.createElement('div', {
1287
+ key: 'ratesNote',
1288
+ style: { fontSize: 10.5, color: C.label3, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
1289
+ }, [
1290
+ React.createElement('span', {
1291
+ key: 'badge',
1292
+ style: {
1293
+ color: C.label2, fontWeight: 600,
1294
+ background: `color-mix(in srgb, ${GOLD_TINT} 14%, transparent)`,
1295
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 32%, transparent)`,
1296
+ borderRadius: 999, padding: '1px 8px',
1297
+ },
1298
+ }, t('maxHolidayBadge')),
1299
+ React.createElement('span', { key: 'txt' }, viewNote),
1300
+ ])
1301
+ : null,
1302
+ React.createElement(SectionTitle, { key: 'detailTitle', title: t('sectionDetail') }),
802
1303
  React.createElement(DetailsFooter, { key: 'footer', t, snap, holidaySource }),
1304
+ React.createElement(HolidaysCard, {
1305
+ key: 'holidays',
1306
+ t,
1307
+ holidaySource,
1308
+ extraWorkdays: cfg.extraWorkdays,
1309
+ }),
803
1310
  React.createElement(SettingsSection, {
804
1311
  key: 'settings',
805
1312
  t,
@@ -809,6 +1316,7 @@ function NiumaPanel(props) {
809
1316
  save,
810
1317
  onSave,
811
1318
  }),
1319
+ ]),
812
1320
  ]);
813
1321
  }
814
1322