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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-niuma",
3
- "version": "0.2.1",
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",
@@ -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
- /** 当前时间:每 250ms 检查一次,只有整秒变化才 setState(每秒重算一次)。 */
375
- function useSecondClock() {
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
- 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);
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('span', {
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, letterSpacing: '-1.2px',
650
+ fontSize: 46, lineHeight: 1.12, fontWeight: 800,
535
651
  ...GOLD,
536
652
  ...NUM,
537
653
  ...(rest ? { opacity: 0.62 } : {}),
538
654
  },
539
- }, formatMoney(snap.todayEarned, 4)),
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
- // 每秒重算(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)。
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);