dsh-niuma 0.2.1 → 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 +16 -0
- package/README.md +3 -0
- package/docs/preview-light-dark.png +0 -0
- package/lib/client.js +251 -18
- package/lib/client.js.map +2 -2
- package/package.json +1 -1
- package/src/client/index.js +212 -16
- package/src/core.mjs +23 -0
package/package.json
CHANGED
package/src/client/index.js
CHANGED
|
@@ -123,6 +123,12 @@ const zh = {
|
|
|
123
123
|
'configFailed': '设置读取失败,使用默认值',
|
|
124
124
|
'retry': '重试',
|
|
125
125
|
'settings': '设置',
|
|
126
|
+
'decimalsLabel': '小数',
|
|
127
|
+
'decimalsUnit': '位',
|
|
128
|
+
'turboLabel': '加速',
|
|
129
|
+
'refreshRate': '刷新频率',
|
|
130
|
+
'rateSecond': '按秒(1s)',
|
|
131
|
+
'rateFrame': '刷新率(≈16ms)',
|
|
126
132
|
'settingsHint': '改完点「保存」生效;格式不对会在下方红字提示,不影响计时。',
|
|
127
133
|
'salaryField': '实收月薪',
|
|
128
134
|
'groupSalary': '工资',
|
|
@@ -223,6 +229,12 @@ const en = {
|
|
|
223
229
|
'configFailed': 'Failed to load settings, using defaults',
|
|
224
230
|
'retry': 'Retry',
|
|
225
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)',
|
|
226
238
|
'settingsHint': 'Click Save to apply. Invalid input is shown in red below; the ticker keeps running.',
|
|
227
239
|
'salaryField': 'Net monthly salary',
|
|
228
240
|
'groupSalary': 'Salary',
|
|
@@ -312,6 +324,7 @@ function formFromConfig(config) {
|
|
|
312
324
|
lunchStart: String(config.lunchStart == null ? '' : config.lunchStart),
|
|
313
325
|
lunchEnd: String(config.lunchEnd == null ? '' : config.lunchEnd),
|
|
314
326
|
lunchPaid: config.lunchPaid === true,
|
|
327
|
+
updateMs: String(config.updateMs == null ? 1000 : config.updateMs),
|
|
315
328
|
};
|
|
316
329
|
}
|
|
317
330
|
|
|
@@ -326,6 +339,7 @@ function patchFromForm(form) {
|
|
|
326
339
|
lunchStart: String(form.lunchStart || '').trim(),
|
|
327
340
|
lunchEnd: String(form.lunchEnd || '').trim(),
|
|
328
341
|
lunchPaid: Boolean(form.lunchPaid),
|
|
342
|
+
updateMs: Number(form.updateMs),
|
|
329
343
|
};
|
|
330
344
|
}
|
|
331
345
|
|
|
@@ -371,19 +385,26 @@ function useRemoteConfig() {
|
|
|
371
385
|
return { ...state, refresh };
|
|
372
386
|
}
|
|
373
387
|
|
|
374
|
-
/**
|
|
375
|
-
|
|
388
|
+
/**
|
|
389
|
+
* 当前时间:按配置的刷新间隔更新。
|
|
390
|
+
* - updateMs >= 50:setInterval(按秒 / 200ms / 100ms)
|
|
391
|
+
* - updateMs <= 16:requestAnimationFrame(跟随屏幕刷新率连续滚动)
|
|
392
|
+
*/
|
|
393
|
+
function useClock(updateMs) {
|
|
376
394
|
const [now, setNow] = React.useState(() => new Date());
|
|
377
395
|
React.useEffect(() => {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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);
|
|
385
406
|
return () => clearInterval(timer);
|
|
386
|
-
}, []);
|
|
407
|
+
}, [updateMs]);
|
|
387
408
|
return now;
|
|
388
409
|
}
|
|
389
410
|
|
|
@@ -471,10 +492,103 @@ function SectionTitle(props) {
|
|
|
471
492
|
}
|
|
472
493
|
|
|
473
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
|
+
|
|
474
584
|
function HeroCard(props) {
|
|
475
585
|
const t = props.t;
|
|
476
586
|
const snap = props.snap;
|
|
477
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;
|
|
478
592
|
const progress = Math.max(0, Math.min(1, Number(snap.todayProgress) || 0));
|
|
479
593
|
const percentText = `${(progress * 100).toFixed(1)}%`;
|
|
480
594
|
const live = snap.status === 'working';
|
|
@@ -528,19 +642,61 @@ function HeroCard(props) {
|
|
|
528
642
|
key: 'amt',
|
|
529
643
|
style: { display: 'flex', alignItems: 'baseline', gap: 7, marginTop: 4, flexWrap: 'wrap' },
|
|
530
644
|
}, [
|
|
531
|
-
React.createElement(
|
|
645
|
+
React.createElement(RollingMoney, {
|
|
532
646
|
key: 'val',
|
|
647
|
+
value: snap.todayEarned,
|
|
648
|
+
decimals,
|
|
533
649
|
style: {
|
|
534
|
-
fontSize: 46, lineHeight: 1.12, fontWeight: 800,
|
|
650
|
+
fontSize: 46, lineHeight: 1.12, fontWeight: 800,
|
|
535
651
|
...GOLD,
|
|
536
652
|
...NUM,
|
|
537
653
|
...(rest ? { opacity: 0.62 } : {}),
|
|
538
654
|
},
|
|
539
|
-
}
|
|
655
|
+
}),
|
|
540
656
|
React.createElement('span', {
|
|
541
657
|
key: 'unit',
|
|
542
658
|
style: { fontSize: 18, fontWeight: 700, color: C.label2, ...NUM },
|
|
543
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
|
+
]),
|
|
544
700
|
]),
|
|
545
701
|
React.createElement('div', {
|
|
546
702
|
key: 'chips',
|
|
@@ -949,6 +1105,13 @@ function SettingsSection(props) {
|
|
|
949
1105
|
]);
|
|
950
1106
|
|
|
951
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
|
+
];
|
|
952
1115
|
|
|
953
1116
|
return React.createElement('details', {
|
|
954
1117
|
open: true,
|
|
@@ -975,6 +1138,14 @@ function SettingsSection(props) {
|
|
|
975
1138
|
toggle('lunchEnabled', 'lunchEnabledShort', false),
|
|
976
1139
|
toggle('lunchPaid', 'lunchPaidShort', lunchDisabled),
|
|
977
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
|
+
]),
|
|
978
1149
|
]),
|
|
979
1150
|
React.createElement('div', {
|
|
980
1151
|
key: 'actions',
|
|
@@ -1022,7 +1193,6 @@ function NiumaPanel(props) {
|
|
|
1022
1193
|
const t = props && typeof props.t === 'function' ? props.t : fallbackTranslate;
|
|
1023
1194
|
|
|
1024
1195
|
const remote = useRemoteConfig();
|
|
1025
|
-
const now = useSecondClock();
|
|
1026
1196
|
|
|
1027
1197
|
// 服务端配置 -> 规范化配置;坏数据/缺字段一律退回 DEFAULTS(计算不会抛)。
|
|
1028
1198
|
const cfg = React.useMemo(() => {
|
|
@@ -1036,7 +1206,11 @@ function NiumaPanel(props) {
|
|
|
1036
1206
|
}
|
|
1037
1207
|
}, [remote.data]);
|
|
1038
1208
|
|
|
1039
|
-
//
|
|
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)。
|
|
1040
1214
|
const snap = React.useMemo(() => earnedToday(cfg, now), [cfg, now]);
|
|
1041
1215
|
const rates = snap && snap.rates ? snap.rates : computeRates(cfg, snap && snap.dateKey);
|
|
1042
1216
|
const baseForm = React.useMemo(() => formFromConfig(cfg), [cfg]);
|
|
@@ -1157,6 +1331,28 @@ function NiumaPanel(props) {
|
|
|
1157
1331
|
});
|
|
1158
1332
|
};
|
|
1159
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
|
+
|
|
1160
1356
|
const holidaySource = (remote.data && remote.data.holidaySource) || HOLIDAY_SOURCE;
|
|
1161
1357
|
const banner = remote.status === 'failed'
|
|
1162
1358
|
? {
|
|
@@ -1255,7 +1451,7 @@ function NiumaPanel(props) {
|
|
|
1255
1451
|
])
|
|
1256
1452
|
: null,
|
|
1257
1453
|
|
|
1258
|
-
React.createElement(HeroCard, { key: 'hero', t, snap }),
|
|
1454
|
+
React.createElement(HeroCard, { key: 'hero', t, snap, decimals: cfg.decimals, onDecimalsChange, turbo: cfg.turbo, onTurboChange }),
|
|
1259
1455
|
React.createElement(CountdownRow, { key: 'countdown', t, snap }),
|
|
1260
1456
|
React.createElement('div', {
|
|
1261
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);
|