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 CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  本文件记录用户可感知的变化。版本号遵循语义化版本。
4
4
 
5
+ ## 0.2.2 — 2026-10-22
6
+
7
+ ### 新增:主金额小数位 + 刷新频率 + 滚动数字/加速
8
+
9
+ - **主金额改连续「里程表」滚动**:每一位按位权连续转动——最后一位小数转得最快(0-9 快速循环),
10
+ 前一位慢 10 倍,整数位几乎不动;不是整块金额跳变,而是数字持续往上滚
11
+ - **加速开关**:主金额右侧「加速」勾选后强制毫秒级刷新(`requestAnimationFrame`,≈16ms),
12
+ 滚轮每帧更新,低位小数飞快转动、高位慢慢进位
13
+ - **小数位数可选(0–6,默认 4)**:主卡金额右侧「小数 [n] 位」,改完即时保存、立即生效
14
+ - **刷新频率可调**:设置区新增「刷新频率」行
15
+ - 按秒(1s,默认,最省电)
16
+ - 250 ms / 100 ms / 50 ms(毫秒级滚动)
17
+ - 刷新率(≈16 ms,requestAnimationFrame,跟随屏幕刷新连续跳动)
18
+ - core 新增配置 `decimals`(0–6 整数)、`updateMs`(16–1000 整数)与 `turbo`(布尔),宿主写接口同样校验
19
+ - 刷新由 `useClock(updateMs)` 驱动:≥50ms 用 setInterval,≤16ms 用 rAF
20
+
5
21
  ## 0.2.1 — 2026-10-21
6
22
 
7
23
  ### 新增:本月工作天数进度
package/README.md CHANGED
@@ -32,6 +32,9 @@
32
32
  - 节假日数据内置在插件中(2026 官方 / 2027 预估),设置区不再需要手工维护一长串日期;2027 官方安排公布后随插件更新
33
33
  - 午休可设置起止,**可勾选午休是否算工时**;午休不算工时则午休期间金额冻结
34
34
  - 状态徽标:休息日 / 未上班 / 搬砖中 / 午休中 / 已下班
35
+ - 主金额是**连续里程表滚动**:小数末位飞快转、前一位慢 10 倍、整数位慢慢进位;勾选「加速」按屏幕刷新率每帧滚动
36
+ - 主金额**小数位可选(0–6)**,右侧下拉即时生效
37
+ - **刷新频率可选**:按秒 / 250ms / 100ms / 50ms / 屏幕刷新率(≈16ms 连续滚动)
35
38
  - 今日进度条、距上班/距下班倒计时
36
39
  - **本月已工作 / 剩余工作日**(与工资口径一致:节假日、周末、调休都算进去)
37
40
 
Binary file
package/lib/client.js CHANGED
@@ -187,7 +187,13 @@ var DEFAULTS = Object.freeze({
187
187
  lunchEnd: "13:00",
188
188
  lunchPaid: false,
189
189
  holidays: DEFAULT_HOLIDAYS,
190
- extraWorkdays: DEFAULT_EXTRA_WORKDAYS
190
+ extraWorkdays: DEFAULT_EXTRA_WORKDAYS,
191
+ /** 主金额小数位(0-6) */
192
+ decimals: 4,
193
+ /** 面板刷新间隔 ms:1000=按秒;16≈屏幕刷新率(连续滚动);中间值为毫秒级 */
194
+ updateMs: 1e3,
195
+ /** 加速(滚动模式):true 时强制毫秒级刷新(≈屏幕刷新率),数字连续滚动 */
196
+ turbo: false
191
197
  });
192
198
  var SALARY_MAX = 1e7;
193
199
  var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -266,6 +272,21 @@ function normalizeConfig(patch) {
266
272
  }
267
273
  merged.lunchEnabled = merged.lunchEnabled !== false;
268
274
  merged.lunchPaid = merged.lunchPaid === true;
