dsh-niuma 0.2.1 → 0.2.3

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.
@@ -123,6 +123,16 @@ const zh = {
123
123
  'configFailed': '设置读取失败,使用默认值',
124
124
  'retry': '重试',
125
125
  'settings': '设置',
126
+ 'decimalsLabel': '小数',
127
+ 'decimalsUnit': '位',
128
+ 'turboLabel': '加速',
129
+ 'updateAvailable': '发现新版本 v{version}',
130
+ 'updateNow': '立即更新',
131
+ 'updating': '正在下载新版本…',
132
+ 'updateStaged': 'v{version} 已下载 · 重启 DSH 生效',
133
+ 'refreshRate': '刷新频率',
134
+ 'rateSecond': '按秒(1s)',
135
+ 'rateFrame': '刷新率(≈16ms)',
126
136
  'settingsHint': '改完点「保存」生效;格式不对会在下方红字提示,不影响计时。',
127
137
  'salaryField': '实收月薪',
128
138
  'groupSalary': '工资',
@@ -223,6 +233,16 @@ const en = {
223
233
  'configFailed': 'Failed to load settings, using defaults',
224
234
  'retry': 'Retry',
225
235
  'settings': 'Settings',
236
+ 'decimalsLabel': 'Decimals',
237
+ 'decimalsUnit': 'dp',
238
+ 'turboLabel': 'Turbo',
239
+ 'updateAvailable': 'New version v{version}',
240
+ 'updateNow': 'Update now',
241
+ 'updating': 'Downloading update…',
242
+ 'updateStaged': 'v{version} downloaded · restart DSH to apply',
243
+ 'refreshRate': 'Refresh rate',
244
+ 'rateSecond': 'Per second (1s)',
245
+ 'rateFrame': 'Display rate (~16ms)',
226
246
  'settingsHint': 'Click Save to apply. Invalid input is shown in red below; the ticker keeps running.',
227
247
  'salaryField': 'Net monthly salary',
228
248
  'groupSalary': 'Salary',
@@ -312,6 +332,7 @@ function formFromConfig(config) {
312
332
  lunchStart: String(config.lunchStart == null ? '' : config.lunchStart),
313
333
  lunchEnd: String(config.lunchEnd == null ? '' : config.lunchEnd),
314
334
  lunchPaid: config.lunchPaid === true,
335
+ updateMs: String(config.updateMs == null ? 1000 : config.updateMs),
315
336
  };
316
337
  }
317
338
 
@@ -326,6 +347,7 @@ function patchFromForm(form) {
326
347
  lunchStart: String(form.lunchStart || '').trim(),
327
348
  lunchEnd: String(form.lunchEnd || '').trim(),
328
349
  lunchPaid: Boolean(form.lunchPaid),
350
+ updateMs: Number(form.updateMs),
329
351
  };
330
352
  }
331
353
 
@@ -371,19 +393,63 @@ function useRemoteConfig() {
371
393
  return { ...state, refresh };
372
394
  }
373
395
 
