dsh-niuma 0.2.0 → 0.2.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-niuma",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "牛马加速器 — DSH 工资换算实时计数器:按实收月薪、上下班时间、午休(可设是否算工时)、法定节假日自动折算秒/分/时/日/周/月工资,工资每秒滚动增长。Community salary ticker plugin for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -116,19 +116,33 @@ const zh = {
116
116
  'paidHoursPerDay': '日有效工时',
117
117
  'todayTarget': '今日目标',
118
118
  'monthEarned': '本月已赚',
119
+ 'monthWorkedDays': '本月已工作',
120
+ 'monthRemainingDays': '剩余工作日',
119
121
  'holidaySourceLink': '国务院办公厅 2026 年节假日安排',
120
122
  'configLoading': '正在读取设置…',
121
123
  'configFailed': '设置读取失败,使用默认值',
122
124
  'retry': '重试',
123
125
  'settings': '设置',
126
+ 'decimalsLabel': '小数',
127
+ 'decimalsUnit': '位',
128
+ 'turboLabel': '加速',
129
+ 'refreshRate': '刷新频率',
130
+ 'rateSecond': '按秒(1s)',
131
+ 'rateFrame': '刷新率(≈16ms)',
124
132
  'settingsHint': '改完点「保存」生效;格式不对会在下方红字提示,不影响计时。',
125
- 'salaryField': '实收月薪(元/月)',
133
+ 'salaryField': '实收月薪',
134
+ 'groupSalary': '工资',
135
+ 'groupWorkTime': '工作时间',
136
+ 'groupLunch': '午休',
137
+ 'salaryUnit': '元 / 月',
126
138
  'workStartField': '上班时间',
127
139
  'workEndField': '下班时间',
128
140
  'lunchEnabledField': '启用午休',
141
+ 'lunchEnabledShort': '启用',
129
142
  'lunchStartField': '午休开始',
130
143
  'lunchEndField': '午休结束',
131
144
  'lunchPaidField': '午休算工时',
145
+ 'lunchPaidShort': '算工时',
132
146
  'holidaysCardTitle': '节假日安排',
133
147
  'holidays2026Label': '2026 年(官方)',
134
148
  'holidays2027Label': '2027 年(预估)',
@@ -153,6 +167,7 @@ const zh = {
153
167
  'saveFailed': '保存失败:',
154
168
  'dayUnit': '天',
155
169
  'hourUnit': '小时',
170
+ 'minuteUnit': '分钟',
156
171
  'viewLabel': '查看',
157
172
  'viewCurrent': '本月',
158
173
  'viewYear': '全年平均',
@@ -207,19 +222,33 @@ const en = {
207
222
  'paidHoursPerDay': 'Paid hours / day',
208
223
  'todayTarget': "Today's target",
209
224
  'monthEarned': 'Earned this month',
225
+ 'monthWorkedDays': 'Worked this month',
226
+ 'monthRemainingDays': 'Workdays left',
210
227
  'holidaySourceLink': 'State Council 2026 holiday schedule',
211
228
  'configLoading': 'Loading settings…',
212
229
  'configFailed': 'Failed to load settings, using defaults',
213
230
  'retry': 'Retry',
214
231
  'settings': 'Settings',
232
+ 'decimalsLabel': 'Decimals',
233
+ 'decimalsUnit': 'dp',
234
+ 'turboLabel': 'Turbo',
235
+ 'refreshRate': 'Refresh rate',
236
+ 'rateSecond': 'Per second (1s)',
237
+ 'rateFrame': 'Display rate (~16ms)',
215
238
  'settingsHint': 'Click Save to apply. Invalid input is shown in red below; the ticker keeps running.',
216
- 'salaryField': 'Net monthly salary (CNY)',
239
+ 'salaryField': 'Net monthly salary',
240
+ 'groupSalary': 'Salary',
241
+ 'groupWorkTime': 'Working hours',
242
+ 'groupLunch': 'Lunch break',
243
+ 'salaryUnit': 'CNY / month',
217
244
  'workStartField': 'Work start',
218
245
  'workEndField': 'Work end',
219
246
  'lunchEnabledField': 'Enable lunch break',
247
+ 'lunchEnabledShort': 'On',
220
248
  'lunchStartField': 'Lunch start',
221
249
  'lunchEndField': 'Lunch end',
222
250
  'lunchPaidField': 'Lunch is paid',
251
+ 'lunchPaidShort': 'Paid',
223
252
  'holidaysCardTitle': 'Holiday calendar',
224
253
  'holidays2026Label': '2026 (official)',
225
254
  'holidays2027Label': '2027 (estimated)',
@@ -244,6 +273,7 @@ const en = {
244
273
  'saveFailed': 'Save failed: ',
245
274
  'dayUnit': 'd',
246
275
  'hourUnit': 'h',
276
+ 'minuteUnit': 'min',
247
277
  'viewLabel': 'View',
248
278
  'viewCurrent': 'This month',
249
279
  'viewYear': 'Year average',
@@ -294,6 +324,7 @@ function formFromConfig(config) {
294
324
  lunchStart: String(config.lunchStart == null ? '' : config.lunchStart),
295
325
  lunchEnd: String(config.lunchEnd == null ? '' : config.lunchEnd),
296
326
  lunchPaid: config.lunchPaid === true,
327
+ updateMs: String(config.updateMs == null ? 1000 : config.updateMs),
297
328
  };
298
329
  }
299
330
 
@@ -308,6 +339,7 @@ function patchFromForm(form) {
308
339
  lunchStart: String(form.lunchStart || '').trim(),
309
340
  lunchEnd: String(form.lunchEnd || '').trim(),
310
341
  lunchPaid: Boolean(form.lunchPaid),
342
+ updateMs: Number(form.updateMs),
311
343
  };
312
344
  }
313
345
 
@@ -353,19 +385,26 @@ function useRemoteConfig() {
353
385
  return { ...state, refresh };
354
386
  }
355
387
 
356
- /** 当前时间:每 250ms 检查一次,只有整秒变化才 setState(每秒重算一次)。 */
357
- function useSecondClock() {
388
+ /**
389
+ * 当前时间:按配置的刷新间隔更新。
390
+ * - updateMs >= 50:setInterval(按秒 / 200ms / 100ms)
391
+ * - updateMs <= 16:requestAnimationFrame(跟随屏幕刷新率连续滚动)
392
+ */
393
+ function useClock(updateMs) {
358
394
  const [now, setNow] = React.useState(() => new Date());
359
395
  React.useEffect(() => {
360
- let lastSecond = -1;
361
- const timer = setInterval(() => {
362
- const second = Math.floor(Date.now() / 1000);
363
- if (second === lastSecond) return;
364
- lastSecond = second;
365
- setNow(new Date());
366
- }, 250);
396
+ if (updateMs <= 16) {
397
+ let raf = 0;
398
+ const loop = () => {
399
+ setNow(new Date());
400
+ raf = requestAnimationFrame(loop);
401
+ };
402
+ raf = requestAnimationFrame(loop);
403
+ return () => cancelAnimationFrame(raf);
404
+ }
405
+ const timer = setInterval(() => setNow(new Date()), updateMs);
367
406
  return () => clearInterval(timer);
368
- }, []);
407
+ }, [updateMs]);
369
408
  return now;
370
409
  }
371
410
 
@@ -453,10 +492,103 @@ function SectionTitle(props) {
453
492
  }
454
493
 
455
494
  /** 今日已赚主卡:渐变底 + 超大金额 + 进度条(视觉焦点只有这一处)。 */
495
+ /* ------------------------------------------------------------------ *
496
+ * 滚动金额:连续的「机械里程表」
497
+ * 每一位不是等金额变了才跳一格,而是按位权连续转动:
498
+ * 最后一位小数转得飞快(0-9 循环),前一位慢 10 倍,整数位几乎不动。
499
+ * 加速模式下每帧(rAF)更新 wheel 位置,看起来就是数字在持续往上滚。
500
+ * ------------------------------------------------------------------ */
501
+ const ROLL_BLOCKS = Array.from({ length: 11 }, (_, i) => String(i % 10));
502
+
503
+ /** 单个数字轮:`position` 是连续位置(单位=1 个数字格,小数部分=滚到一半)。 */
504
+ function RollingDigit(props) {
505
+ const heightEm = 1.22;
506
+ const widthEm = 0.62;
507
+ const pos = Number.isFinite(props.position) ? props.position : 0;
508
+ const wheel = ((pos % 10) + 10) % 10;
509
+ return React.createElement('span', {
510
+ 'aria-hidden': 'true',
511
+ style: {
512
+ display: 'inline-block', position: 'relative', overflow: 'hidden',
513
+ width: `${widthEm}em`, height: `${heightEm}em`, verticalAlign: 'baseline',
514
+ },
515
+ }, [
516
+ React.createElement('span', {
517
+ key: 'ghost',
518
+ style: { visibility: 'hidden', display: 'block', height: `${heightEm}em`, lineHeight: `${heightEm}em` },
519
+ }, '0'),
520
+ React.createElement('span', {
521
+ key: 'strip',
522
+ style: {
523
+ position: 'absolute', left: 0, top: 0, display: 'flex', flexDirection: 'column',
524
+ transform: `translateY(${-(wheel * heightEm)}em)`,
525
+ willChange: 'transform',
526
+ },
527
+ }, ROLL_BLOCKS.map((value, i) => React.createElement('span', {
528
+ key: i,
529
+ style: { display: 'block', height: `${heightEm}em`, lineHeight: `${heightEm}em` },
530
+ }, value))),
531
+ ]);
532
+ }
533
+
534
+ /**
535
+ * 金额 → 连续滚轮。`value` 是原始金额,`decimals` 决定小数位。
536
+ * 每一位的轮位 = value * 10^decimals / 10^(该位右侧数字个数),因此低位快、高位慢。
537
+ */
538
+ function RollingMoney(props) {
539
+ const decimals = Math.max(0, Math.min(6, Number.isInteger(props.decimals) ? props.decimals : 4));
540
+ const text = formatMoney(props.value, decimals);
541
+ const scale = 10 ** decimals;
542
+ // 从右往左数「该位右侧还有几个数字」,得到每位位权
543
+ const chars = text.split('');
544
+ const significances = new Array(chars.length).fill(-1);
545
+ let seen = 0;
546
+ for (let i = chars.length - 1; i >= 0; i -= 1) {
547
+ if (/[0-9]/.test(chars[i])) {
548
+ significances[i] = seen;
549
+ seen += 1;
550
+ }
551
+ }
552
+ return React.createElement('span', {
553
+ style: {
554
+ display: 'inline-flex', alignItems: 'baseline', letterSpacing: 0, position: 'relative',
555
+ // 等宽字体保证每个数字轮宽度稳定(不裁字、不抖动)
556
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
557
+ ...(props.style || {}),
558
+ },
559
+ }, [
560
+ React.createElement('span', {
561
+ key: 'sr',
562
+ style: {
563
+ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1,
564
+ overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0,
565
+ },
566
+ }, text),
567
+ React.createElement('span', {
568
+ key: 'visual',
569
+ 'aria-hidden': 'true',
570
+ style: { display: 'inline-flex', alignItems: 'baseline' },
571
+ }, chars.map((char, i) => {
572
+ if (!/[0-9]/.test(char)) {
573
+ return React.createElement('span', {
574
+ key: `s${i}`,
575
+ style: { display: 'inline-block', lineHeight: '1.22em' },
576
+ }, char);
577
+ }
578
+ const position = (Number(props.value) || 0) * scale / 10 ** significances[i];
579
+ return React.createElement(RollingDigit, { key: `d${i}`, position });
580
+ })),
581
+ ]);
582
+ }
583
+
456
584
  function HeroCard(props) {
457
585
  const t = props.t;
458
586
  const snap = props.snap;
459
587
  const rates = snap.rates;
588
+ const decimals = Number.isInteger(props.decimals) ? props.decimals : 4;
589
+ const onDecimalsChange = props.onDecimalsChange;
590
+ const turbo = props.turbo === true;
591
+ const onTurboChange = props.onTurboChange;
460
592
  const progress = Math.max(0, Math.min(1, Number(snap.todayProgress) || 0));
461
593
  const percentText = `${(progress * 100).toFixed(1)}%`;
462
594
  const live = snap.status === 'working';
@@ -510,19 +642,61 @@ function HeroCard(props) {
510
642
  key: 'amt',
511
643
  style: { display: 'flex', alignItems: 'baseline', gap: 7, marginTop: 4, flexWrap: 'wrap' },
512
644
  }, [
513
- React.createElement('span', {
645
+ React.createElement(RollingMoney, {
514
646
  key: 'val',
647
+ value: snap.todayEarned,
648
+ decimals,
515
649
  style: {
516
- fontSize: 46, lineHeight: 1.12, fontWeight: 800, letterSpacing: '-1.2px',
650
+ fontSize: 46, lineHeight: 1.12, fontWeight: 800,
517
651
  ...GOLD,
518
652
  ...NUM,
519
653
  ...(rest ? { opacity: 0.62 } : {}),
520
654
  },
521
- }, formatMoney(snap.todayEarned, 4)),
655
+ }),
522
656
  React.createElement('span', {
523
657
  key: 'unit',
524
658
  style: { fontSize: 18, fontWeight: 700, color: C.label2, ...NUM },
525
659
  }, '元'),
660
+ React.createElement('label', {
661
+ key: 'dec',
662
+ title: t('decimalsLabel'),
663
+ style: {
664
+ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 3,
665
+ fontSize: 10, color: C.label3, ...NUM,
666
+ },
667
+ }, [
668
+ React.createElement('span', { key: 'l' }, t('decimalsLabel')),
669
+ React.createElement('select', {
670
+ key: 's',
671
+ value: String(decimals),
672
+ 'aria-label': t('decimalsLabel'),
673
+ onChange: (event) => onDecimalsChange && onDecimalsChange(Number(event.target.value)),
674
+ style: {
675
+ height: 22, padding: '0 2px', borderRadius: 6,
676
+ border: `1px solid ${C.border1}`, background: C.layer2, color: C.label2,
677
+ fontSize: 10.5, fontVariantNumeric: 'tabular-nums', outline: 'none', cursor: 'pointer',
678
+ },
679
+ }, [0, 1, 2, 3, 4, 5, 6].map((n) => React.createElement('option', { key: n, value: String(n) }, String(n)))),
680
+ React.createElement('span', { key: 'u' }, t('decimalsUnit')),
681
+ ]),
682
+ React.createElement('label', {
683
+ key: 'turbo',
684
+ title: t('turboLabel'),
685
+ style: {
686
+ display: 'inline-flex', alignItems: 'center', gap: 3,
687
+ fontSize: 10, padding: '1px 6px', borderRadius: 999,
688
+ border: `1px solid ${turbo ? `color-mix(in srgb, ${GOLD_TINT} 60%, ${C.border1})` : C.border1}`,
689
+ background: turbo ? `color-mix(in srgb, ${GOLD_TINT} 16%, transparent)` : 'transparent',
690
+ color: turbo ? C.label2 : C.label3, cursor: 'pointer', ...NUM,
691
+ },
692
+ }, [
693
+ React.createElement('input', {
694
+ key: 'i', type: 'checkbox', checked: turbo,
695
+ onChange: (event) => onTurboChange && onTurboChange(event.target.checked),
696
+ style: { accentColor: GOLD_TINT, width: 12, height: 12, margin: 0, cursor: 'inherit' },
697
+ }),
698
+ React.createElement('span', { key: 'l' }, t('turboLabel')),
699
+ ]),
526
700
  ]),
527
701
  React.createElement('div', {
528
702
  key: 'chips',
@@ -695,6 +869,8 @@ function DetailsFooter(props) {
695
869
  { key: 'hours', label: t('paidHoursPerDay'), value: `${trimNumber(rates.paidHours, 2)}${t('hourUnit')}` },
696
870
  { key: 'target', label: t('todayTarget'), value: money(snap.todayTarget, 2) },
697
871
  { key: 'month', label: t('monthEarned'), value: money(snap.monthEarned, 2) },
872
+ { key: 'worked', label: t('monthWorkedDays'), value: `${snap.monthWorkdaysDone}${t('dayUnit')}` },
873
+ { key: 'left', label: t('monthRemainingDays'), value: `${snap.monthWorkdaysLeft}${t('dayUnit')}` },
698
874
  ];
699
875
  return React.createElement('div', {
700
876
  style: {
@@ -854,81 +1030,126 @@ function SettingsSection(props) {
854
1030
  const save = props.save;
855
1031
  const onSave = props.onSave;
856
1032
 
1033
+ // E 方案:行式短控件 —— 标签固定左列,控件都按内容定宽(不拉满)
1034
+ const rowStyle = {
1035
+ display: 'flex', alignItems: 'center', gap: 10,
1036
+ minHeight: 33, padding: '2px 0', borderTop: `1px solid ${C.border1}`,
1037
+ };
1038
+ const rowKeyStyle = { width: 62, flex: '0 0 62px', fontSize: 11.5, color: C.label3 };
1039
+ const rowValStyle = { display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap', minWidth: 0 };
857
1040
  const inputStyle = {
858
- width: '100%',
859
- boxSizing: 'border-box',
860
- padding: '6px 9px',
861
- borderRadius: 8,
862
- border: `1px solid ${C.border}`,
863
- background: C.layer2,
864
- color: C.label,
865
- fontSize: 13,
866
- fontVariantNumeric: 'tabular-nums',
867
- outline: 'none',
1041
+ boxSizing: 'border-box', width: 88, minHeight: 28, padding: '4px 8px',
1042
+ borderRadius: 7, border: `1px solid ${C.border}`, background: C.layer2,
1043
+ color: C.label, fontSize: 12.5, fontVariantNumeric: 'tabular-nums', outline: 'none',
868
1044
  };
869
- const labelStyle = { fontSize: 10.5, color: C.label3, fontWeight: 500 };
1045
+ const selectStyle = {
1046
+ boxSizing: 'border-box', width: 48, minHeight: 28, padding: '4px 2px 4px 6px',
1047
+ borderRadius: 7, border: `1px solid ${C.border}`, background: C.layer2,
1048
+ color: C.label, fontSize: 12.5, fontVariantNumeric: 'tabular-nums', outline: 'none', cursor: 'pointer',
1049
+ };
1050
+ const unitStyle = { fontSize: 10.5, color: C.label3, whiteSpace: 'nowrap' };
1051
+ const dashStyle = { fontSize: 11, color: C.label3, margin: '0 1px' };
1052
+ const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
1053
+ const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
870
1054
 
871
- const textField = (field, labelKey, placeholder) => React.createElement('label', {
872
- key: field,
873
- style: { display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 130px', minWidth: 112 },
1055
+ const row = (key, label, values) => React.createElement('div', { key, style: rowStyle }, [
1056
+ React.createElement('span', { key: 'k', style: rowKeyStyle }, label),
1057
+ React.createElement('div', { key: 'v', style: rowValStyle }, values),
1058
+ ]);
1059
+
1060
+ /** 24 小时制时间:小时/分钟两个下拉,各 46px(不受浏览器 locale 影响)。 */
1061
+ const timeSelects = (field, label, disabled = false) => {
1062
+ const [rawHour = '00', rawMinute = '00'] = String(form[field] || '00:00').split(':');
1063
+ const hour = HOUR_OPTIONS.includes(rawHour) ? rawHour : '00';
1064
+ const minute = MINUTE_OPTIONS.includes(rawMinute) ? rawMinute : '00';
1065
+ const commit = (nextHour, nextMinute) => setField(field, `${nextHour}:${nextMinute}`);
1066
+ const style = { ...selectStyle, opacity: disabled ? 0.45 : 1, cursor: disabled ? 'default' : 'pointer' };
1067
+ return React.createElement('span', { key: field, style: { display: 'inline-flex', alignItems: 'center', gap: 2 } }, [
1068
+ React.createElement('select', {
1069
+ key: 'h', value: hour, disabled, 'aria-label': `${label} ${t('hourUnit')}`,
1070
+ onChange: (event) => commit(event.target.value, minute), style,
1071
+ }, HOUR_OPTIONS.map((value) => React.createElement('option', { key: value, value }, value))),
1072
+ React.createElement('span', { key: 'c', style: { color: C.label3, fontWeight: 700, fontSize: 12 } }, ':'),
1073
+ React.createElement('select', {
1074
+ key: 'm', value: minute, disabled, 'aria-label': `${label} ${t('minuteUnit')}`,
1075
+ onChange: (event) => commit(hour, event.target.value), style,
1076
+ }, MINUTE_OPTIONS.map((value) => React.createElement('option', { key: value, value }, value))),
1077
+ ]);
1078
+ };
1079
+
1080
+ const timeRange = (startField, endField, label, disabled) => React.createElement('span', {
1081
+ key: `${startField}-${endField}`,
1082
+ style: { display: 'inline-flex', alignItems: 'center', gap: 2 },
874
1083
  }, [
875
- React.createElement('span', { key: 'l', style: labelStyle }, t(labelKey)),
876
- React.createElement('input', {
877
- key: 'i',
878
- type: 'text',
879
- value: form[field],
880
- placeholder: placeholder || 'HH:mm',
881
- onChange: (event) => setField(field, event.target.value),
882
- style: inputStyle,
883
- }),
1084
+ timeSelects(startField, label, disabled),
1085
+ React.createElement('span', { key: 'dash', style: dashStyle }, '–'),
1086
+ timeSelects(endField, label, disabled),
884
1087
  ]);
885
1088
 
886
- const checkField = (field, labelKey) => React.createElement('label', {
1089
+ const toggle = (field, labelKey, disabled) => React.createElement('label', {
887
1090
  key: field,
888
1091
  style: {
889
- display: 'flex', alignItems: 'center', gap: 6, flex: '1 1 140px',
890
- fontSize: 12, color: C.label2, cursor: 'pointer', minHeight: 30,
1092
+ display: 'inline-flex', alignItems: 'center', gap: 5,
1093
+ padding: '2px 6px', borderRadius: 8,
1094
+ border: `1px solid ${C.border1}`, background: C.layer2,
1095
+ fontSize: 10.5, color: disabled ? C.label3 : C.label2, whiteSpace: 'nowrap',
1096
+ cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.55 : 1,
891
1097
  },
892
1098
  }, [
893
1099
  React.createElement('input', {
894
- key: 'i',
895
- type: 'checkbox',
896
- checked: Boolean(form[field]),
1100
+ key: 'i', type: 'checkbox', checked: Boolean(form[field]), disabled,
897
1101
  onChange: (event) => setCheck(field, event.target.checked),
1102
+ style: { accentColor: GOLD_TINT, width: 13, height: 13, margin: 0, cursor: 'inherit' },
898
1103
  }),
899
1104
  React.createElement('span', { key: 'l' }, t(labelKey)),
900
1105
  ]);
901
1106
 
1107
+ const lunchDisabled = !form.lunchEnabled;
1108
+ const RATE_OPTIONS = [
1109
+ ['1000', t('rateSecond')],
1110
+ ['250', '250 ms'],
1111
+ ['100', '100 ms'],
1112
+ ['50', '50 ms'],
1113
+ ['16', t('rateFrame')],
1114
+ ];
1115
+
902
1116
  return React.createElement('details', {
903
- style: { background: C.layer1, border: `1px solid ${C.border1}`, borderRadius: 12, padding: '11px 13px' },
1117
+ open: true,
1118
+ style: { background: C.layer1, border: `1px solid ${C.border1}`, borderRadius: 12, padding: '10px 12px' },
904
1119
  }, [
905
1120
  React.createElement('summary', {
906
1121
  key: 'sum',
907
- style: { cursor: 'pointer', fontSize: 13, fontWeight: 650, color: C.label, userSelect: 'none' },
1122
+ style: { cursor: 'pointer', fontSize: 12.5, fontWeight: 650, color: C.label, userSelect: 'none' },
908
1123
  }, t('settings')),
909
- React.createElement('div', {
910
- key: 'hint',
911
- style: { fontSize: 10.5, color: C.label3, margin: '5px 0 10px' },
912
- }, t('settingsHint')),
913
- React.createElement('div', {
914
- key: 'dataNote',
915
- style: { fontSize: 10.5, color: C.label3, margin: '-4px 0 10px', lineHeight: 1.55 },
916
- }, t('settingsDataNote')),
917
- React.createElement('div', {
918
- key: 'fields',
919
- style: { display: 'flex', flexWrap: 'wrap', gap: 9 },
920
- }, [
921
- textField('monthlySalary', 'salaryField', '10000'),
922
- textField('workStart', 'workStartField', '09:00'),
923
- textField('workEnd', 'workEndField', '18:00'),
924
- textField('lunchStart', 'lunchStartField', '12:00'),
925
- textField('lunchEnd', 'lunchEndField', '13:00'),
926
- checkField('lunchEnabled', 'lunchEnabledField'),
927
- checkField('lunchPaid', 'lunchPaidField'),
1124
+ React.createElement('div', { key: 'body', style: { marginTop: 7 } }, [
1125
+ row('salary', t('salaryField'), [
1126
+ React.createElement('input', {
1127
+ key: 'i', type: 'text', inputMode: 'decimal', value: form.monthlySalary, placeholder: '10000',
1128
+ onChange: (event) => setField('monthlySalary', event.target.value), style: inputStyle,
1129
+ }),
1130
+ React.createElement('span', { key: 'u', style: unitStyle }, t('salaryUnit')),
1131
+ ]),
1132
+ row('worktime', t('groupWorkTime'), [
1133
+ timeRange('workStart', 'workEnd', t('groupWorkTime'), false),
1134
+ ]),
1135
+ row('lunch', t('groupLunch'), [
1136
+ // 时间在前、两个开关在后(放到行尾)
1137
+ timeRange('lunchStart', 'lunchEnd', t('groupLunch'), lunchDisabled),
1138
+ toggle('lunchEnabled', 'lunchEnabledShort', false),
1139
+ toggle('lunchPaid', 'lunchPaidShort', lunchDisabled),
1140
+ ]),
1141
+ row('refresh', t('refreshRate'), [
1142
+ React.createElement('select', {
1143
+ key: 'r',
1144
+ value: String(form.updateMs),
1145
+ onChange: (event) => setField('updateMs', event.target.value),
1146
+ style: { ...selectStyle, width: 128, padding: '4px 6px' },
1147
+ }, RATE_OPTIONS.map(([value, label]) => React.createElement('option', { key: value, value }, label))),
1148
+ ]),
928
1149
  ]),
929
1150
  React.createElement('div', {
930
1151
  key: 'actions',
931
- style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginTop: 11 },
1152
+ style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 7, paddingTop: 7, borderTop: `1px solid ${C.border1}` },
932
1153
  }, [
933
1154
  React.createElement('button', {
934
1155
  key: 'save',
@@ -936,30 +1157,29 @@ function SettingsSection(props) {
936
1157
  onClick: onSave,
937
1158
  disabled: save.status === 'saving',
938
1159
  style: {
939
- // 金色按钮:与主卡金额、节假日强调同一套金色系;深棕字保证任何主题下都有对比度
940
1160
  border: `1px solid color-mix(in srgb, ${GOLD_TINT} 72%, rgba(0,0,0,.18))`,
941
1161
  color: '#3A2A05',
942
1162
  background: save.status === 'saving'
943
1163
  ? `color-mix(in srgb, ${GOLD_TINT} 52%, #fff)`
944
1164
  : 'linear-gradient(180deg, #F8DC86 0%, #EFC258 52%, #D99D2C 100%)',
945
1165
  borderRadius: 8,
946
- padding: '7px 20px',
947
- fontSize: 13,
1166
+ padding: '5px 16px',
1167
+ fontSize: 12.5,
948
1168
  fontWeight: 700,
949
1169
  letterSpacing: '.3px',
950
1170
  cursor: save.status === 'saving' ? 'default' : 'pointer',
951
1171
  opacity: save.status === 'saving' ? 0.72 : 1,
952
- boxShadow: `0 2px 10px color-mix(in srgb, ${GOLD_TINT} 36%, transparent), inset 0 1px 0 rgba(255,255,255,.45)`,
1172
+ boxShadow: `0 2px 9px color-mix(in srgb, ${GOLD_TINT} 34%, transparent), inset 0 1px 0 rgba(255,255,255,.45)`,
953
1173
  textShadow: '0 1px 0 rgba(255,255,255,.28)',
954
1174
  },
955
1175
  }, save.status === 'saving' ? t('saving') : t('save')),
956
1176
  save.status === 'ok'
957
- ? React.createElement('span', { key: 'ok', style: { fontSize: 12, color: C.ok } }, save.message || t('saved'))
1177
+ ? React.createElement('span', { key: 'ok', style: { fontSize: 11.5, color: C.ok } }, save.message || t('saved'))
958
1178
  : null,
959
1179
  save.status === 'error'
960
1180
  ? React.createElement('span', {
961
1181
  key: 'err',
962
- style: { fontSize: 12, color: C.error, flex: '1 1 220px', whiteSpace: 'pre-wrap' },
1182
+ style: { fontSize: 11.5, color: C.error, flex: '1 1 200px', whiteSpace: 'pre-wrap' },
963
1183
  }, `${t('saveFailed')}${save.errors.join(';')}`)
964
1184
  : null,
965
1185
  ]),
@@ -973,7 +1193,6 @@ function NiumaPanel(props) {
973
1193
  const t = props && typeof props.t === 'function' ? props.t : fallbackTranslate;
974
1194
 
975
1195
  const remote = useRemoteConfig();
976
- const now = useSecondClock();
977
1196
 
978
1197
  // 服务端配置 -> 规范化配置;坏数据/缺字段一律退回 DEFAULTS(计算不会抛)。
979
1198
  const cfg = React.useMemo(() => {
@@ -987,7 +1206,11 @@ function NiumaPanel(props) {
987
1206
  }
988
1207
  }, [remote.data]);
989
1208
 
990
- // 每秒重算(core 是纯函数,重算代价极小;已下班/休息日会稳定在日工资或 0)。
1209
+ // 刷新频率:1000=按秒;16≈屏幕刷新率(连续滚动);中间值为毫秒级
1210
+ const updateMs = cfg.turbo === true ? 16 : (Number.isInteger(cfg.updateMs) ? cfg.updateMs : 1000);
1211
+ const now = useClock(updateMs);
1212
+
1213
+ // 每次刷新重算(core 是纯函数,重算代价极小;已下班/休息日会稳定在日工资或 0)。
991
1214
  const snap = React.useMemo(() => earnedToday(cfg, now), [cfg, now]);
992
1215
  const rates = snap && snap.rates ? snap.rates : computeRates(cfg, snap && snap.dateKey);
993
1216
  const baseForm = React.useMemo(() => formFromConfig(cfg), [cfg]);
@@ -1108,6 +1331,28 @@ function NiumaPanel(props) {
1108
1331
  });
1109
1332
  };
1110
1333
 
1334
+ // 快速保存:主金额小数位这种即时偏好,不走设置表单的「保存」流程
1335
+ const [quickError, setQuickError] = React.useState('');
1336
+ const postConfig = React.useCallback((patch) => {
1337
+ if (typeof fetch !== 'function') return;
1338
+ fetch(`${ROUTE_BASE}/settings`, {
1339
+ method: 'POST',
1340
+ headers: { 'content-type': 'application/json' },
1341
+ credentials: 'same-origin',
1342
+ body: JSON.stringify(patch),
1343
+ })
1344
+ .then(async (response) => {
1345
+ let body = null;
1346
+ try { body = await response.json(); } catch (error) { body = null; }
1347
+ if (!response.ok || !body || body.ok !== true) throw new Error((body && body.error) || `HTTP ${response.status}`);
1348
+ setQuickError('');
1349
+ remote.refresh();
1350
+ })
1351
+ .catch((error) => setQuickError(String((error && error.message) || error)));
1352
+ }, [remote.refresh]);
1353
+ const onDecimalsChange = (value) => postConfig({ decimals: value });
1354
+ const onTurboChange = (checked) => postConfig({ turbo: Boolean(checked) });
1355
+
1111
1356
  const holidaySource = (remote.data && remote.data.holidaySource) || HOLIDAY_SOURCE;
1112
1357
  const banner = remote.status === 'failed'
1113
1358
  ? {
@@ -1206,7 +1451,7 @@ function NiumaPanel(props) {
1206
1451
  ])
1207
1452
  : null,
1208
1453
 
1209
- React.createElement(HeroCard, { key: 'hero', t, snap }),
1454
+ React.createElement(HeroCard, { key: 'hero', t, snap, decimals: cfg.decimals, onDecimalsChange, turbo: cfg.turbo, onTurboChange }),
1210
1455
  React.createElement(CountdownRow, { key: 'countdown', t, snap }),
1211
1456
  React.createElement('div', {
1212
1457
  key: 'ratesHead',
package/src/core.mjs CHANGED
@@ -27,6 +27,12 @@ export const DEFAULTS = Object.freeze({
27
27
  lunchPaid: false,
28
28
  holidays: DEFAULT_HOLIDAYS,
29
29
  extraWorkdays: DEFAULT_EXTRA_WORKDAYS,
30
+ /** 主金额小数位(0-6) */
31
+ decimals: 4,
32
+ /** 面板刷新间隔 ms:1000=按秒;16≈屏幕刷新率(连续滚动);中间值为毫秒级 */
33
+ updateMs: 1000,
34
+ /** 加速(滚动模式):true 时强制毫秒级刷新(≈屏幕刷新率),数字连续滚动 */
35
+ turbo: false,
30
36
  });
31
37
 
32
38
  const SALARY_MAX = 10_000_000;
@@ -159,6 +165,23 @@ export function normalizeConfig(patch) {
159
165
  merged.lunchEnabled = merged.lunchEnabled !== false;
160
166
  merged.lunchPaid = merged.lunchPaid === true;
161
167
 
168
+ // 展示/刷新偏好:主金额小数位 0-6;刷新间隔 16-1000ms(16 ≈ 屏幕刷新率);加速开关
169
+ merged.turbo = merged.turbo === true;
170
+ const decimals = Number(merged.decimals);
171
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 6) {
172
+ errors.push('小数位数需要是 0~6 的整数');
173
+ merged.decimals = Number.isInteger(decimals) ? decimals : DEFAULTS.decimals;
174
+ } else {
175
+ merged.decimals = decimals;
176
+ }
177
+ const updateMs = Number(merged.updateMs);
178
+ if (!Number.isInteger(updateMs) || updateMs < 16 || updateMs > 1000) {
179
+ errors.push('刷新间隔需要是 16~1000ms 的整数');
180
+ merged.updateMs = Number.isInteger(updateMs) ? updateMs : DEFAULTS.updateMs;
181
+ } else {
182
+ merged.updateMs = updateMs;
183
+ }
184
+
162
185
  // 日期列表
163
186
  const holidays = normalizeDateList(merged.holidays);
164
187
  const extraWorkdays = normalizeDateList(merged.extraWorkdays);
@@ -342,6 +365,16 @@ export function earnedToday(config, now) {
342
365
  const secondsToStart = workday && nowSec < startSec ? startSec - nowSec : 0;
343
366
  const secondsToEnd = workday && nowSec < endSec ? endSec - nowSec : 0;
344
367
 
368
+ // 本月工作天数进度:已完成 = 今天之前的完整工作日 +(今天已下班则 +1);
369
+ // 「今天」在上班/午休中时算剩余(还没干完),这样「已工作 + 剩余 = 本月总工作日」。
370
+ const monthWorkdays = rates.monthlyWorkdays;
371
+ let monthWorkdaysDone = 0;
372
+ for (let day = 1; day < date.getDate(); day += 1) {
373
+ if (isWorkday(dateKeyOf(new Date(date.getFullYear(), date.getMonth(), day)), config)) monthWorkdaysDone += 1;
374
+ }
375
+ if (workday && status === 'off') monthWorkdaysDone += 1;
376
+ const monthWorkdaysLeft = Math.max(0, monthWorkdays - monthWorkdaysDone);
377
+
345
378
  return {
346
379
  dateKey: key,
347
380
  isWorkday: workday,
@@ -352,6 +385,9 @@ export function earnedToday(config, now) {
352
385
  todayTarget,
353
386
  todayProgress,
354
387
  monthEarned,
388
+ monthWorkdays,
389
+ monthWorkdaysDone,
390
+ monthWorkdaysLeft,
355
391
  secondsToStart,
356
392
  secondsToEnd,
357
393
  rates,