275
+ merged.turbo = merged.turbo === true;
276
+ const decimals = Number(merged.decimals);
277
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 6) {
278
+ errors.push("\u5C0F\u6570\u4F4D\u6570\u9700\u8981\u662F 0~6 \u7684\u6574\u6570");
279
+ merged.decimals = Number.isInteger(decimals) ? decimals : DEFAULTS.decimals;
280
+ } else {
281
+ merged.decimals = decimals;
282
+ }
283
+ const updateMs = Number(merged.updateMs);
284
+ if (!Number.isInteger(updateMs) || updateMs < 16 || updateMs > 1e3) {
285
+ errors.push("\u5237\u65B0\u95F4\u9694\u9700\u8981\u662F 16~1000ms \u7684\u6574\u6570");
286
+ merged.updateMs = Number.isInteger(updateMs) ? updateMs : DEFAULTS.updateMs;
287
+ } else {
288
+ merged.updateMs = updateMs;
289
+ }
269
290
  const holidays = normalizeDateList(merged.holidays);
270
291
  const extraWorkdays = normalizeDateList(merged.extraWorkdays);
271
292
  errors.push(...holidays.errors, ...extraWorkdays.errors);
@@ -588,6 +609,12 @@ var zh = {
588
609
  "configFailed": "\u8BBE\u7F6E\u8BFB\u53D6\u5931\u8D25\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u503C",
589
610
  "retry": "\u91CD\u8BD5",
590
611
  "settings": "\u8BBE\u7F6E",
612
+ "decimalsLabel": "\u5C0F\u6570",
613
+ "decimalsUnit": "\u4F4D",
614
+ "turboLabel": "\u52A0\u901F",
615
+ "refreshRate": "\u5237\u65B0\u9891\u7387",
616
+ "rateSecond": "\u6309\u79D2\uFF081s\uFF09",
617
+ "rateFrame": "\u5237\u65B0\u7387\uFF08\u224816ms\uFF09",
591
618
  "settingsHint": "\u6539\u5B8C\u70B9\u300C\u4FDD\u5B58\u300D\u751F\u6548\uFF1B\u683C\u5F0F\u4E0D\u5BF9\u4F1A\u5728\u4E0B\u65B9\u7EA2\u5B57\u63D0\u793A\uFF0C\u4E0D\u5F71\u54CD\u8BA1\u65F6\u3002",
592
619
  "salaryField": "\u5B9E\u6536\u6708\u85AA",
593
620
  "groupSalary": "\u5DE5\u8D44",
@@ -686,6 +713,12 @@ var en = {
686
713
  "configFailed": "Failed to load settings, using defaults",
687
714
  "retry": "Retry",
688
715
  "settings": "Settings",
716
+ "decimalsLabel": "Decimals",
717
+ "decimalsUnit": "dp",
718
+ "turboLabel": "Turbo",
719
+ "refreshRate": "Refresh rate",
720
+ "rateSecond": "Per second (1s)",
721
+ "rateFrame": "Display rate (~16ms)",
689
722
  "settingsHint": "Click Save to apply. Invalid input is shown in red below; the ticker keeps running.",
690
723
  "salaryField": "Net monthly salary",
691
724
  "groupSalary": "Salary",
@@ -763,7 +796,8 @@ function formFromConfig(config) {
763
796
  lunchEnabled: config.lunchEnabled !== false,
764
797
  lunchStart: String(config.lunchStart == null ? "" : config.lunchStart),
765
798
  lunchEnd: String(config.lunchEnd == null ? "" : config.lunchEnd),
766
- lunchPaid: config.lunchPaid === true
799
+ lunchPaid: config.lunchPaid === true,
800
+ updateMs: String(config.updateMs == null ? 1e3 : config.updateMs)
767
801
  };
768
802
  }
769
803
  function patchFromForm(form) {
@@ -775,7 +809,8 @@ function patchFromForm(form) {
775
809
  lunchEnabled: Boolean(form.lunchEnabled),
776
810
  lunchStart: String(form.lunchStart || "").trim(),
777
811
  lunchEnd: String(form.lunchEnd || "").trim(),
778
- lunchPaid: Boolean(form.lunchPaid)
812
+ lunchPaid: Boolean(form.lunchPaid),
813
+ updateMs: Number(form.updateMs)
779
814
  };
780
815
  }
781
816
  function useRemoteConfig() {
@@ -811,18 +846,21 @@ function useRemoteConfig() {
811
846
  const refresh = import_react.default.useCallback(() => setTick((n) => n + 1), []);
812
847
  return { ...state, refresh };
813
848
  }
814
- function useSecondClock() {
849
+ function useClock(updateMs) {
815
850
  const [now, setNow] = import_react.default.useState(() => /* @__PURE__ */ new Date());
816
851
  import_react.default.useEffect(() => {
817
- let lastSecond = -1;
818
- const timer = setInterval(() => {
819
- const second = Math.floor(Date.now() / 1e3);
820
- if (second === lastSecond) return;
821
- lastSecond = second;
822
- setNow(/* @__PURE__ */ new Date());
823
- }, 250);
852
+ if (updateMs <= 16) {
853
+ let raf = 0;
854
+ const loop = () => {
855
+ setNow(/* @__PURE__ */ new Date());
856
+ raf = requestAnimationFrame(loop);
857
+ };
858
+ raf = requestAnimationFrame(loop);
859
+ return () => cancelAnimationFrame(raf);
860
+ }
861
+ const timer = setInterval(() => setNow(/* @__PURE__ */ new Date()), updateMs);
824
862
  return () => clearInterval(timer);
825
- }, []);
863
+ }, [updateMs]);
826
864
  return now;
827
865
  }
828
866
  function NiumaIcon(props) {
@@ -898,10 +936,106 @@ function SectionTitle(props) {
898
936
  props.hint ? import_react.default.createElement("span", { key: "h", style: { fontSize: 10.5, color: C.label3, ...NUM } }, props.hint) : null
899
937
  ]);
900
938
  }