374
- /** 当前时间:每 250ms 检查一次,只有整秒变化才 setState(每秒重算一次)。 */
375
- function useSecondClock() {
376
- const [now, setNow] = React.useState(() => new Date());
396
+ /** 版本 / 自动更新状态:挂载时查一次、每 6 小时刷新;点「立即更新」触发下载暂存。 */
397
+ function useUpdateInfo() {
398
+ const [state, setState] = React.useState({ status: 'loading', data: null, error: null });
399
+ const load = React.useCallback(() => {
400
+ if (typeof fetch !== 'function') {
401
+ setState({ status: 'failed', data: null, error: 'fetch unavailable' });
402
+ return;
403
+ }
404
+ fetch(`${ROUTE_BASE}/update`, { cache: 'no-store', credentials: 'same-origin' })
405
+ .then(async (response) => {
406
+ const body = await response.json();
407
+ if (!response.ok || !body || body.ok !== true) throw new Error((body && body.error) || `HTTP ${response.status}`);
408
+ return body.data || null;
409
+ })
410
+ .then((data) => setState({ status: 'ready', data, error: null }))
411
+ .catch((error) => setState({ status: 'failed', data: null, error: String((error && error.message) || error) }));
412
+ }, []);
377
413
  React.useEffect(() => {
378
- let lastSecond = -1;
379
- const timer = setInterval(() => {
380
- const second = Math.floor(Date.now() / 1000);
381
- if (second === lastSecond) return;
382
- lastSecond = second;
383
- setNow(new Date());
384
- }, 250);
414
+ load();
415
+ const timer = setInterval(load, 6 * 3600 * 1000);
385
416
  return () => clearInterval(timer);
417
+ }, [load]);
418
+ const download = React.useCallback(() => {
419
+ setState((prev) => ({ ...prev, status: 'updating' }));
420
+ if (typeof fetch !== 'function') return;
421
+ fetch(`${ROUTE_BASE}/update`, { method: 'POST', credentials: 'same-origin' })
422
+ .then(async (response) => {
423
+ const body = await response.json();
424
+ if (!response.ok || !body || body.ok !== true) throw new Error((body && body.error) || `HTTP ${response.status}`);
425
+ return body.data || null;
426
+ })
427
+ .then((data) => setState({ status: 'ready', data, error: null }))
428
+ .catch((error) => setState({ status: 'failed', data: null, error: String((error && error.message) || error) }));
386
429
  }, []);
430
+ return { ...state, reload: load, download };
431
+ }
432
+
433
+ /**
434
+ * 当前时间:按配置的刷新间隔更新。
435
+ * - updateMs >= 50:setInterval(按秒 / 200ms / 100ms)
436
+ * - updateMs <= 16:requestAnimationFrame(跟随屏幕刷新率连续滚动)
437
+ */
438
+ function useClock(updateMs) {
439
+ const [now, setNow] = React.useState(() => new Date());
440
+ React.useEffect(() => {
441
+ if (updateMs <= 16) {
442
+ let raf = 0;
443
+ const loop = () => {
444
+ setNow(new Date());
445
+ raf = requestAnimationFrame(loop);
446
+ };
447
+ raf = requestAnimationFrame(loop);
448
+ return () => cancelAnimationFrame(raf);
449
+ }
450
+ const timer = setInterval(() => setNow(new Date()), updateMs);
451
+ return () => clearInterval(timer);
452
+ }, [updateMs]);
387
453
  return now;
388
454
  }
389
455
 
@@ -471,10 +537,29 @@ function SectionTitle(props) {
471
537
  }
472
538
 
473
539
  /** 今日已赚主卡:渐变底 + 超大金额 + 进度条(视觉焦点只有这一处)。 */
540
+ /* ------------------------------------------------------------------ *
541
+ * 主金额:毫秒级刷新时数字本身高速变化(末位小数在 60fps 下每帧都在跳),
542
+ * 视觉上就是「欻欻」往上滚;不做逐位轮盘,避免滚动裁切带来的重影。
543
+ * ------------------------------------------------------------------ */
544
+ function RollingMoney(props) {
545
+ const decimals = Math.max(0, Math.min(6, Number.isInteger(props.decimals) ? props.decimals : 4));
546
+ return React.createElement('span', {
547
+ style: {
548
+ display: 'inline-block', whiteSpace: 'nowrap', letterSpacing: '-1.2px',
549
+ fontVariantNumeric: 'tabular-nums',
550
+ ...(props.style || {}),
551
+ },
552
+ }, formatMoney(props.value, decimals));
553
+ }
554
+
474
555
  function HeroCard(props) {
475
556
  const t = props.t;
476
557
  const snap = props.snap;
477
558
  const rates = snap.rates;
559
+ const decimals = Number.isInteger(props.decimals) ? props.decimals : 4;
560
+ const onDecimalsChange = props.onDecimalsChange;
561
+ const turbo = props.turbo === true;
562
+ const onTurboChange = props.onTurboChange;
478
563
  const progress = Math.max(0, Math.min(1, Number(snap.todayProgress) || 0));
479
564
  const percentText = `${(progress * 100).toFixed(1)}%`;
480
565
  const live = snap.status === 'working';
@@ -521,22 +606,64 @@ function HeroCard(props) {
521
606
  : null,
522
607
  t('earnedToday'),
523
608
  ]),
524
- React.createElement('span', { key: 'b', style: { fontSize: 11, color: C.label3, ...NUM } },
525
- `${t('todayOn')} ${snap.dateKey}`),
609
+ React.createElement('div', {
610
+ key: 'b',
611
+ style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 },
612
+ }, [
613
+ React.createElement('span', { key: 'd', style: { fontSize: 11, color: C.label3, ...NUM } },
614
+ `${t('todayOn')} ${snap.dateKey}`),
615
+ React.createElement('div', { key: 'c', style: { display: 'inline-flex', alignItems: 'center', gap: 6 } }, [
616
+ React.createElement('label', {
617
+ key: 'dec', title: t('decimalsLabel'),
618
+ style: { display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10, color: C.label3, ...NUM },
619
+ }, [
620
+ React.createElement('span', { key: 'l' }, t('decimalsLabel')),
621
+ React.createElement('select', {
622
+ key: 's', value: String(decimals), 'aria-label': t('decimalsLabel'),
623
+ onChange: (event) => onDecimalsChange && onDecimalsChange(Number(event.target.value)),
624
+ style: {
625
+ height: 22, padding: '0 2px', borderRadius: 6,
626
+ border: `1px solid ${C.border1}`, background: C.layer2, color: C.label2,
627
+ fontSize: 10.5, fontVariantNumeric: 'tabular-nums', outline: 'none', cursor: 'pointer',
628
+ },
629
+ }, [0, 1, 2, 3, 4, 5, 6].map((n) => React.createElement('option', { key: n, value: String(n) }, String(n)))),
630
+ React.createElement('span', { key: 'u' }, t('decimalsUnit')),
631
+ ]),
632
+ React.createElement('label', {
633
+ key: 'turbo', title: t('turboLabel'),
634
+ style: {
635
+ display: 'inline-flex', alignItems: 'center', gap: 3,
636
+ fontSize: 10, padding: '1px 6px', borderRadius: 999,
637
+ border: `1px solid ${turbo ? `color-mix(in srgb, ${GOLD_TINT} 60%, ${C.border1})` : C.border1}`,
638
+ background: turbo ? `color-mix(in srgb, ${GOLD_TINT} 16%, transparent)` : 'transparent',
639
+ color: turbo ? C.label2 : C.label3, cursor: 'pointer', ...NUM,
640
+ },
641
+ }, [
642
+ React.createElement('input', {
643
+ key: 'i', type: 'checkbox', checked: turbo,
644
+ onChange: (event) => onTurboChange && onTurboChange(event.target.checked),
645
+ style: { accentColor: GOLD_TINT, width: 12, height: 12, margin: 0, cursor: 'inherit' },
646
+ }),
647
+ React.createElement('span', { key: 'l' }, t('turboLabel')),
648
+ ]),
649
+ ]),
650
+ ]),
526
651
  ]),
