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/CHANGELOG.md +35 -0
- package/README.md +4 -0
- package/docs/preview-light-dark.png +0 -0
- package/lib/client.js +400 -75
- package/lib/client.js.map +2 -2
- package/package.json +1 -1
- package/src/client/index.js +320 -75
- package/src/core.mjs +36 -0
package/package.json
CHANGED
package/src/client/index.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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(
|
|
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,
|
|
650
|
+
fontSize: 46, lineHeight: 1.12, fontWeight: 800,
|
|
517
651
|
...GOLD,
|
|
518
652
|
...NUM,
|
|
519
653
|
...(rest ? { opacity: 0.62 } : {}),
|
|
520
654
|
},
|
|
521
|
-
}
|
|
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: '
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
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
|
|
872
|
-
key:
|
|
873
|
-
|
|
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
|
-
|
|
876
|
-
React.createElement('
|
|
877
|
-
|
|
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
|
|
1089
|
+
const toggle = (field, labelKey, disabled) => React.createElement('label', {
|
|
887
1090
|
key: field,
|
|
888
1091
|
style: {
|
|
889
|
-
display: 'flex', alignItems: 'center', gap:
|
|
890
|
-
|
|
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
|
-
|
|
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:
|
|
1122
|
+
style: { cursor: 'pointer', fontSize: 12.5, fontWeight: 650, color: C.label, userSelect: 'none' },
|
|
908
1123
|
}, t('settings')),
|
|
909
|
-
React.createElement('div', {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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,
|
|
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: '
|
|
947
|
-
fontSize:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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,
|