939
+ var ROLL_BLOCKS = Array.from({ length: 11 }, (_, i) => String(i % 10));
940
+ function RollingDigit(props) {
941
+ const heightEm = 1.22;
942
+ const widthEm = 0.62;
943
+ const pos = Number.isFinite(props.position) ? props.position : 0;
944
+ const wheel = (pos % 10 + 10) % 10;
945
+ return import_react.default.createElement("span", {
946
+ "aria-hidden": "true",
947
+ style: {
948
+ display: "inline-block",
949
+ position: "relative",
950
+ overflow: "hidden",
951
+ width: `${widthEm}em`,
952
+ height: `${heightEm}em`,
953
+ verticalAlign: "baseline"
954
+ }
955
+ }, [
956
+ import_react.default.createElement("span", {
957
+ key: "ghost",
958
+ style: { visibility: "hidden", display: "block", height: `${heightEm}em`, lineHeight: `${heightEm}em` }
959
+ }, "0"),
960
+ import_react.default.createElement("span", {
961
+ key: "strip",
962
+ style: {
963
+ position: "absolute",
964
+ left: 0,
965
+ top: 0,
966
+ display: "flex",
967
+ flexDirection: "column",
968
+ transform: `translateY(${-(wheel * heightEm)}em)`,
969
+ willChange: "transform"
970
+ }
971
+ }, ROLL_BLOCKS.map((value, i) => import_react.default.createElement("span", {
972
+ key: i,
973
+ style: { display: "block", height: `${heightEm}em`, lineHeight: `${heightEm}em` }
974
+ }, value)))
975
+ ]);
976
+ }
977
+ function RollingMoney(props) {
978
+ const decimals = Math.max(0, Math.min(6, Number.isInteger(props.decimals) ? props.decimals : 4));
979
+ const text = formatMoney(props.value, decimals);
980
+ const scale = 10 ** decimals;
981
+ const chars = text.split("");
982
+ const significances = new Array(chars.length).fill(-1);
983
+ let seen = 0;
984
+ for (let i = chars.length - 1; i >= 0; i -= 1) {
985
+ if (/[0-9]/.test(chars[i])) {
986
+ significances[i] = seen;
987
+ seen += 1;
988
+ }
989
+ }
990
+ return import_react.default.createElement("span", {
991
+ style: {
992
+ display: "inline-flex",
993
+ alignItems: "baseline",
994
+ letterSpacing: 0,
995
+ position: "relative",
996
+ // 等宽字体保证每个数字轮宽度稳定(不裁字、不抖动)
997
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
998
+ ...props.style || {}
999
+ }
1000
+ }, [
1001
+ import_react.default.createElement("span", {
1002
+ key: "sr",
1003
+ style: {
1004
+ position: "absolute",
1005
+ width: 1,
1006
+ height: 1,
1007
+ padding: 0,
1008
+ margin: -1,
1009
+ overflow: "hidden",
1010
+ clip: "rect(0,0,0,0)",
1011
+ whiteSpace: "nowrap",
1012
+ border: 0
1013
+ }
1014
+ }, text),
1015
+ import_react.default.createElement("span", {
1016
+ key: "visual",
1017
+ "aria-hidden": "true",
1018
+ style: { display: "inline-flex", alignItems: "baseline" }
1019
+ }, chars.map((char, i) => {
1020
+ if (!/[0-9]/.test(char)) {
1021
+ return import_react.default.createElement("span", {
1022
+ key: `s${i}`,
1023
+ style: { display: "inline-block", lineHeight: "1.22em" }
1024
+ }, char);
1025
+ }
1026
+ const position = (Number(props.value) || 0) * scale / 10 ** significances[i];
1027
+ return import_react.default.createElement(RollingDigit, { key: `d${i}`, position });
1028
+ }))
1029
+ ]);
1030
+ }
901
1031
  function HeroCard(props) {
902
1032
  const t = props.t;
903
1033
  const snap = props.snap;
904
1034
  const rates = snap.rates;
1035
+ const decimals = Number.isInteger(props.decimals) ? props.decimals : 4;
1036
+ const onDecimalsChange = props.onDecimalsChange;
1037
+ const turbo = props.turbo === true;
1038
+ const onTurboChange = props.onTurboChange;
905
1039
  const progress = Math.max(0, Math.min(1, Number(snap.todayProgress) || 0));
906
1040
  const percentText = `${(progress * 100).toFixed(1)}%`;
907
1041
  const live = snap.status === "working";
@@ -959,22 +1093,83 @@ function HeroCard(props) {
959
1093
  key: "amt",
960
1094
  style: { display: "flex", alignItems: "baseline", gap: 7, marginTop: 4, flexWrap: "wrap" }
961
1095
  }, [
962
- import_react.default.createElement("span", {
1096
+ import_react.default.createElement(RollingMoney, {
963
1097
  key: "val",
1098
+ value: snap.todayEarned,
1099
+ decimals,
964
1100
  style: {
965
1101
  fontSize: 46,
966
1102
  lineHeight: 1.12,
967
1103
  fontWeight: 800,
968
- letterSpacing: "-1.2px",
969
1104
  ...GOLD,
970
1105
  ...NUM,
971
1106
  ...rest ? { opacity: 0.62 } : {}
972
1107
  }
973
- }, formatMoney(snap.todayEarned, 4)),
1108
+ }),
974
1109
  import_react.default.createElement("span", {
975
1110
  key: "unit",
976
1111
  style: { fontSize: 18, fontWeight: 700, color: C.label2, ...NUM }
977
- }, "\u5143")
1112
+ }, "\u5143"),
1113
+ import_react.default.createElement("label", {
1114
+ key: "dec",
1115
+ title: t("decimalsLabel"),
1116
+ style: {
1117
+ marginLeft: "auto",
1118
+ display: "inline-flex",
1119
+ alignItems: "center",
1120
+ gap: 3,
1121
+ fontSize: 10,
1122
+ color: C.label3,
1123
+ ...NUM
1124
+ }
1125
+ }, [
1126
+ import_react.default.createElement("span", { key: "l" }, t("decimalsLabel")),
1127
+ import_react.default.createElement("select", {
1128
+ key: "s",
1129
+ value: String(decimals),
1130
+ "aria-label": t("decimalsLabel"),
1131
+ onChange: (event) => onDecimalsChange && onDecimalsChange(Number(event.target.value)),
1132
+ style: {
1133
+ height: 22,
1134
+ padding: "0 2px",
1135
+ borderRadius: 6,
1136
+ border: `1px solid ${C.border1}`,
1137
+ background: C.layer2,
1138
+ color: C.label2,
1139
+ fontSize: 10.5,
1140
+ fontVariantNumeric: "tabular-nums",
1141
+ outline: "none",
1142
+ cursor: "pointer"
1143
+ }
1144
+ }, [0, 1, 2, 3, 4, 5, 6].map((n) => import_react.default.createElement("option", { key: n, value: String(n) }, String(n)))),
1145
+ import_react.default.createElement("span", { key: "u" }, t("decimalsUnit"))
1146
+ ]),
1147
+ import_react.default.createElement("label", {
1148
+ key: "turbo",
1149
+ title: t("turboLabel"),
1150
+ style: {
1151
+ display: "inline-flex",
1152
+ alignItems: "center",
1153
+ gap: 3,
1154
+ fontSize: 10,
1155
+ padding: "1px 6px",
1156
+ borderRadius: 999,
1157
+ border: `1px solid ${turbo ? `color-mix(in srgb, ${GOLD_TINT} 60%, ${C.border1})` : C.border1}`,
1158
+ background: turbo ? `color-mix(in srgb, ${GOLD_TINT} 16%, transparent)` : "transparent",
1159
+ color: turbo ? C.label2 : C.label3,
1160
+ cursor: "pointer",
1161
+ ...NUM
1162
+ }
1163
+ }, [
1164
+ import_react.default.createElement("input", {
1165
+ key: "i",
1166
+ type: "checkbox",
1167
+ checked: turbo,
1168
+ onChange: (event) => onTurboChange && onTurboChange(event.target.checked),
1169
+ style: { accentColor: GOLD_TINT, width: 12, height: 12, margin: 0, cursor: "inherit" }
1170
+ }),
1171
+ import_react.default.createElement("span", { key: "l" }, t("turboLabel"))
1172
+ ])
978
1173
  ]),