527
652
  React.createElement('div', {
528
653
  key: 'amt',
529
654
  style: { display: 'flex', alignItems: 'baseline', gap: 7, marginTop: 4, flexWrap: 'wrap' },
530
655
  }, [
531
- React.createElement('span', {
656
+ React.createElement(RollingMoney, {
532
657
  key: 'val',
658
+ value: snap.todayEarned,
659
+ decimals,
533
660
  style: {
534
- fontSize: 46, lineHeight: 1.12, fontWeight: 800, letterSpacing: '-1.2px',
535
- ...GOLD,
661
+ fontSize: 46, lineHeight: 1.12, fontWeight: 800,
662
+ color: '#D9A441',
536
663
  ...NUM,
537
664
  ...(rest ? { opacity: 0.62 } : {}),
538
665
  },
539
- }, formatMoney(snap.todayEarned, 4)),
666
+ }),
540
667
  React.createElement('span', {
541
668
  key: 'unit',
542
669
  style: { fontSize: 18, fontWeight: 700, color: C.label2, ...NUM },
@@ -949,6 +1076,13 @@ function SettingsSection(props) {
949
1076
  ]);
950
1077
 
951
1078
  const lunchDisabled = !form.lunchEnabled;
1079
+ const RATE_OPTIONS = [
1080
+ ['1000', t('rateSecond')],
1081
+ ['250', '250 ms'],
1082
+ ['100', '100 ms'],
1083
+ ['50', '50 ms'],
1084
+ ['16', t('rateFrame')],
1085
+ ];
952
1086
 
953
1087
  return React.createElement('details', {
954
1088
  open: true,
@@ -975,6 +1109,14 @@ function SettingsSection(props) {
975
1109
  toggle('lunchEnabled', 'lunchEnabledShort', false),
976
1110
  toggle('lunchPaid', 'lunchPaidShort', lunchDisabled),
977
1111
  ]),
1112
+ row('refresh', t('refreshRate'), [
1113
+ React.createElement('select', {
1114
+ key: 'r',
1115
+ value: String(form.updateMs),
1116
+ onChange: (event) => setField('updateMs', event.target.value),
1117
+ style: { ...selectStyle, width: 128, padding: '4px 6px' },
1118
+ }, RATE_OPTIONS.map(([value, label]) => React.createElement('option', { key: value, value }, label))),
1119
+ ]),
978
1120
  ]),
979
1121
  React.createElement('div', {
980
1122
  key: 'actions',
@@ -1022,7 +1164,7 @@ function NiumaPanel(props) {
1022
1164
  const t = props && typeof props.t === 'function' ? props.t : fallbackTranslate;
1023
1165
 
1024
1166
  const remote = useRemoteConfig();
1025
- const now = useSecondClock();
1167
+ const update = useUpdateInfo();
1026
1168
 
1027
1169
  // 服务端配置 -> 规范化配置;坏数据/缺字段一律退回 DEFAULTS(计算不会抛)。
1028
1170
  const cfg = React.useMemo(() => {
@@ -1036,7 +1178,11 @@ function NiumaPanel(props) {
1036
1178
  }
1037
1179
  }, [remote.data]);
1038
1180
 
1039
- // 每秒重算(core 是纯函数,重算代价极小;已下班/休息日会稳定在日工资或 0)。
1181
+ // 刷新频率:1000=按秒;16≈屏幕刷新率(连续滚动);中间值为毫秒级
1182
+ const updateMs = cfg.turbo === true ? 16 : (Number.isInteger(cfg.updateMs) ? cfg.updateMs : 1000);
1183
+ const now = useClock(updateMs);
1184
+
1185
+ // 每次刷新重算(core 是纯函数,重算代价极小;已下班/休息日会稳定在日工资或 0)。
1040
1186
  const snap = React.useMemo(() => earnedToday(cfg, now), [cfg, now]);
1041
1187
  const rates = snap && snap.rates ? snap.rates : computeRates(cfg, snap && snap.dateKey);
1042
1188
  const baseForm = React.useMemo(() => formFromConfig(cfg), [cfg]);
@@ -1157,7 +1303,30 @@ function NiumaPanel(props) {
1157
1303
  });
1158
1304
  };
1159
1305
 
1306
+ // 快速保存:主金额小数位这种即时偏好,不走设置表单的「保存」流程
1307
+ const [quickError, setQuickError] = React.useState('');
1308
+ const postConfig = React.useCallback((patch) => {
1309
+ if (typeof fetch !== 'function') return;
1310
+ fetch(`${ROUTE_BASE}/settings`, {
1311
+ method: 'POST',
1312
+ headers: { 'content-type': 'application/json' },
1313
+ credentials: 'same-origin',
1314
+ body: JSON.stringify(patch),
1315
+ })
1316
+ .then(async (response) => {
1317
+ let body = null;
1318
+ try { body = await response.json(); } catch (error) { body = null; }
1319
+ if (!response.ok || !body || body.ok !== true) throw new Error((body && body.error) || `HTTP ${response.status}`);
1320
+ setQuickError('');
1321
+ remote.refresh();
1322
+ })
1323
+ .catch((error) => setQuickError(String((error && error.message) || error)));
1324
+ }, [remote.refresh]);
1325
+ const onDecimalsChange = (value) => postConfig({ decimals: value });
1326
+ const onTurboChange = (checked) => postConfig({ turbo: Boolean(checked) });
1327
+
1160
1328
  const holidaySource = (remote.data && remote.data.holidaySource) || HOLIDAY_SOURCE;
1329
+ const versionText = (remote.data && remote.data.version) || (update.data && update.data.currentVersion) || '';
1161
1330
  const banner = remote.status === 'failed'
1162
1331
  ? {
1163
1332
  color: C.warn,
@@ -1255,7 +1424,52 @@ function NiumaPanel(props) {
1255
1424
  ])
1256
1425
  : null,
1257
1426
 
1258
- React.createElement(HeroCard, { key: 'hero', t, snap }),
1427
+ // 版本 / 自动更新提示:已下载→提示重启;发现新版→一键更新
1428
+ update.data && update.data.staged
1429
+ ? React.createElement('div', {
1430
+ key: 'update',
1431
+ style: {
1432
+ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
1433
+ fontSize: 11.5, color: C.label2, padding: '6px 10px', borderRadius: 9,
1434
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 45%, transparent)`,
1435
+ background: `color-mix(in srgb, ${GOLD_TINT} 10%, transparent)`,
1436
+ },
1437
+ }, [
1438
+ React.createElement('span', { key: 'dot', 'aria-hidden': 'true', style: { width: 6, height: 6, borderRadius: '50%', background: GOLD_TINT } }),
1439
+ React.createElement('span', { key: 'txt', style: { flex: '1 1 160px' } },
1440
+ t('updateStaged').replace('{version}', String(update.data.stagedVersion || update.data.latestVersion || ''))),
1441
+ ])
1442
+ : update.status === 'updating'
1443
+ ? React.createElement('div', {
1444
+ key: 'update',
1445
+ style: { fontSize: 11.5, color: C.label3, padding: '2px 2px' },
1446
+ }, t('updating'))
1447
+ : update.data && update.data.hasUpdate
1448
+ ? React.createElement('div', {
1449
+ key: 'update',
1450
+ style: {
1451
+ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
1452
+ fontSize: 11.5, color: C.label2, padding: '6px 10px', borderRadius: 9,
1453
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 45%, transparent)`,
1454
+ background: `color-mix(in srgb, ${GOLD_TINT} 10%, transparent)`,
1455
+ },
1456
+ }, [
1457
+ React.createElement('span', { key: 'txt', style: { flex: '1 1 160px' } },
1458
+ t('updateAvailable').replace('{version}', String(update.data.latestVersion || ''))),
1459
+ React.createElement('button', {
1460
+ key: 'go',
1461
+ type: 'button',
1462
+ onClick: update.download,
1463
+ style: {
1464
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 70%, rgba(0,0,0,.15))`,
1465
+ color: '#3A2A05',
1466
+ background: 'linear-gradient(180deg, #F8DC86 0%, #EFC258 52%, #D99D2C 100%)',
1467
+ borderRadius: 7, padding: '3px 12px', fontSize: 11.5, fontWeight: 700, cursor: 'pointer',
1468
+ },
1469
+ }, t('updateNow')),
1470
+ ])
1471
+ : null,
1472
+ React.createElement(HeroCard, { key: 'hero', t, snap, decimals: cfg.decimals, onDecimalsChange, turbo: cfg.turbo, onTurboChange }),
1259
1473
  React.createElement(CountdownRow, { key: 'countdown', t, snap }),
1260
1474
  React.createElement('div', {
1261
1475
  key: 'ratesHead',
@@ -1316,6 +1530,20 @@ function NiumaPanel(props) {
1316
1530
  save,
1317
1531
  onSave,
1318
1532
  }),
1533
+ // 版本信息固定放在面板最底部
1534
+ React.createElement('div', {
1535
+ key: 'version',
1536
+ style: {
1537
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
1538
+ padding: '4px 0 0', fontSize: 10.5, color: C.label3, ...NUM,
1539
+ },
1540
+ }, [
1541
+ versionText ? React.createElement('span', { key: 'v' }, `牛马加速器 v${versionText}`) : null,
1542
+ update.data && update.data.staged
1543
+ ? React.createElement('span', { key: 's', style: { color: C.label3 } },
1544
+ `· ${t('updateStaged').replace('{version}', String(update.data.stagedVersion || update.data.latestVersion || ''))}`)
1545
+ : null,
1546
+ ]),
1319
1547
  ]),
1320
1548
  ]);
1321
1549
  }
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);