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/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);
@@ -384,6 +405,13 @@ function earnedToday(config, now) {
384
405
  const monthEarned = earnedThisMonth(config, key, date, todayEarned, rates);
385
406
  const secondsToStart = workday && nowSec < startSec ? startSec - nowSec : 0;
386
407
  const secondsToEnd = workday && nowSec < endSec ? endSec - nowSec : 0;
408
+ const monthWorkdays = rates.monthlyWorkdays;
409
+ let monthWorkdaysDone = 0;
410
+ for (let day = 1; day < date.getDate(); day += 1) {
411
+ if (isWorkday(dateKeyOf(new Date(date.getFullYear(), date.getMonth(), day)), config)) monthWorkdaysDone += 1;
412
+ }
413
+ if (workday && status === "off") monthWorkdaysDone += 1;
414
+ const monthWorkdaysLeft = Math.max(0, monthWorkdays - monthWorkdaysDone);
387
415
  return {
388
416
  dateKey: key,
389
417
  isWorkday: workday,
@@ -394,6 +422,9 @@ function earnedToday(config, now) {
394
422
  todayTarget,
395
423
  todayProgress,
396
424
  monthEarned,
425
+ monthWorkdays,
426
+ monthWorkdaysDone,
427
+ monthWorkdaysLeft,
397
428
  secondsToStart,
398
429
  secondsToEnd,
399
430
  rates
@@ -571,19 +602,33 @@ var zh = {
571
602
  "paidHoursPerDay": "\u65E5\u6709\u6548\u5DE5\u65F6",
572
603
  "todayTarget": "\u4ECA\u65E5\u76EE\u6807",
573
604
  "monthEarned": "\u672C\u6708\u5DF2\u8D5A",
605
+ "monthWorkedDays": "\u672C\u6708\u5DF2\u5DE5\u4F5C",
606
+ "monthRemainingDays": "\u5269\u4F59\u5DE5\u4F5C\u65E5",
574
607
  "holidaySourceLink": "\u56FD\u52A1\u9662\u529E\u516C\u5385 2026 \u5E74\u8282\u5047\u65E5\u5B89\u6392",
575
608
  "configLoading": "\u6B63\u5728\u8BFB\u53D6\u8BBE\u7F6E\u2026",
576
609
  "configFailed": "\u8BBE\u7F6E\u8BFB\u53D6\u5931\u8D25\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u503C",
577
610
  "retry": "\u91CD\u8BD5",
578
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",
579
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",
580
- "salaryField": "\u5B9E\u6536\u6708\u85AA\uFF08\u5143/\u6708\uFF09",
619
+ "salaryField": "\u5B9E\u6536\u6708\u85AA",
620
+ "groupSalary": "\u5DE5\u8D44",
621
+ "groupWorkTime": "\u5DE5\u4F5C\u65F6\u95F4",
622
+ "groupLunch": "\u5348\u4F11",
623
+ "salaryUnit": "\u5143 / \u6708",
581
624
  "workStartField": "\u4E0A\u73ED\u65F6\u95F4",
582
625
  "workEndField": "\u4E0B\u73ED\u65F6\u95F4",
583
626
  "lunchEnabledField": "\u542F\u7528\u5348\u4F11",
627
+ "lunchEnabledShort": "\u542F\u7528",
584
628
  "lunchStartField": "\u5348\u4F11\u5F00\u59CB",
585
629
  "lunchEndField": "\u5348\u4F11\u7ED3\u675F",
586
630
  "lunchPaidField": "\u5348\u4F11\u7B97\u5DE5\u65F6",
631
+ "lunchPaidShort": "\u7B97\u5DE5\u65F6",
587
632
  "holidaysCardTitle": "\u8282\u5047\u65E5\u5B89\u6392",
588
633
  "holidays2026Label": "2026 \u5E74\uFF08\u5B98\u65B9\uFF09",
589
634
  "holidays2027Label": "2027 \u5E74\uFF08\u9884\u4F30\uFF09",
@@ -607,6 +652,7 @@ var zh = {
607
652
  "saveFailed": "\u4FDD\u5B58\u5931\u8D25\uFF1A",
608
653
  "dayUnit": "\u5929",
609
654
  "hourUnit": "\u5C0F\u65F6",
655
+ "minuteUnit": "\u5206\u949F",
610
656
  "viewLabel": "\u67E5\u770B",
611
657
  "viewCurrent": "\u672C\u6708",
612
658
  "viewYear": "\u5168\u5E74\u5E73\u5747",
@@ -660,19 +706,33 @@ var en = {
660
706
  "paidHoursPerDay": "Paid hours / day",
661
707
  "todayTarget": "Today's target",
662
708
  "monthEarned": "Earned this month",
709
+ "monthWorkedDays": "Worked this month",
710
+ "monthRemainingDays": "Workdays left",
663
711
  "holidaySourceLink": "State Council 2026 holiday schedule",
664
712
  "configLoading": "Loading settings\u2026",
665
713
  "configFailed": "Failed to load settings, using defaults",
666
714
  "retry": "Retry",
667
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)",
668
722
  "settingsHint": "Click Save to apply. Invalid input is shown in red below; the ticker keeps running.",
669
- "salaryField": "Net monthly salary (CNY)",
723
+ "salaryField": "Net monthly salary",
724
+ "groupSalary": "Salary",
725
+ "groupWorkTime": "Working hours",
726
+ "groupLunch": "Lunch break",
727
+ "salaryUnit": "CNY / month",
670
728
  "workStartField": "Work start",
671
729
  "workEndField": "Work end",
672
730
  "lunchEnabledField": "Enable lunch break",
731
+ "lunchEnabledShort": "On",
673
732
  "lunchStartField": "Lunch start",
674
733
  "lunchEndField": "Lunch end",
675
734
  "lunchPaidField": "Lunch is paid",
735
+ "lunchPaidShort": "Paid",
676
736
  "holidaysCardTitle": "Holiday calendar",
677
737
  "holidays2026Label": "2026 (official)",
678
738
  "holidays2027Label": "2027 (estimated)",
@@ -696,6 +756,7 @@ var en = {
696
756
  "saveFailed": "Save failed: ",
697
757
  "dayUnit": "d",
698
758
  "hourUnit": "h",
759
+ "minuteUnit": "min",
699
760
  "viewLabel": "View",
700
761
  "viewCurrent": "This month",
701
762
  "viewYear": "Year average",
@@ -735,7 +796,8 @@ function formFromConfig(config) {
735
796
  lunchEnabled: config.lunchEnabled !== false,
736
797
  lunchStart: String(config.lunchStart == null ? "" : config.lunchStart),
737
798
  lunchEnd: String(config.lunchEnd == null ? "" : config.lunchEnd),
738
- lunchPaid: config.lunchPaid === true
799
+ lunchPaid: config.lunchPaid === true,
800
+ updateMs: String(config.updateMs == null ? 1e3 : config.updateMs)
739
801
  };
740
802
  }
741
803
  function patchFromForm(form) {
@@ -747,7 +809,8 @@ function patchFromForm(form) {
747
809
  lunchEnabled: Boolean(form.lunchEnabled),
748
810
  lunchStart: String(form.lunchStart || "").trim(),
749
811
  lunchEnd: String(form.lunchEnd || "").trim(),
750
- lunchPaid: Boolean(form.lunchPaid)
812
+ lunchPaid: Boolean(form.lunchPaid),
813
+ updateMs: Number(form.updateMs)
751
814
  };
752
815
  }
753
816
  function useRemoteConfig() {
@@ -783,18 +846,21 @@ function useRemoteConfig() {
783
846
  const refresh = import_react.default.useCallback(() => setTick((n) => n + 1), []);
784
847
  return { ...state, refresh };
785
848
  }
786
- function useSecondClock() {
849
+ function useClock(updateMs) {
787
850
  const [now, setNow] = import_react.default.useState(() => /* @__PURE__ */ new Date());
788
851
  import_react.default.useEffect(() => {
789
- let lastSecond = -1;
790
- const timer = setInterval(() => {
791
- const second = Math.floor(Date.now() / 1e3);
792
- if (second === lastSecond) return;
793
- lastSecond = second;
794
- setNow(/* @__PURE__ */ new Date());
795
- }, 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);
796
862
  return () => clearInterval(timer);
797
- }, []);
863
+ }, [updateMs]);
798
864
  return now;
799
865
  }
800
866
  function NiumaIcon(props) {
@@ -870,10 +936,106 @@ function SectionTitle(props) {
870
936
  props.hint ? import_react.default.createElement("span", { key: "h", style: { fontSize: 10.5, color: C.label3, ...NUM } }, props.hint) : null
871
937
  ]);
872
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
+ }
873
1031
  function HeroCard(props) {
874
1032
  const t = props.t;
875
1033
  const snap = props.snap;
876
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;
877
1039
  const progress = Math.max(0, Math.min(1, Number(snap.todayProgress) || 0));
878
1040
  const percentText = `${(progress * 100).toFixed(1)}%`;
879
1041
  const live = snap.status === "working";
@@ -931,22 +1093,83 @@ function HeroCard(props) {
931
1093
  key: "amt",
932
1094
  style: { display: "flex", alignItems: "baseline", gap: 7, marginTop: 4, flexWrap: "wrap" }
933
1095
  }, [
934
- import_react.default.createElement("span", {
1096
+ import_react.default.createElement(RollingMoney, {
935
1097
  key: "val",
1098
+ value: snap.todayEarned,
1099
+ decimals,
936
1100
  style: {
937
1101
  fontSize: 46,
938
1102
  lineHeight: 1.12,
939
1103
  fontWeight: 800,
940
- letterSpacing: "-1.2px",
941
1104
  ...GOLD,
942
1105
  ...NUM,
943
1106
  ...rest ? { opacity: 0.62 } : {}
944
1107
  }
945
- }, formatMoney(snap.todayEarned, 4)),
1108
+ }),
946
1109
  import_react.default.createElement("span", {
947
1110
  key: "unit",
948
1111
  style: { fontSize: 18, fontWeight: 700, color: C.label2, ...NUM }
949
- }, "\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
+ ])
950
1173
  ]),
951
1174
  import_react.default.createElement("div", {
952
1175
  key: "chips",
@@ -1131,7 +1354,9 @@ function DetailsFooter(props) {
1131
1354
  { key: "workdays", label: t("workdaysThisMonth"), value: `${rates.monthlyWorkdays}${t("dayUnit")}` },
1132
1355
  { key: "hours", label: t("paidHoursPerDay"), value: `${trimNumber(rates.paidHours, 2)}${t("hourUnit")}` },
1133
1356
  { key: "target", label: t("todayTarget"), value: money(snap.todayTarget, 2) },
1134
- { key: "month", label: t("monthEarned"), value: money(snap.monthEarned, 2) }
1357
+ { key: "month", label: t("monthEarned"), value: money(snap.monthEarned, 2) },
1358
+ { key: "worked", label: t("monthWorkedDays"), value: `${snap.monthWorkdaysDone}${t("dayUnit")}` },
1359
+ { key: "left", label: t("monthRemainingDays"), value: `${snap.monthWorkdaysLeft}${t("dayUnit")}` }
1135
1360
  ];
1136
1361
  return import_react.default.createElement("div", {
1137
1362
  style: {
@@ -1302,84 +1527,162 @@ function SettingsSection(props) {
1302
1527
  const setCheck = props.setCheck;
1303
1528
  const save = props.save;
1304
1529
  const onSave = props.onSave;
1530
+ const rowStyle = {
1531
+ display: "flex",
1532
+ alignItems: "center",
1533
+ gap: 10,
1534
+ minHeight: 33,
1535
+ padding: "2px 0",
1536
+ borderTop: `1px solid ${C.border1}`
1537
+ };
1538
+ const rowKeyStyle = { width: 62, flex: "0 0 62px", fontSize: 11.5, color: C.label3 };
1539
+ const rowValStyle = { display: "flex", alignItems: "center", gap: 4, flexWrap: "wrap", minWidth: 0 };
1305
1540
  const inputStyle = {
1306
- width: "100%",
1307
1541
  boxSizing: "border-box",
1308
- padding: "6px 9px",
1309
- borderRadius: 8,
1542
+ width: 88,
1543
+ minHeight: 28,
1544
+ padding: "4px 8px",
1545
+ borderRadius: 7,
1310
1546
  border: `1px solid ${C.border}`,
1311
1547
  background: C.layer2,
1312
1548
  color: C.label,
1313
- fontSize: 13,
1549
+ fontSize: 12.5,
1314
1550
  fontVariantNumeric: "tabular-nums",
1315
1551
  outline: "none"
1316
1552
  };
1317
- const labelStyle = { fontSize: 10.5, color: C.label3, fontWeight: 500 };
1318
- const textField = (field, labelKey, placeholder) => import_react.default.createElement("label", {
1319
- key: field,
1320
- style: { display: "flex", flexDirection: "column", gap: 4, flex: "1 1 130px", minWidth: 112 }
1553
+ const selectStyle = {
1554
+ boxSizing: "border-box",
1555
+ width: 48,
1556
+ minHeight: 28,
1557
+ padding: "4px 2px 4px 6px",
1558
+ borderRadius: 7,
1559
+ border: `1px solid ${C.border}`,
1560
+ background: C.layer2,
1561
+ color: C.label,
1562
+ fontSize: 12.5,
1563
+ fontVariantNumeric: "tabular-nums",
1564
+ outline: "none",
1565
+ cursor: "pointer"
1566
+ };
1567
+ const unitStyle = { fontSize: 10.5, color: C.label3, whiteSpace: "nowrap" };
1568
+ const dashStyle = { fontSize: 11, color: C.label3, margin: "0 1px" };
1569
+ const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"));
1570
+ const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"));
1571
+ const row = (key, label, values) => import_react.default.createElement("div", { key, style: rowStyle }, [
1572
+ import_react.default.createElement("span", { key: "k", style: rowKeyStyle }, label),
1573
+ import_react.default.createElement("div", { key: "v", style: rowValStyle }, values)
1574
+ ]);
1575
+ const timeSelects = (field, label, disabled = false) => {
1576
+ const [rawHour = "00", rawMinute = "00"] = String(form[field] || "00:00").split(":");
1577
+ const hour = HOUR_OPTIONS.includes(rawHour) ? rawHour : "00";
1578
+ const minute = MINUTE_OPTIONS.includes(rawMinute) ? rawMinute : "00";
1579
+ const commit = (nextHour, nextMinute) => setField(field, `${nextHour}:${nextMinute}`);
1580
+ const style = { ...selectStyle, opacity: disabled ? 0.45 : 1, cursor: disabled ? "default" : "pointer" };
1581
+ return import_react.default.createElement("span", { key: field, style: { display: "inline-flex", alignItems: "center", gap: 2 } }, [
1582
+ import_react.default.createElement("select", {
1583
+ key: "h",
1584
+ value: hour,
1585
+ disabled,
1586
+ "aria-label": `${label} ${t("hourUnit")}`,
1587
+ onChange: (event) => commit(event.target.value, minute),
1588
+ style
1589
+ }, HOUR_OPTIONS.map((value) => import_react.default.createElement("option", { key: value, value }, value))),
1590
+ import_react.default.createElement("span", { key: "c", style: { color: C.label3, fontWeight: 700, fontSize: 12 } }, ":"),
1591
+ import_react.default.createElement("select", {
1592
+ key: "m",
1593
+ value: minute,
1594
+ disabled,
1595
+ "aria-label": `${label} ${t("minuteUnit")}`,
1596
+ onChange: (event) => commit(hour, event.target.value),
1597
+ style
1598
+ }, MINUTE_OPTIONS.map((value) => import_react.default.createElement("option", { key: value, value }, value)))
1599
+ ]);
1600
+ };
1601
+ const timeRange = (startField, endField, label, disabled) => import_react.default.createElement("span", {
1602
+ key: `${startField}-${endField}`,
1603
+ style: { display: "inline-flex", alignItems: "center", gap: 2 }
1321
1604
  }, [
1322
- import_react.default.createElement("span", { key: "l", style: labelStyle }, t(labelKey)),
1323
- import_react.default.createElement("input", {
1324
- key: "i",
1325
- type: "text",
1326
- value: form[field],
1327
- placeholder: placeholder || "HH:mm",
1328
- onChange: (event) => setField(field, event.target.value),
1329
- style: inputStyle
1330
- })
1605
+ timeSelects(startField, label, disabled),
1606
+ import_react.default.createElement("span", { key: "dash", style: dashStyle }, "\u2013"),
1607
+ timeSelects(endField, label, disabled)
1331
1608
  ]);
1332
- const checkField = (field, labelKey) => import_react.default.createElement("label", {
1609
+ const toggle = (field, labelKey, disabled) => import_react.default.createElement("label", {
1333
1610
  key: field,
1334
1611
  style: {
1335
- display: "flex",
1612
+ display: "inline-flex",
1336
1613
  alignItems: "center",
1337
- gap: 6,
1338
- flex: "1 1 140px",
1339
- fontSize: 12,
1340
- color: C.label2,
1341
- cursor: "pointer",
1342
- minHeight: 30
1614
+ gap: 5,
1615
+ padding: "2px 6px",
1616
+ borderRadius: 8,
1617
+ border: `1px solid ${C.border1}`,
1618
+ background: C.layer2,
1619
+ fontSize: 10.5,
1620
+ color: disabled ? C.label3 : C.label2,
1621
+ whiteSpace: "nowrap",
1622
+ cursor: disabled ? "default" : "pointer",
1623
+ opacity: disabled ? 0.55 : 1
1343
1624
  }
1344
1625
  }, [
1345
1626
  import_react.default.createElement("input", {
1346
1627
  key: "i",
1347
1628
  type: "checkbox",
1348
1629
  checked: Boolean(form[field]),
1349
- onChange: (event) => setCheck(field, event.target.checked)
1630
+ disabled,
1631
+ onChange: (event) => setCheck(field, event.target.checked),
1632
+ style: { accentColor: GOLD_TINT, width: 13, height: 13, margin: 0, cursor: "inherit" }
1350
1633
  }),
1351
1634
  import_react.default.createElement("span", { key: "l" }, t(labelKey))
1352
1635
  ]);
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
+ ];
1353
1644
  return import_react.default.createElement("details", {
1354
- style: { background: C.layer1, border: `1px solid ${C.border1}`, borderRadius: 12, padding: "11px 13px" }
1645
+ open: true,
1646
+ style: { background: C.layer1, border: `1px solid ${C.border1}`, borderRadius: 12, padding: "10px 12px" }
1355
1647
  }, [
1356
1648
  import_react.default.createElement("summary", {
1357
1649
  key: "sum",
1358
- style: { cursor: "pointer", fontSize: 13, fontWeight: 650, color: C.label, userSelect: "none" }
1650
+ style: { cursor: "pointer", fontSize: 12.5, fontWeight: 650, color: C.label, userSelect: "none" }
1359
1651
  }, t("settings")),
1360
- import_react.default.createElement("div", {
1361
- key: "hint",
1362
- style: { fontSize: 10.5, color: C.label3, margin: "5px 0 10px" }
1363
- }, t("settingsHint")),
1364
- import_react.default.createElement("div", {
1365
- key: "dataNote",
1366
- style: { fontSize: 10.5, color: C.label3, margin: "-4px 0 10px", lineHeight: 1.55 }
1367
- }, t("settingsDataNote")),
1368
- import_react.default.createElement("div", {
1369
- key: "fields",
1370
- style: { display: "flex", flexWrap: "wrap", gap: 9 }
1371
- }, [
1372
- textField("monthlySalary", "salaryField", "10000"),
1373
- textField("workStart", "workStartField", "09:00"),
1374
- textField("workEnd", "workEndField", "18:00"),
1375
- textField("lunchStart", "lunchStartField", "12:00"),
1376
- textField("lunchEnd", "lunchEndField", "13:00"),
1377
- checkField("lunchEnabled", "lunchEnabledField"),
1378
- checkField("lunchPaid", "lunchPaidField")
1652
+ import_react.default.createElement("div", { key: "body", style: { marginTop: 7 } }, [
1653
+ row("salary", t("salaryField"), [
1654
+ import_react.default.createElement("input", {
1655
+ key: "i",
1656
+ type: "text",
1657
+ inputMode: "decimal",
1658
+ value: form.monthlySalary,
1659
+ placeholder: "10000",
1660
+ onChange: (event) => setField("monthlySalary", event.target.value),
1661
+ style: inputStyle
1662
+ }),
1663
+ import_react.default.createElement("span", { key: "u", style: unitStyle }, t("salaryUnit"))
1664
+ ]),
1665
+ row("worktime", t("groupWorkTime"), [
1666
+ timeRange("workStart", "workEnd", t("groupWorkTime"), false)
1667
+ ]),
1668
+ row("lunch", t("groupLunch"), [
1669
+ // 时间在前、两个开关在后(放到行尾)
1670
+ timeRange("lunchStart", "lunchEnd", t("groupLunch"), lunchDisabled),
1671
+ toggle("lunchEnabled", "lunchEnabledShort", false),
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)))
1681
+ ])
1379
1682
  ]),
1380
1683
  import_react.default.createElement("div", {
1381
1684
  key: "actions",
1382
- style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", marginTop: 11 }
1685
+ style: { display: "flex", alignItems: "center", gap: 10, marginTop: 7, paddingTop: 7, borderTop: `1px solid ${C.border1}` }
1383
1686
  }, [
1384
1687
  import_react.default.createElement("button", {
1385
1688
  key: "save",
@@ -1387,25 +1690,24 @@ function SettingsSection(props) {
1387
1690
  onClick: onSave,
1388
1691
  disabled: save.status === "saving",
1389
1692
  style: {
1390
- // 金色按钮:与主卡金额、节假日强调同一套金色系;深棕字保证任何主题下都有对比度
1391
1693
  border: `1px solid color-mix(in srgb, ${GOLD_TINT} 72%, rgba(0,0,0,.18))`,
1392
1694
  color: "#3A2A05",
1393
1695
  background: save.status === "saving" ? `color-mix(in srgb, ${GOLD_TINT} 52%, #fff)` : "linear-gradient(180deg, #F8DC86 0%, #EFC258 52%, #D99D2C 100%)",
1394
1696
  borderRadius: 8,
1395
- padding: "7px 20px",
1396
- fontSize: 13,
1697
+ padding: "5px 16px",
1698
+ fontSize: 12.5,
1397
1699
  fontWeight: 700,
1398
1700
  letterSpacing: ".3px",
1399
1701
  cursor: save.status === "saving" ? "default" : "pointer",
1400
1702
  opacity: save.status === "saving" ? 0.72 : 1,
1401
- boxShadow: `0 2px 10px color-mix(in srgb, ${GOLD_TINT} 36%, transparent), inset 0 1px 0 rgba(255,255,255,.45)`,
1703
+ boxShadow: `0 2px 9px color-mix(in srgb, ${GOLD_TINT} 34%, transparent), inset 0 1px 0 rgba(255,255,255,.45)`,
1402
1704
  textShadow: "0 1px 0 rgba(255,255,255,.28)"
1403
1705
  }
1404
1706
  }, save.status === "saving" ? t("saving") : t("save")),
1405
- save.status === "ok" ? import_react.default.createElement("span", { key: "ok", style: { fontSize: 12, color: C.ok } }, save.message || t("saved")) : null,
1707
+ save.status === "ok" ? import_react.default.createElement("span", { key: "ok", style: { fontSize: 11.5, color: C.ok } }, save.message || t("saved")) : null,
1406
1708
  save.status === "error" ? import_react.default.createElement("span", {
1407
1709
  key: "err",
1408
- style: { fontSize: 12, color: C.error, flex: "1 1 220px", whiteSpace: "pre-wrap" }
1710
+ style: { fontSize: 11.5, color: C.error, flex: "1 1 200px", whiteSpace: "pre-wrap" }
1409
1711
  }, `${t("saveFailed")}${save.errors.join("\uFF1B")}`) : null
1410
1712
  ])
1411
1713
  ]);
@@ -1413,7 +1715,6 @@ function SettingsSection(props) {
1413
1715
  function NiumaPanel(props) {
1414
1716
  const t = props && typeof props.t === "function" ? props.t : fallbackTranslate;
1415
1717
  const remote = useRemoteConfig();
1416
- const now = useSecondClock();
1417
1718
  const cfg = import_react.default.useMemo(() => {
1418
1719
  const raw = remote.data && remote.data.config ? remote.data.config : null;
1419
1720
  if (!raw) return DEFAULTS;
@@ -1424,6 +1725,8 @@ function NiumaPanel(props) {
1424
1725
  return DEFAULTS;
1425
1726
  }
1426
1727
  }, [remote.data]);
1728
+ const updateMs = cfg.turbo === true ? 16 : Number.isInteger(cfg.updateMs) ? cfg.updateMs : 1e3;
1729
+ const now = useClock(updateMs);
1427
1730
  const snap = import_react.default.useMemo(() => earnedToday(cfg, now), [cfg, now]);
1428
1731
  const rates = snap && snap.rates ? snap.rates : computeRates(cfg, snap && snap.dateKey);
1429
1732
  const baseForm = import_react.default.useMemo(() => formFromConfig(cfg), [cfg]);
@@ -1513,6 +1816,28 @@ function NiumaPanel(props) {
1513
1816
  setSave({ status: "error", errors: [String(error && error.message || error)], message: "" });
1514
1817
  });
1515
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) });
1516
1841
  const holidaySource = remote.data && remote.data.holidaySource || HOLIDAY_SOURCE;
1517
1842
  const banner = remote.status === "failed" ? {
1518
1843
  color: C.warn,
@@ -1615,7 +1940,7 @@ function NiumaPanel(props) {
1615
1940
  }
1616
1941
  }, t("retry")) : null
1617
1942
  ]) : null,
1618
- 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 }),
1619
1944
  import_react.default.createElement(CountdownRow, { key: "countdown", t, snap }),
1620
1945
  import_react.default.createElement("div", {
1621
1946
  key: "ratesHead",