979
1174
  import_react.default.createElement("div", {
980
1175
  key: "chips",
@@ -1439,6 +1634,13 @@ function SettingsSection(props) {
1439
1634
  import_react.default.createElement("span", { key: "l" }, t(labelKey))
1440
1635
  ]);
1441
1636
  const lunchDisabled = !form.lunchEnabled;
1637
+ const RATE_OPTIONS = [
1638
+ ["1000", t("rateSecond")],
1639
+ ["250", "250 ms"],
1640
+ ["100", "100 ms"],
1641
+ ["50", "50 ms"],
1642
+ ["16", t("rateFrame")]
1643
+ ];
1442
1644
  return import_react.default.createElement("details", {
1443
1645
  open: true,
1444
1646
  style: { background: C.layer1, border: `1px solid ${C.border1}`, borderRadius: 12, padding: "10px 12px" }
@@ -1468,6 +1670,14 @@ function SettingsSection(props) {
1468
1670
  timeRange("lunchStart", "lunchEnd", t("groupLunch"), lunchDisabled),
1469
1671
  toggle("lunchEnabled", "lunchEnabledShort", false),
1470
1672
  toggle("lunchPaid", "lunchPaidShort", lunchDisabled)
1673
+ ]),
1674
+ row("refresh", t("refreshRate"), [
1675
+ import_react.default.createElement("select", {
1676
+ key: "r",
1677
+ value: String(form.updateMs),
1678
+ onChange: (event) => setField("updateMs", event.target.value),
1679
+ style: { ...selectStyle, width: 128, padding: "4px 6px" }
1680
+ }, RATE_OPTIONS.map(([value, label]) => import_react.default.createElement("option", { key: value, value }, label)))
1471
1681
  ])
1472
1682
  ]),
1473
1683
  import_react.default.createElement("div", {
@@ -1505,7 +1715,6 @@ function SettingsSection(props) {
1505
1715
  function NiumaPanel(props) {
1506
1716
  const t = props && typeof props.t === "function" ? props.t : fallbackTranslate;
1507
1717
  const remote = useRemoteConfig();
1508
- const now = useSecondClock();
1509
1718
  const cfg = import_react.default.useMemo(() => {
1510
1719
  const raw = remote.data && remote.data.config ? remote.data.config : null;
1511
1720
  if (!raw) return DEFAULTS;
@@ -1516,6 +1725,8 @@ function NiumaPanel(props) {
1516
1725
  return DEFAULTS;
1517
1726
  }
1518
1727
  }, [remote.data]);
1728
+ const updateMs = cfg.turbo === true ? 16 : Number.isInteger(cfg.updateMs) ? cfg.updateMs : 1e3;
1729
+ const now = useClock(updateMs);
1519
1730
  const snap = import_react.default.useMemo(() => earnedToday(cfg, now), [cfg, now]);
1520
1731
  const rates = snap && snap.rates ? snap.rates : computeRates(cfg, snap && snap.dateKey);
1521
1732
  const baseForm = import_react.default.useMemo(() => formFromConfig(cfg), [cfg]);
@@ -1605,6 +1816,28 @@ function NiumaPanel(props) {
1605
1816
  setSave({ status: "error", errors: [String(error && error.message || error)], message: "" });
1606
1817
  });
1607
1818
  };
1819
+ const [quickError, setQuickError] = import_react.default.useState("");
1820
+ const postConfig = import_react.default.useCallback((patch) => {
1821
+ if (typeof fetch !== "function") return;
1822
+ fetch(`${ROUTE_BASE}/settings`, {
1823
+ method: "POST",
1824
+ headers: { "content-type": "application/json" },
1825
+ credentials: "same-origin",
1826
+ body: JSON.stringify(patch)
1827
+ }).then(async (response) => {
1828
+ let body = null;
1829
+ try {
1830
+ body = await response.json();
1831
+ } catch (error) {
1832
+ body = null;
1833
+ }
1834
+ if (!response.ok || !body || body.ok !== true) throw new Error(body && body.error || `HTTP ${response.status}`);
1835
+ setQuickError("");
1836
+ remote.refresh();
1837
+ }).catch((error) => setQuickError(String(error && error.message || error)));
1838
+ }, [remote.refresh]);
1839
+ const onDecimalsChange = (value) => postConfig({ decimals: value });
1840
+ const onTurboChange = (checked) => postConfig({ turbo: Boolean(checked) });
1608
1841
  const holidaySource = remote.data && remote.data.holidaySource || HOLIDAY_SOURCE;
1609
1842
  const banner = remote.status === "failed" ? {
1610
1843
  color: C.warn,
@@ -1707,7 +1940,7 @@ function NiumaPanel(props) {
1707
1940
  }
1708
1941
  }, t("retry")) : null
1709
1942
  ]) : null,
1710
- import_react.default.createElement(HeroCard, { key: "hero", t, snap }),
1943
+ import_react.default.createElement(HeroCard, { key: "hero", t, snap, decimals: cfg.decimals, onDecimalsChange, turbo: cfg.turbo, onTurboChange }),
1711
1944
  import_react.default.createElement(CountdownRow, { key: "countdown", t, snap }),
1712
1945
  import_react.default.createElement("div", {
1713
1946
  key: "ratesHead",