diff --git a/.gitignore b/.gitignore
index 236cfc6..116e8e4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,7 @@ yarn-error.log*
# typescript
next-env.d.ts
+*.tsbuildinfo
/dist/
/.wrangler/
/outputs/
diff --git a/README.md b/README.md
index b02db8a..6531b22 100644
--- a/README.md
+++ b/README.md
@@ -1,98 +1,87 @@
-# vinext-starter
+# 沃尔核材智能电子秤
-A clean full-stack starter running on
-[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
-Drizzle support.
+面向生产现场 Android 终端的横屏智能称重应用。应用完成废料选择、人员与机台确认、连续稳定采样、称重记录上传、本地备份和异常补传。
-## Prerequisites
+## 主要功能
-- Node.js `>=22.13.0`
+- 首页选择废铜、废电线、废铝或废锡,Type ID 默认为 3、4、5、6。
+- 从本地服务器读取门禁人员和生产机台数据。
+- 通过 WebSocket 连续读取电子秤重量,支持 kg、g、lb 单位切换。
+- 支持清零、去皮、累计和取消上一次累计。
+- 重量稳定后提交记录;网络异常时写入本地备份和补传队列。
+- 管理员可维护废料字典、设备信息、接口地址、语言和密码。
+- 预留 USB 摄像头拍照与打印机触发点。
+- 支持开机自启动、系统设置、文件管理、实时日志和日志导出。
-## Quick Start
+## 正式数据接口
-```bash
-npm install
-npm run dev
-npm run build
-```
+应用不包含人员、机台或重量模拟数据。接口未配置或不可用时,界面会显示错误并保留在安全等待状态。
-This starter does not use `wrangler.jsonc`.
+### 人员接口
-## Included Shape
+返回 JSON:
-- edit site code under `app/`
-- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
-- `vite.config.ts` simulates declared bindings for local development
-- `db/schema.ts` starts intentionally empty
-- `examples/d1/` contains an optional D1 example surface
-- `drizzle.config.ts` supports local migration generation when needed
-
-## Workspace Auth Headers
-
-OpenAI workspace sites can read the current user's email from
-`oai-authenticated-user-email`.
-
-SIWC-authenticated workspace sites may also receive
-`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
-`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
-`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
-
-Treat the full name as optional and fall back to email when it is absent:
-
-```tsx
-import { headers } from "next/headers";
-
-export default async function Home() {
- const requestHeaders = await headers();
- const email = requestHeaders.get("oai-authenticated-user-email");
- const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
- const fullName =
- encodedFullName &&
- requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
- "percent-encoded-utf-8"
- ? decodeURIComponent(encodedFullName)
- : null;
-
- const displayName = fullName ?? email;
- // ...
+```json
+{
+ "name": "员工姓名",
+ "employeeId": "员工工号"
}
```
-## Optional Dispatch-Owned ChatGPT Sign-In
+### 机台接口
-Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
-optional or required ChatGPT sign-in:
+可返回字符串数组,或包含 `machines` 的对象:
-- Use `getChatGPTUser()` for optional signed-in UI.
-- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
- anonymous visitors through Sign in with ChatGPT.
-- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
- browser links or actions.
-- Pass a same-origin relative `returnTo` path for the destination after sign-in
- or sign-out. The helper validates and safely encodes it.
-- Mark protected pages with `export const dynamic = "force-dynamic"` because
- they depend on per-request identity headers.
+```json
+{
+ "machines": [
+ { "id": "机台号", "name": "机台名称" }
+ ]
+}
+```
-Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
-OAuth cookies, and identity header injection. Do not implement app routes for
-those reserved paths. Routes that do not import and call the helper remain
-anonymous-compatible.
+### 电子秤接口
-SIWC establishes identity only; it does not prove workspace membership. Use the
-Sites hosting platform's access policy controls for workspace-wide restrictions,
-or enforce explicit server-side membership or allowlist checks.
+使用 WebSocket。支持纯数字或 JSON 消息:
-Use SIWC for account pages, user-specific dashboards, saved records, and write
-actions tied to the current ChatGPT user. Leave public content anonymous.
+```json
+{ "weight": 2490, "unit": "g" }
+```
-## Useful Commands
+内部统一换算为 kg。支持 `weightKg`、`weight`、`value` 字段和 `kg`、`g`、`lb` 单位。
-- `npm run dev`: start local development
-- `npm run build`: verify the vinext build output
-- `npm test`: build the starter and verify its rendered loading skeleton
-- `npm run db:generate`: generate Drizzle migrations after schema changes
+### 上传接口
-## Learn More
+使用 HTTP POST,内容类型为 `application/json`,字段包括员工、机台、废料、重量和称重时间。
-- [vinext Documentation](https://github.com/cloudflare/vinext)
-- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
+## 本地目录
+
+- 图片:`/sdcard/smart/image/`
+- 称重备份:`/sdcard/smart/cache/`
+- 运行及导出日志:`/sdcard/smart/logs/`
+
+## 开发与测试
+
+要求 Node.js 22 或更高版本、pnpm、Android SDK 和 JDK 21。
+
+```bash
+pnpm install
+pnpm test
+pnpm run lint
+```
+
+Android Web 资源检查与打包:
+
+```bash
+cd android-app
+pnpm install
+pnpm exec tsc --noEmit
+pnpm run build:web
+pnpm exec cap copy android
+cd android
+./gradlew test assembleDebug
+```
+
+调试 APK 输出位置:
+
+`android-app/android/app/build/outputs/apk/debug/app-debug.apk`
diff --git a/android-app/.gitignore b/android-app/.gitignore
new file mode 100644
index 0000000..2043214
--- /dev/null
+++ b/android-app/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+outputs/
+android/.gradle/
+android/.idea/
+android/build/
+android/app/build/
+android/local.properties
diff --git a/android-app/android/.gitignore b/android-app/android/.gitignore
new file mode 100644
index 0000000..48354a3
--- /dev/null
+++ b/android-app/android/.gitignore
@@ -0,0 +1,101 @@
+# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
+
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+# Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/gradle.xml
+.idea/assetWizardSettings.xml
+.idea/dictionaries
+.idea/libraries
+# Android Studio 3 in .gitignore file.
+.idea/caches
+.idea/modules.xml
+# Comment next line if keeping position of elements in Navigation Editor is relevant for you
+.idea/navEditor.xml
+
+# Keystore files
+# Uncomment the following lines if you do not want to check your keystore files in.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
+
+# Android Profiling
+*.hprof
+
+# Cordova plugins for Capacitor
+capacitor-cordova-android-plugins
+
+# Copied web assets
+app/src/main/assets/public
+
+# Generated Config files
+app/src/main/assets/capacitor.config.json
+app/src/main/assets/capacitor.plugins.json
+app/src/main/res/xml/config.xml
diff --git a/android-app/android/app/.gitignore b/android-app/android/app/.gitignore
new file mode 100644
index 0000000..043df80
--- /dev/null
+++ b/android-app/android/app/.gitignore
@@ -0,0 +1,2 @@
+/build/*
+!/build/.npmkeep
diff --git a/android-app/android/app/build.gradle b/android-app/android/app/build.gradle
new file mode 100644
index 0000000..c308806
--- /dev/null
+++ b/android-app/android/app/build.gradle
@@ -0,0 +1,54 @@
+apply plugin: 'com.android.application'
+
+android {
+ namespace = "com.woer.smartscale"
+ compileSdk = rootProject.ext.compileSdkVersion
+ defaultConfig {
+ applicationId "com.woer.smartscale"
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+ versionCode 1
+ versionName "1.0"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ aaptOptions {
+ // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+ // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
+ ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
+ }
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+}
+
+repositories {
+ flatDir{
+ dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
+ }
+}
+
+dependencies {
+ implementation fileTree(include: ['*.jar'], dir: 'libs')
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
+ implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
+ implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
+ implementation project(':capacitor-android')
+ testImplementation "junit:junit:$junitVersion"
+ androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
+ androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
+ implementation project(':capacitor-cordova-android-plugins')
+}
+
+apply from: 'capacitor.build.gradle'
+
+try {
+ def servicesJSON = file('google-services.json')
+ if (servicesJSON.text) {
+ apply plugin: 'com.google.gms.google-services'
+ }
+} catch(Exception e) {
+ logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
+}
diff --git a/android-app/android/app/capacitor.build.gradle b/android-app/android/app/capacitor.build.gradle
new file mode 100644
index 0000000..bbfb44f
--- /dev/null
+++ b/android-app/android/app/capacitor.build.gradle
@@ -0,0 +1,19 @@
+// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
+
+android {
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_21
+ targetCompatibility JavaVersion.VERSION_21
+ }
+}
+
+apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
+dependencies {
+
+
+}
+
+
+if (hasProperty('postBuildExtras')) {
+ postBuildExtras()
+}
diff --git a/android-app/android/app/proguard-rules.pro b/android-app/android/app/proguard-rules.pro
new file mode 100644
index 0000000..f1b4245
--- /dev/null
+++ b/android-app/android/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/android-app/android/app/src/main/AndroidManifest.xml b/android-app/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..2db7b37
--- /dev/null
+++ b/android-app/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/android/app/src/main/java/com/woer/smartscale/BootReceiver.java b/android-app/android/app/src/main/java/com/woer/smartscale/BootReceiver.java
new file mode 100644
index 0000000..aef0e18
--- /dev/null
+++ b/android-app/android/app/src/main/java/com/woer/smartscale/BootReceiver.java
@@ -0,0 +1,29 @@
+package com.woer.smartscale;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class BootReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // 某些定制终端可能发送空广播,先校验再读取动作名称。
+ if (intent == null || intent.getAction() == null) {
+ return;
+ }
+
+ String action = intent.getAction();
+ // 同时兼容标准开机广播和常见厂商的快速开机广播。
+ if (Intent.ACTION_BOOT_COMPLETED.equals(action)
+ || Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action)
+ || "android.intent.action.QUICKBOOT_POWERON".equals(action)
+ || "com.htc.intent.action.QUICKBOOT_POWERON".equals(action)) {
+ Intent launchIntent = new Intent(context, MainActivity.class);
+ // 复用已有任务,避免重复创建多个称重界面实例。
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ context.startActivity(launchIntent);
+ }
+ }
+}
diff --git a/android-app/android/app/src/main/java/com/woer/smartscale/MainActivity.java b/android-app/android/app/src/main/java/com/woer/smartscale/MainActivity.java
new file mode 100644
index 0000000..3009db9
--- /dev/null
+++ b/android-app/android/app/src/main/java/com/woer/smartscale/MainActivity.java
@@ -0,0 +1,211 @@
+package com.woer.smartscale;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Environment;
+import android.provider.Settings;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.ViewGroup;
+import android.webkit.JavascriptInterface;
+import android.webkit.WebChromeClient;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+public class MainActivity extends Activity {
+ // Android 6 到 Android 9 写入公共目录时需要动态申请存储权限。
+ private static final int STORAGE_PERMISSION_REQUEST = 1001;
+ private WebView webView;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // 使用单个全屏 WebView 承载 React 操作界面。
+ webView = new WebView(this);
+ webView.setLayoutParams(new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT));
+ webView.setBackgroundColor(Color.rgb(248, 250, 248));
+ webView.setWebViewClient(new WebViewClient());
+ webView.setWebChromeClient(new WebChromeClient());
+
+ // 开启应用运行、接口访问和本地配置所需的 WebView 能力。
+ WebSettings settings = webView.getSettings();
+ settings.setJavaScriptEnabled(true);
+ settings.setDomStorageEnabled(true);
+ settings.setDatabaseEnabled(true);
+ settings.setAllowFileAccess(true);
+ settings.setAllowContentAccess(true);
+ settings.setAllowFileAccessFromFileURLs(true);
+ settings.setAllowUniversalAccessFromFileURLs(true);
+ settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
+ settings.setMediaPlaybackRequiresUserGesture(false);
+ settings.setBuiltInZoomControls(false);
+ settings.setDisplayZoomControls(false);
+ settings.setUseWideViewPort(true);
+ settings.setLoadWithOverviewMode(false);
+ // 向前端暴露受控的文件保存和系统页面跳转能力。
+ webView.addJavascriptInterface(new AndroidBridge(), "WoerNative");
+
+ setContentView(webView);
+ requestStoragePermission();
+ loadApp();
+ }
+
+ private void requestStoragePermission() {
+ // Android 10 之后采用新的存储模型,不再走旧版动态权限分支。
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ && Build.VERSION.SDK_INT <= Build.VERSION_CODES.P
+ && checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(
+ new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
+ STORAGE_PERMISSION_REQUEST);
+ }
+ }
+
+ private class AndroidBridge {
+ // 每条称重记录单独写入 cache 目录,便于断网补传和人工核查。
+ @JavascriptInterface
+ public boolean saveBackup(String fileName, String content) {
+ return writeTextFile("cache", fileName, content, false);
+ }
+
+ // 运行日志按天追加写入,避免覆盖当天已有内容。
+ @JavascriptInterface
+ public boolean appendLog(String fileName, String content) {
+ return writeTextFile("logs", fileName, content, true);
+ }
+
+ // 导出日志使用新文件,防止与每日运行日志混在一起。
+ @JavascriptInterface
+ public boolean exportLogs(String fileName, String content) {
+ return writeTextFile("logs", fileName, content, false);
+ }
+
+ // 系统设置和文件管理必须在主线程启动 Activity。
+ @JavascriptInterface
+ public boolean openSystemSettings() {
+ return launchSystemActivity(new Intent(Settings.ACTION_SETTINGS));
+ }
+
+ @JavascriptInterface
+ public boolean openFileManager() {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
+ return launchSystemActivity(intent);
+ }
+ }
+
+ private boolean launchSystemActivity(final Intent intent) {
+ if (intent.resolveActivity(getPackageManager()) == null) {
+ return false;
+ }
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ startActivity(intent);
+ }
+ });
+ return true;
+ }
+
+ private boolean writeTextFile(
+ String directoryName,
+ String fileName,
+ String content,
+ boolean append) {
+ // 目录白名单与文件名净化共同阻止前端写入任意路径。
+ if (!"cache".equals(directoryName) && !"logs".equals(directoryName)) {
+ return false;
+ }
+ String safeFileName = fileName == null
+ ? ""
+ : fileName.replaceAll("[^A-Za-z0-9._-]", "_");
+ if (safeFileName.isEmpty()) {
+ return false;
+ }
+ File smartDirectory = new File(
+ Environment.getExternalStorageDirectory(),
+ "smart/" + directoryName);
+ if (!smartDirectory.exists() && !smartDirectory.mkdirs()) {
+ return false;
+ }
+ File targetFile = new File(smartDirectory, safeFileName);
+ try (FileOutputStream output = new FileOutputStream(targetFile, append)) {
+ output.write((content == null ? "" : content).getBytes(StandardCharsets.UTF_8));
+ if (append) {
+ output.write('\n');
+ }
+ output.flush();
+ Log.i("WoerStorage", "Saved " + targetFile.getAbsolutePath());
+ return true;
+ } catch (IOException error) {
+ Log.e("WoerStorage", "Unable to save " + targetFile.getAbsolutePath(), error);
+ return false;
+ }
+ }
+
+ private void loadApp() {
+ /*
+ * 终端的 WebView 会根据 density 缩小 CSS 视口。
+ * 这里使用真实横屏宽度改写 viewport,使 1920×1080 等分辨率都能自适应。
+ */
+ DisplayMetrics metrics = new DisplayMetrics();
+ getWindowManager().getDefaultDisplay().getRealMetrics(metrics);
+ int landscapeWidth = Math.round(
+ Math.max(metrics.widthPixels, metrics.heightPixels) / metrics.density);
+ try {
+ String html = readAsset("public/index.html")
+ .replace("width=device-width", "width=" + landscapeWidth)
+ .replace(", viewport-fit=cover", "");
+ webView.loadDataWithBaseURL(
+ "file:///android_asset/public/",
+ html,
+ "text/html",
+ "UTF-8",
+ null);
+ } catch (IOException error) {
+ webView.loadUrl("file:///android_asset/public/index.html");
+ }
+ }
+
+ private String readAsset(String path) throws IOException {
+ // 使用 UTF-8 读取打包后的首页,再交给 WebView 加载。
+ StringBuilder content = new StringBuilder();
+ try (InputStream input = getAssets().open(path);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(input, "UTF-8"))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ content.append(line).append('\n');
+ }
+ }
+ return content.toString();
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (webView != null) {
+ // 主动销毁 WebView,释放页面定时器和 JavaScript 上下文。
+ webView.destroy();
+ }
+ super.onDestroy();
+ }
+}
diff --git a/android-app/android/app/src/main/res/drawable-land-hdpi/splash.png b/android-app/android/app/src/main/res/drawable-land-hdpi/splash.png
new file mode 100644
index 0000000..e31573b
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-land-hdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-land-mdpi/splash.png b/android-app/android/app/src/main/res/drawable-land-mdpi/splash.png
new file mode 100644
index 0000000..f7a6492
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-land-mdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android-app/android/app/src/main/res/drawable-land-xhdpi/splash.png
new file mode 100644
index 0000000..8077255
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-land-xhdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png
new file mode 100644
index 0000000..14c6c8f
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
new file mode 100644
index 0000000..244ca25
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-port-hdpi/splash.png b/android-app/android/app/src/main/res/drawable-port-hdpi/splash.png
new file mode 100644
index 0000000..74faaa5
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-port-hdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-port-mdpi/splash.png b/android-app/android/app/src/main/res/drawable-port-mdpi/splash.png
new file mode 100644
index 0000000..e944f4a
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-port-mdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android-app/android/app/src/main/res/drawable-port-xhdpi/splash.png
new file mode 100644
index 0000000..564a82f
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-port-xhdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png
new file mode 100644
index 0000000..bfabe68
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
new file mode 100644
index 0000000..6929071
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ
diff --git a/android-app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android-app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..c7bd21d
--- /dev/null
+++ b/android-app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/android/app/src/main/res/drawable/ic_launcher_background.xml b/android-app/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..d5fccc5
--- /dev/null
+++ b/android-app/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/android/app/src/main/res/drawable/splash.png b/android-app/android/app/src/main/res/drawable/splash.png
new file mode 100644
index 0000000..f7a6492
Binary files /dev/null and b/android-app/android/app/src/main/res/drawable/splash.png differ
diff --git a/android-app/android/app/src/main/res/layout/activity_main.xml b/android-app/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..b5ad138
--- /dev/null
+++ b/android-app/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/android-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..036d09b
--- /dev/null
+++ b/android-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..036d09b
--- /dev/null
+++ b/android-app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..c023e50
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..2127973
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b441f37
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..72905b8
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..8ed0605
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..9502e47
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d1e077
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..df0f158
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..853db04
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..6cdf97c
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..2960cbb
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..8e3093a
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..46de6e2
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d2ea9ab
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..a40d73e
Binary files /dev/null and b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android-app/android/app/src/main/res/values/ic_launcher_background.xml b/android-app/android/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..c5d5899
--- /dev/null
+++ b/android-app/android/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/android-app/android/app/src/main/res/values/strings.xml b/android-app/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..ce1f43f
--- /dev/null
+++ b/android-app/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+
+ WOER 智能称重
+ WOER 智能称重
+ com.woer.smartscale
+ com.woer.smartscale
+
diff --git a/android-app/android/app/src/main/res/values/styles.xml b/android-app/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..be874e5
--- /dev/null
+++ b/android-app/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/android/app/src/main/res/xml/file_paths.xml b/android-app/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..bd0c4d8
--- /dev/null
+++ b/android-app/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/android/build.gradle b/android-app/android/build.gradle
new file mode 100644
index 0000000..b648f20
--- /dev/null
+++ b/android-app/android/build.gradle
@@ -0,0 +1,29 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.13.0'
+ classpath 'com.google.gms:google-services:4.4.4'
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+apply from: "variables.gradle"
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
diff --git a/android-app/android/capacitor.settings.gradle b/android-app/android/capacitor.settings.gradle
new file mode 100644
index 0000000..822bfc4
--- /dev/null
+++ b/android-app/android/capacitor.settings.gradle
@@ -0,0 +1,3 @@
+// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
+include ':capacitor-android'
+project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.4.2_@capacitor+core@8.4.2/node_modules/@capacitor/android/capacitor')
diff --git a/android-app/android/gradle.properties b/android-app/android/gradle.properties
new file mode 100644
index 0000000..2e87c52
--- /dev/null
+++ b/android-app/android/gradle.properties
@@ -0,0 +1,22 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx1536m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
diff --git a/android-app/android/gradle/wrapper/gradle-wrapper.jar b/android-app/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/android-app/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android-app/android/gradle/wrapper/gradle-wrapper.properties b/android-app/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..7705927
--- /dev/null
+++ b/android-app/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android-app/android/gradlew b/android-app/android/gradlew
new file mode 100755
index 0000000..23d15a9
--- /dev/null
+++ b/android-app/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android-app/android/gradlew.bat b/android-app/android/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/android-app/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android-app/android/settings.gradle b/android-app/android/settings.gradle
new file mode 100644
index 0000000..3b4431d
--- /dev/null
+++ b/android-app/android/settings.gradle
@@ -0,0 +1,5 @@
+include ':app'
+include ':capacitor-cordova-android-plugins'
+project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
+
+apply from: 'capacitor.settings.gradle'
\ No newline at end of file
diff --git a/android-app/android/variables.gradle b/android-app/android/variables.gradle
new file mode 100644
index 0000000..ee4ba41
--- /dev/null
+++ b/android-app/android/variables.gradle
@@ -0,0 +1,16 @@
+ext {
+ minSdkVersion = 24
+ compileSdkVersion = 36
+ targetSdkVersion = 36
+ androidxActivityVersion = '1.11.0'
+ androidxAppCompatVersion = '1.7.1'
+ androidxCoordinatorLayoutVersion = '1.3.0'
+ androidxCoreVersion = '1.17.0'
+ androidxFragmentVersion = '1.8.9'
+ coreSplashScreenVersion = '1.2.0'
+ androidxWebkitVersion = '1.14.0'
+ junitVersion = '4.13.2'
+ androidxJunitVersion = '1.3.0'
+ androidxEspressoCoreVersion = '3.7.0'
+ cordovaAndroidVersion = '14.0.1'
+}
\ No newline at end of file
diff --git a/android-app/capacitor.config.ts b/android-app/capacitor.config.ts
new file mode 100644
index 0000000..99e4d15
--- /dev/null
+++ b/android-app/capacitor.config.ts
@@ -0,0 +1,15 @@
+import type { CapacitorConfig } from "@capacitor/cli";
+
+const config: CapacitorConfig = {
+ appId: "com.woer.smartscale",
+ appName: "WOER 智能称重",
+ webDir: "dist",
+ android: {
+ // 本地服务器可能使用 HTTP,因此允许 WebView 加载混合内容。
+ allowMixedContent: true,
+ // 输入事件交给应用内数字键盘处理,避免系统键盘遮挡界面。
+ captureInput: true,
+ },
+};
+
+export default config;
diff --git a/android-app/index.html b/android-app/index.html
new file mode 100644
index 0000000..fbbea90
--- /dev/null
+++ b/android-app/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ WOER 智能称重
+
+
+
+
+
+
diff --git a/android-app/package.json b/android-app/package.json
new file mode 100644
index 0000000..41034e8
--- /dev/null
+++ b/android-app/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "woer-smart-scale-android",
+ "version": "0.2.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build:web": "vite build",
+ "android:sync": "cap sync android",
+ "apk:debug": "pnpm run build:web && cap sync android && cd android && ./gradlew assembleDebug"
+ },
+ "dependencies": {
+ "@capacitor/core": "8.4.2",
+ "react": "19.2.6",
+ "react-dom": "19.2.6"
+ },
+ "devDependencies": {
+ "@capacitor/android": "8.4.2",
+ "@capacitor/cli": "8.4.2",
+ "@tailwindcss/postcss": "4.2.1",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "@vitejs/plugin-react": "6.0.2",
+ "tailwindcss": "4.2.1",
+ "typescript": "5.9.3",
+ "vite": "8.0.13"
+ }
+}
diff --git a/android-app/pnpm-lock.yaml b/android-app/pnpm-lock.yaml
new file mode 100644
index 0000000..fe4f914
--- /dev/null
+++ b/android-app/pnpm-lock.yaml
@@ -0,0 +1,1694 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@capacitor/core':
+ specifier: 8.4.2
+ version: 8.4.2
+ react:
+ specifier: 19.2.6
+ version: 19.2.6
+ react-dom:
+ specifier: 19.2.6
+ version: 19.2.6(react@19.2.6)
+ devDependencies:
+ '@capacitor/android':
+ specifier: 8.4.2
+ version: 8.4.2(@capacitor/core@8.4.2)
+ '@capacitor/cli':
+ specifier: 8.4.2
+ version: 8.4.2
+ '@tailwindcss/postcss':
+ specifier: 4.2.1
+ version: 4.2.1
+ '@types/react':
+ specifier: 19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: 19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: 6.0.2
+ version: 6.0.2(vite@8.0.13(@types/node@26.1.1)(jiti@2.7.0))
+ tailwindcss:
+ specifier: 4.2.1
+ version: 4.2.1
+ typescript:
+ specifier: 5.9.3
+ version: 5.9.3
+ vite:
+ specifier: 8.0.13
+ version: 8.0.13(@types/node@26.1.1)(jiti@2.7.0)
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@capacitor/android@8.4.2':
+ resolution: {integrity: sha512-bzf8XZ9C+ePgOQ+e+T0fnhHGH4aCNY494vvSQIF5w4AY07RwEmkFqZs1678DDSMH0hLSQpxEwXGFNUVcXO6bBA==}
+ peerDependencies:
+ '@capacitor/core': ^8.4.0
+
+ '@capacitor/cli@8.4.2':
+ resolution: {integrity: sha512-hE4HmpMdr5T8eQ++kGa1BKkdG2JLEvo1y1cj1kmFK78VB9dBp538oe1EZvzwupNGOl0ZueALotzL5wcXRD9w1Q==}
+ engines: {node: '>=22.0.0'}
+ hasBin: true
+
+ '@capacitor/core@8.4.2':
+ resolution: {integrity: sha512-fQPRb3JXRaU2pnufDUvqOjhsrYxDQeFRZyaj/sHydIUxD2NxOGHKMpmKUdI+U4OOmKuGZyvRpi7TSJU+7Bjbmg==}
+
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
+ '@ionic/cli-framework-output@2.2.8':
+ resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-array@2.1.6':
+ resolution: {integrity: sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-fs@3.1.7':
+ resolution: {integrity: sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-object@2.1.6':
+ resolution: {integrity: sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-process@2.1.12':
+ resolution: {integrity: sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-stream@3.1.7':
+ resolution: {integrity: sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-subprocess@3.0.1':
+ resolution: {integrity: sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-terminal@2.3.5':
+ resolution: {integrity: sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==}
+ engines: {node: '>=16.0.0'}
+
+ '@isaacs/fs-minipass@4.0.1':
+ resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
+ engines: {node: '>=18.0.0'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/wasm-runtime@1.1.6':
+ resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
+ '@oxc-project/types@0.130.0':
+ resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
+
+ '@rolldown/binding-android-arm64@1.0.1':
+ resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.0.1':
+ resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.0.1':
+ resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.0.1':
+ resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.1':
+ resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.1':
+ resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-arm64-musl@1.0.1':
+ resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.1':
+ resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.1':
+ resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-gnu@1.0.1':
+ resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-musl@1.0.1':
+ resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-openharmony-arm64@1.0.1':
+ resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.0.1':
+ resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.1':
+ resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.0.1':
+ resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
+ '@tailwindcss/node@4.2.1':
+ resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
+
+ '@tailwindcss/oxide-android-arm64@4.2.1':
+ resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
+ resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
+ resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
+ resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
+ resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
+ resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
+ resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
+ resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
+ resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
+ resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
+ resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
+ resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.2.1':
+ resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/postcss@4.2.1':
+ resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==}
+
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+
+ '@types/fs-extra@8.1.5':
+ resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
+
+ '@types/node@26.1.1':
+ resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.14':
+ resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+
+ '@types/slice-ansi@4.0.0':
+ resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==}
+
+ '@vitejs/plugin-react@6.0.2':
+ resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+
+ '@xmldom/xmldom@0.9.10':
+ resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
+ engines: {node: '>=14.6'}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ astral-regex@2.0.0:
+ resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
+ engines: {node: '>=8'}
+
+ at-least-node@1.0.0:
+ resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
+ engines: {node: '>= 4.0.0'}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
+ big-integer@1.6.52:
+ resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
+ engines: {node: '>=0.6'}
+
+ bplist-parser@0.3.2:
+ resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==}
+ engines: {node: '>= 5.10.0'}
+
+ brace-expansion@5.0.8:
+ resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
+ engines: {node: 20 || >=22}
+
+ buffer-crc32@0.2.13:
+ resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+
+ chownr@3.0.0:
+ resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
+ engines: {node: '>=18'}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ commander@12.1.0:
+ resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
+ engines: {node: '>=18'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ define-lazy-prop@2.0.0:
+ resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
+ engines: {node: '>=8'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ elementtree@0.1.7:
+ resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==}
+ engines: {node: '>= 0.4.0'}
+
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+ enhanced-resolve@5.24.3:
+ resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==}
+ engines: {node: '>=10.13.0'}
+
+ env-paths@2.2.1:
+ resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+ engines: {node: '>=6'}
+
+ fd-slicer@1.1.0:
+ resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fs-extra@11.4.0:
+ resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==}
+ engines: {node: '>=14.14'}
+
+ fs-extra@9.1.0:
+ resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
+ engines: {node: '>=10'}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ glob@13.0.6:
+ resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
+ engines: {node: 18 || 20 || >=22}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ ini@4.1.3:
+ resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
+ engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+
+ is-docker@2.2.1:
+ resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
+ is-wsl@2.2.0:
+ resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
+ engines: {node: '>=8'}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
+ hasBin: true
+
+ jsonfile@6.2.1:
+ resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
+
+ kleur@3.0.3:
+ resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
+ engines: {node: '>=6'}
+
+ kleur@4.1.5:
+ resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
+ engines: {node: '>=6'}
+
+ lightningcss-android-arm64@1.31.1:
+ resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-android-arm64@1.33.0:
+ resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.31.1:
+ resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-arm64@1.33.0:
+ resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.31.1:
+ resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.33.0:
+ resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.31.1:
+ resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-freebsd-x64@1.33.0:
+ resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.31.1:
+ resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.31.1:
+ resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-gnu@1.33.0:
+ resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.31.1:
+ resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-arm64-musl@1.33.0:
+ resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.31.1:
+ resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-gnu@1.33.0:
+ resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.31.1:
+ resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-musl@1.33.0:
+ resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.31.1:
+ resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-arm64-msvc@1.33.0:
+ resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.31.1:
+ resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.33.0:
+ resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.31.1:
+ resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
+ engines: {node: '>= 12.0.0'}
+
+ lightningcss@1.33.0:
+ resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
+ engines: {node: '>= 12.0.0'}
+
+ lru-cache@11.5.2:
+ resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
+ engines: {node: 20 || >=22}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
+ engines: {node: 18 || 20 || >=22}
+
+ minipass@7.1.3:
+ resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ minizlib@3.1.0:
+ resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
+ engines: {node: '>= 18'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.16:
+ resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ native-run@2.0.3:
+ resolution: {integrity: sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==}
+ engines: {node: '>=16.0.0'}
+ hasBin: true
+
+ open@8.4.2:
+ resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
+ engines: {node: '>=12'}
+
+ package-json-from-dist@1.0.1:
+ resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-scurry@2.0.2:
+ resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
+ engines: {node: 18 || 20 || >=22}
+
+ pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.5:
+ resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
+ engines: {node: '>=12'}
+
+ plist@3.1.1:
+ resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==}
+ engines: {node: '>=10.4.0'}
+
+ postcss@8.5.23:
+ resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prompts@2.4.2:
+ resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
+ engines: {node: '>= 6'}
+
+ react-dom@19.2.6:
+ resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
+ peerDependencies:
+ react: ^19.2.6
+
+ react@19.2.6:
+ resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
+ engines: {node: '>=0.10.0'}
+
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
+ rimraf@6.1.3:
+ resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==}
+ engines: {node: 20 || >=22}
+ hasBin: true
+
+ rolldown@1.0.1:
+ resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+ sax@1.1.4:
+ resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==}
+
+ sax@1.6.1:
+ resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
+ engines: {node: '>=11.0.0'}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ signal-exit@3.0.7:
+ resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
+ sisteransi@1.0.5:
+ resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+
+ slice-ansi@4.0.0:
+ resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
+ engines: {node: '>=10'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ split2@4.2.0:
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+ engines: {node: '>= 10.x'}
+
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
+ tailwindcss@4.2.1:
+ resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
+
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+ engines: {node: '>=6'}
+
+ tar@7.5.22:
+ resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==}
+ engines: {node: '>=18'}
+
+ through2@4.0.2:
+ resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tree-kill@1.2.2:
+ resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
+ hasBin: true
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@8.3.0:
+ resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
+
+ universalify@2.0.1:
+ resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
+ engines: {node: '>= 10.0.0'}
+
+ untildify@4.0.0:
+ resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
+ engines: {node: '>=8'}
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ vite@8.0.13:
+ resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.18
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ wrap-ansi@7.0.0:
+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+ engines: {node: '>=10'}
+
+ xml2js@0.6.2:
+ resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
+ engines: {node: '>=4.0.0'}
+
+ xmlbuilder@11.0.1:
+ resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
+ engines: {node: '>=4.0'}
+
+ xmlbuilder@15.1.1:
+ resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
+ engines: {node: '>=8.0'}
+
+ yallist@5.0.0:
+ resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
+ engines: {node: '>=18'}
+
+ yauzl@2.10.0:
+ resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@capacitor/android@8.4.2(@capacitor/core@8.4.2)':
+ dependencies:
+ '@capacitor/core': 8.4.2
+
+ '@capacitor/cli@8.4.2':
+ dependencies:
+ '@ionic/cli-framework-output': 2.2.8
+ '@ionic/utils-subprocess': 3.0.1
+ '@ionic/utils-terminal': 2.3.5
+ commander: 12.1.0
+ debug: 4.4.3
+ env-paths: 2.2.1
+ fs-extra: 11.4.0
+ kleur: 4.1.5
+ native-run: 2.0.3
+ open: 8.4.2
+ plist: 3.1.1
+ prompts: 2.4.2
+ rimraf: 6.1.3
+ semver: 7.8.5
+ tar: 7.5.22
+ tslib: 2.8.1
+ xml2js: 0.6.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@capacitor/core@8.4.2':
+ dependencies:
+ tslib: 2.8.1
+
+ '@emnapi/core@1.10.0':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.10.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@ionic/cli-framework-output@2.2.8':
+ dependencies:
+ '@ionic/utils-terminal': 2.3.5
+ debug: 4.4.3
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-array@2.1.6':
+ dependencies:
+ debug: 4.4.3
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-fs@3.1.7':
+ dependencies:
+ '@types/fs-extra': 8.1.5
+ debug: 4.4.3
+ fs-extra: 9.1.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-object@2.1.6':
+ dependencies:
+ debug: 4.4.3
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-process@2.1.12':
+ dependencies:
+ '@ionic/utils-object': 2.1.6
+ '@ionic/utils-terminal': 2.3.5
+ debug: 4.4.3
+ signal-exit: 3.0.7
+ tree-kill: 1.2.2
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-stream@3.1.7':
+ dependencies:
+ debug: 4.4.3
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-subprocess@3.0.1':
+ dependencies:
+ '@ionic/utils-array': 2.1.6
+ '@ionic/utils-fs': 3.1.7
+ '@ionic/utils-process': 2.1.12
+ '@ionic/utils-stream': 3.1.7
+ '@ionic/utils-terminal': 2.3.5
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-terminal@2.3.5':
+ dependencies:
+ '@types/slice-ansi': 4.0.0
+ debug: 4.4.3
+ signal-exit: 3.0.7
+ slice-ansi: 4.0.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ tslib: 2.8.1
+ untildify: 4.0.0
+ wrap-ansi: 7.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@isaacs/fs-minipass@4.0.1':
+ dependencies:
+ minipass: 7.1.3
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
+ '@oxc-project/types@0.130.0': {}
+
+ '@rolldown/binding-android-arm64@1.0.1':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.0.1':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.0.1':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.0.1':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.1':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.1':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.0.1':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.1':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.1':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.0.1':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.0.1':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.0.1':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.0.1':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.1':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.0.1':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
+
+ '@tailwindcss/node@4.2.1':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.24.3
+ jiti: 2.7.0
+ lightningcss: 1.31.1
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.2.1
+
+ '@tailwindcss/oxide-android-arm64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide@4.2.1':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.2.1
+ '@tailwindcss/oxide-darwin-arm64': 4.2.1
+ '@tailwindcss/oxide-darwin-x64': 4.2.1
+ '@tailwindcss/oxide-freebsd-x64': 4.2.1
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.1
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.1
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.1
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.1
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.1
+
+ '@tailwindcss/postcss@4.2.1':
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ '@tailwindcss/node': 4.2.1
+ '@tailwindcss/oxide': 4.2.1
+ postcss: 8.5.23
+ tailwindcss: 4.2.1
+
+ '@tybys/wasm-util@0.10.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/fs-extra@8.1.5':
+ dependencies:
+ '@types/node': 26.1.1
+
+ '@types/node@26.1.1':
+ dependencies:
+ undici-types: 8.3.0
+
+ '@types/react-dom@19.2.3(@types/react@19.2.14)':
+ dependencies:
+ '@types/react': 19.2.14
+
+ '@types/react@19.2.14':
+ dependencies:
+ csstype: 3.2.3
+
+ '@types/slice-ansi@4.0.0': {}
+
+ '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@26.1.1)(jiti@2.7.0))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ vite: 8.0.13(@types/node@26.1.1)(jiti@2.7.0)
+
+ '@xmldom/xmldom@0.9.10': {}
+
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ astral-regex@2.0.0: {}
+
+ at-least-node@1.0.0: {}
+
+ balanced-match@4.0.4: {}
+
+ base64-js@1.5.1: {}
+
+ big-integer@1.6.52: {}
+
+ bplist-parser@0.3.2:
+ dependencies:
+ big-integer: 1.6.52
+
+ brace-expansion@5.0.8:
+ dependencies:
+ balanced-match: 4.0.4
+
+ buffer-crc32@0.2.13: {}
+
+ chownr@3.0.0: {}
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ commander@12.1.0: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ define-lazy-prop@2.0.0: {}
+
+ detect-libc@2.1.2: {}
+
+ elementtree@0.1.7:
+ dependencies:
+ sax: 1.1.4
+
+ emoji-regex@8.0.0: {}
+
+ enhanced-resolve@5.24.3:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.3
+
+ env-paths@2.2.1: {}
+
+ fd-slicer@1.1.0:
+ dependencies:
+ pend: 1.2.0
+
+ fdir@6.5.0(picomatch@4.0.5):
+ optionalDependencies:
+ picomatch: 4.0.5
+
+ fs-extra@11.4.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.1
+ universalify: 2.0.1
+
+ fs-extra@9.1.0:
+ dependencies:
+ at-least-node: 1.0.0
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.1
+ universalify: 2.0.1
+
+ fsevents@2.3.3:
+ optional: true
+
+ glob@13.0.6:
+ dependencies:
+ minimatch: 10.2.5
+ minipass: 7.1.3
+ path-scurry: 2.0.2
+
+ graceful-fs@4.2.11: {}
+
+ inherits@2.0.4: {}
+
+ ini@4.1.3: {}
+
+ is-docker@2.2.1: {}
+
+ is-fullwidth-code-point@3.0.0: {}
+
+ is-wsl@2.2.0:
+ dependencies:
+ is-docker: 2.2.1
+
+ isexe@2.0.0: {}
+
+ jiti@2.7.0: {}
+
+ jsonfile@6.2.1:
+ dependencies:
+ universalify: 2.0.1
+ optionalDependencies:
+ graceful-fs: 4.2.11
+
+ kleur@3.0.3: {}
+
+ kleur@4.1.5: {}
+
+ lightningcss-android-arm64@1.31.1:
+ optional: true
+
+ lightningcss-android-arm64@1.33.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.31.1:
+ optional: true
+
+ lightningcss-darwin-arm64@1.33.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.31.1:
+ optional: true
+
+ lightningcss-darwin-x64@1.33.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.31.1:
+ optional: true
+
+ lightningcss-freebsd-x64@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.31.1:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.31.1:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.31.1:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.33.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.31.1:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.33.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.31.1:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.33.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.31.1:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.33.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.31.1:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.33.0:
+ optional: true
+
+ lightningcss@1.31.1:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.31.1
+ lightningcss-darwin-arm64: 1.31.1
+ lightningcss-darwin-x64: 1.31.1
+ lightningcss-freebsd-x64: 1.31.1
+ lightningcss-linux-arm-gnueabihf: 1.31.1
+ lightningcss-linux-arm64-gnu: 1.31.1
+ lightningcss-linux-arm64-musl: 1.31.1
+ lightningcss-linux-x64-gnu: 1.31.1
+ lightningcss-linux-x64-musl: 1.31.1
+ lightningcss-win32-arm64-msvc: 1.31.1
+ lightningcss-win32-x64-msvc: 1.31.1
+
+ lightningcss@1.33.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.33.0
+ lightningcss-darwin-arm64: 1.33.0
+ lightningcss-darwin-x64: 1.33.0
+ lightningcss-freebsd-x64: 1.33.0
+ lightningcss-linux-arm-gnueabihf: 1.33.0
+ lightningcss-linux-arm64-gnu: 1.33.0
+ lightningcss-linux-arm64-musl: 1.33.0
+ lightningcss-linux-x64-gnu: 1.33.0
+ lightningcss-linux-x64-musl: 1.33.0
+ lightningcss-win32-arm64-msvc: 1.33.0
+ lightningcss-win32-x64-msvc: 1.33.0
+
+ lru-cache@11.5.2: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ minimatch@10.2.5:
+ dependencies:
+ brace-expansion: 5.0.8
+
+ minipass@7.1.3: {}
+
+ minizlib@3.1.0:
+ dependencies:
+ minipass: 7.1.3
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.16: {}
+
+ native-run@2.0.3:
+ dependencies:
+ '@ionic/utils-fs': 3.1.7
+ '@ionic/utils-terminal': 2.3.5
+ bplist-parser: 0.3.2
+ debug: 4.4.3
+ elementtree: 0.1.7
+ ini: 4.1.3
+ plist: 3.1.1
+ split2: 4.2.0
+ through2: 4.0.2
+ tslib: 2.8.1
+ yauzl: 2.10.0
+ transitivePeerDependencies:
+ - supports-color
+
+ open@8.4.2:
+ dependencies:
+ define-lazy-prop: 2.0.0
+ is-docker: 2.2.1
+ is-wsl: 2.2.0
+
+ package-json-from-dist@1.0.1: {}
+
+ path-key@3.1.1: {}
+
+ path-scurry@2.0.2:
+ dependencies:
+ lru-cache: 11.5.2
+ minipass: 7.1.3
+
+ pend@1.2.0: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.5: {}
+
+ plist@3.1.1:
+ dependencies:
+ '@xmldom/xmldom': 0.9.10
+ base64-js: 1.5.1
+ xmlbuilder: 15.1.1
+
+ postcss@8.5.23:
+ dependencies:
+ nanoid: 3.3.16
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prompts@2.4.2:
+ dependencies:
+ kleur: 3.0.3
+ sisteransi: 1.0.5
+
+ react-dom@19.2.6(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ scheduler: 0.27.0
+
+ react@19.2.6: {}
+
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
+ rimraf@6.1.3:
+ dependencies:
+ glob: 13.0.6
+ package-json-from-dist: 1.0.1
+
+ rolldown@1.0.1:
+ dependencies:
+ '@oxc-project/types': 0.130.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.0.1
+ '@rolldown/binding-darwin-arm64': 1.0.1
+ '@rolldown/binding-darwin-x64': 1.0.1
+ '@rolldown/binding-freebsd-x64': 1.0.1
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.1
+ '@rolldown/binding-linux-arm64-gnu': 1.0.1
+ '@rolldown/binding-linux-arm64-musl': 1.0.1
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.1
+ '@rolldown/binding-linux-s390x-gnu': 1.0.1
+ '@rolldown/binding-linux-x64-gnu': 1.0.1
+ '@rolldown/binding-linux-x64-musl': 1.0.1
+ '@rolldown/binding-openharmony-arm64': 1.0.1
+ '@rolldown/binding-wasm32-wasi': 1.0.1
+ '@rolldown/binding-win32-arm64-msvc': 1.0.1
+ '@rolldown/binding-win32-x64-msvc': 1.0.1
+
+ safe-buffer@5.2.1: {}
+
+ sax@1.1.4: {}
+
+ sax@1.6.1: {}
+
+ scheduler@0.27.0: {}
+
+ semver@7.8.5: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ signal-exit@3.0.7: {}
+
+ sisteransi@1.0.5: {}
+
+ slice-ansi@4.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ astral-regex: 2.0.0
+ is-fullwidth-code-point: 3.0.0
+
+ source-map-js@1.2.1: {}
+
+ split2@4.2.0: {}
+
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
+ tailwindcss@4.2.1: {}
+
+ tapable@2.3.3: {}
+
+ tar@7.5.22:
+ dependencies:
+ '@isaacs/fs-minipass': 4.0.1
+ chownr: 3.0.0
+ minipass: 7.1.3
+ minizlib: 3.1.0
+ yallist: 5.0.0
+
+ through2@4.0.2:
+ dependencies:
+ readable-stream: 3.6.2
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
+
+ tree-kill@1.2.2: {}
+
+ tslib@2.8.1: {}
+
+ typescript@5.9.3: {}
+
+ undici-types@8.3.0: {}
+
+ universalify@2.0.1: {}
+
+ untildify@4.0.0: {}
+
+ util-deprecate@1.0.2: {}
+
+ vite@8.0.13(@types/node@26.1.1)(jiti@2.7.0):
+ dependencies:
+ lightningcss: 1.33.0
+ picomatch: 4.0.5
+ postcss: 8.5.23
+ rolldown: 1.0.1
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 26.1.1
+ fsevents: 2.3.3
+ jiti: 2.7.0
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ wrap-ansi@7.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
+ xml2js@0.6.2:
+ dependencies:
+ sax: 1.6.1
+ xmlbuilder: 11.0.1
+
+ xmlbuilder@11.0.1: {}
+
+ xmlbuilder@15.1.1: {}
+
+ yallist@5.0.0: {}
+
+ yauzl@2.10.0:
+ dependencies:
+ buffer-crc32: 0.2.13
+ fd-slicer: 1.1.0
diff --git a/android-app/src/main.tsx b/android-app/src/main.tsx
new file mode 100644
index 0000000..9d81900
--- /dev/null
+++ b/android-app/src/main.tsx
@@ -0,0 +1,18 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "../../app/page";
+import "../../app/globals.css";
+
+const root = document.getElementById("root");
+
+// Android 安装包必须挂载到固定根节点;缺失时立即报错,避免静默白屏。
+if (!root) {
+ throw new Error("Application root element was not found.");
+}
+
+// StrictMode 仅用于发现副作用问题,生产构建不会渲染额外界面。
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/android-app/tsconfig.json b/android-app/tsconfig.json
new file mode 100644
index 0000000..daa5b5e
--- /dev/null
+++ b/android-app/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src", "vite.config.ts", "capacitor.config.ts", "../app/page.tsx"]
+}
diff --git a/android-app/vite.config.ts b/android-app/vite.config.ts
new file mode 100644
index 0000000..1f42cb7
--- /dev/null
+++ b/android-app/vite.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ base: "./",
+ plugins: [
+ react(),
+ {
+ // Android 7.1 的旧版 WebView 不能直接执行 module 脚本。
+ name: "android-7-classic-script",
+ transformIndexHtml: {
+ order: "post",
+ handler(html) {
+ return html.replace(
+ /',
+ );
+ },
+ },
+ },
+ ],
+ resolve: {
+ dedupe: ["react", "react-dom"],
+ },
+ build: {
+ // 生产终端为 Android 7.1,内置 Chromium WebView 52。
+ target: "chrome52",
+ cssTarget: "chrome52",
+ sourcemap: false,
+ },
+});
diff --git a/app/globals.css b/app/globals.css
index 862f8c3..faa88b0 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -46,86 +46,106 @@ button:disabled { cursor: not-allowed; opacity: .55; }
overflow: hidden;
}
.app-header { height: 74px; display: flex; align-items: center; justify-content: space-between; padding: 0 38px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.72); }
-.brand { display: flex; align-items: center; gap: 12px; border: 0; background: none; color: var(--ink); padding: 0; text-align: left; }
-.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; background: var(--green); color: white; border-radius: 11px; font-size: 24px; font-weight: 900; transform: skew(-5deg); }
+.brand { display: flex; align-items: center; gap: 0; margin-left: 12px; border: 0; background: none; color: var(--ink); padding: 0; text-align: left; }
+.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; background: var(--green); color: white; border-radius: 11px; font-size: 25px; font-weight: 900; transform: skew(-5deg); }
+.brand > span + span { margin-left: 18px; }
.brand strong, .brand small { display: block; }
-.brand strong { font-size: 16px; letter-spacing: .04em; }
-.brand small { color: #88938d; font-size: 9px; margin-top: 3px; letter-spacing: .12em; }
-.status-pill { display: flex; gap: 8px; align-items: center; padding: 8px 13px; border-radius: 99px; background: #edf7f2; color: #236451; font-size: 12px; font-weight: 700; }
+.brand strong { font-size: 17px; letter-spacing: .04em; }
+.brand small { color: #88938d; font-size: 11px; margin-top: 3px; letter-spacing: .12em; }
+.status-pill { display: flex; gap: 8px; align-items: center; padding: 8px 13px; border-radius: 99px; background: #edf7f2; color: #236451; font-size: 14px; font-weight: 700; }
.status-dot { width: 7px; height: 7px; background: #22a06b; border-radius: 50%; box-shadow: 0 0 0 4px rgba(34,160,107,.12); }
-.progress-strip { display: flex; gap: 36px; justify-content: center; padding: 17px 28px; border-bottom: 1px solid var(--line); color: #9aa39e; font-size: 12px; font-weight: 700; }
+.progress-strip { display: flex; gap: 36px; justify-content: center; padding: 17px 28px; border-bottom: 1px solid var(--line); color: #9aa39e; font-size: 14px; font-weight: 700; }
.progress-item { display: flex; align-items: center; gap: 8px; }
-.progress-item span { width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid #ccd4cf; border-radius: 50%; font-size: 10px; }
+.progress-item + .progress-item { margin-left: 36px; }
+.progress-item span { width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid #ccd4cf; border-radius: 50%; font-size: 12px; }
.progress-item.active { color: var(--green); }
.progress-item.active span { color: white; background: var(--green); border-color: var(--green); }
.content { flex: 1; display: grid; }
-footer { min-height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 38px; border-top: 1px solid var(--line); color: #94a098; font-size: 10px; letter-spacing: .04em; }
+footer { min-height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 38px; border-top: 1px solid var(--line); color: #94a098; font-size: 12px; letter-spacing: .04em; }
.home-view { padding: 46px clamp(36px, 6vw, 70px) 34px; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(340px, .95fr); align-items: center; gap: clamp(36px, 6vw, 74px); position: relative; }
.home-copy { min-width: 0; }
-.eyebrow, .step-kicker { color: var(--green); font-size: 11px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
+.eyebrow, .step-kicker { color: var(--green); font-size: 13px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
.eyebrow { display: flex; align-items: center; gap: 8px; }
.eyebrow span { width: 20px; height: 2px; background: #e5a63c; }
-h1 { font-size: clamp(46px, 5.2vw, 64px); line-height: 1.03; letter-spacing: -.06em; margin: 19px 0 21px; max-width: 590px; }
+h1 { font-size: clamp(48px, 5.3vw, 66px); line-height: 1.03; letter-spacing: -.06em; margin: 19px 0 21px; max-width: 590px; }
h1 em { font-style: normal; color: var(--green); }
-.home-copy > p { color: var(--muted); line-height: 1.85; font-size: 13px; }
-.home-start-hint { width: min(390px, 100%); display: flex; align-items: center; gap: 10px; margin-top: 25px; padding: 12px 14px; border: 1px solid #bedacd; border-radius: 12px; background: var(--mint); color: var(--green); font-size: 10px; }
-.home-start-hint span { width: 26px; height: 26px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: var(--green); color: white; font-size: 9px; }
+.home-copy > p { color: var(--muted); line-height: 1.85; font-size: 14px; }
+.home-start-hint { width: min(390px, 100%); display: flex; align-items: center; gap: 10px; margin-top: 25px; padding: 12px 14px; border: 1px solid #bedacd; border-radius: 12px; background: var(--mint); color: var(--green); font-size: 12px; }
+.home-start-hint span { width: 26px; height: 26px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: var(--green); color: white; font-size: 11px; }
.home-start-hint.loading span { animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .45; transform: scale(.92); } }
-.admin-link { min-width: 190px; display: flex; align-items: center; justify-content: space-between; margin-top: 30px; border: 1px solid #cbd8d1; border-radius: 11px; background: rgba(255,255,255,.78); color: #53625a; padding: 11px 14px; font-size: 11px; font-weight: 800; box-shadow: 0 6px 18px rgba(30,55,43,.05); }
+.admin-link { min-width: 190px; display: flex; align-items: center; justify-content: space-between; margin-top: 30px; border: 1px solid #cbd8d1; border-radius: 11px; background: rgba(255,255,255,.78); color: #53625a; padding: 11px 14px; font-size: 13px; font-weight: 800; box-shadow: 0 6px 18px rgba(30,55,43,.05); }
.admin-link:hover { color: var(--green); border-color: #9fc5b5; background: white; }
.home-notice { width: min(390px, 100%); }
-.home-stats { margin-top: 36px; display: flex; gap: 32px; color: #849088; font-size: 9px; }
+.home-stats { margin-top: 36px; display: flex; gap: 32px; color: #849088; font-size: 11px; }
.home-stats span { padding-left: 12px; border-left: 2px solid #e0e6e1; }
-.home-stats b { display: block; color: var(--ink); font-size: 13px; margin-bottom: 4px; }
-.home-material-panel { position: relative; padding: 23px; border: 1px solid rgba(255,255,255,.9); border-radius: 21px; background: rgba(255,255,255,.7); box-shadow: 0 18px 50px rgba(30,56,43,.1), inset 0 1px #fff; }
+.home-stats b { display: block; color: var(--ink); font-size: 14px; margin-bottom: 4px; }
+.home-material-panel { position: relative; padding: 28px; border: 1px solid rgba(255,255,255,.9); border-radius: 23px; background: rgba(255,255,255,.7); box-shadow: 0 18px 50px rgba(30,56,43,.1), inset 0 1px #fff; }
.home-material-panel::before { content: ""; position: absolute; width: 180px; height: 180px; right: -60px; top: -55px; z-index: -1; border-radius: 50%; background: rgba(0,127,95,.08); box-shadow: 0 0 0 32px rgba(0,127,95,.025); }
-.material-panel-heading h2 { margin: 7px 0 5px; font-size: 23px; letter-spacing: -.035em; }
-.material-panel-heading p { margin: 0 0 16px; color: var(--muted); font-size: 10px; }
-.home-material-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
-.home-material-grid .material-card { min-height: 82px; }
-.material-selection-status { display: flex; align-items: center; gap: 8px; margin-top: 13px; padding: 9px 11px; border-radius: 9px; background: #eef1ee; color: #7c8781; font-size: 9px; font-weight: 700; }
-.material-selection-status span { width: 19px; height: 19px; display: grid; place-items: center; border: 1px solid #bfc8c2; border-radius: 50%; font-size: 8px; }
+.material-panel-heading h2 { margin: 7px 0 5px; font-size: 28px; letter-spacing: -.035em; }
+.material-panel-heading p { margin: 0 0 18px; color: var(--muted); font-size: 13px; }
+.home-material-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+.home-material-grid .material-card { min-height: 100px; padding: 18px 17px; border-radius: 14px; }
+.home-material-grid .material-card.selected { padding: 17px 16px; }
+.home-material-grid .material-card b { font-size: 20px; }
+.home-material-grid .material-card small { margin-top: 8px; font-size: 11px; }
+.home-material-grid .material-card i { font-size: 14px; }
+.material-selection-status { display: flex; align-items: center; gap: 8px; margin-top: 13px; padding: 9px 11px; border-radius: 9px; background: #eef1ee; color: #7c8781; font-size: 11px; font-weight: 700; }
+.material-selection-status span { width: 19px; height: 19px; display: grid; place-items: center; border: 1px solid #bfc8c2; border-radius: 50%; font-size: 10px; }
.material-selection-status.ready { background: var(--mint); color: var(--green); }
.material-selection-status.ready span { border-color: var(--green); background: var(--green); color: white; }
.panel-view { width: min(760px, calc(100% - 48px)); margin: 0 auto; padding: 35px 0; }
-.back-button { display: inline-flex; align-items: center; gap: 5px; margin: 0 0 12px; border: 0; background: transparent; color: #6f7b74; padding: 4px 0; font-size: 10px; font-weight: 800; }
-.back-button:hover { color: var(--green); }
+.back-button {
+ min-height: 38px;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ margin: 0 0 12px;
+ border: 1px solid var(--green);
+ border-radius: 10px;
+ background: var(--green);
+ color: white;
+ padding: 8px 14px;
+ font-size: 14px;
+ font-weight: 800;
+ box-shadow: 0 4px 12px rgba(30,55,43,.06);
+}
+.back-button:hover, .back-button:active { color: white; border-color: #006e53; background: #006e53; }
.section-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 24px; }
-.section-heading h2, .result-view h2 { margin: 7px 0 7px; font-size: 29px; letter-spacing: -.035em; }
-.section-heading p, .result-view > p { margin: 0; color: var(--muted); font-size: 12px; }
-.refresh-button { border: 1px solid var(--line); border-radius: 10px; background: white; color: #53615a; font-size: 11px; padding: 10px 14px; }
+.section-heading h2, .result-view h2 { margin: 7px 0 7px; font-size: 30px; letter-spacing: -.035em; }
+.section-heading p, .result-view > p { margin: 0; color: var(--muted); font-size: 14px; }
+.refresh-button { border: 1px solid var(--line); border-radius: 10px; background: white; color: #53615a; font-size: 13px; padding: 10px 14px; }
.identity-card { display: flex; align-items: center; gap: 16px; background: #fff; border: 1px solid var(--line); border-radius: 15px; padding: 16px; box-shadow: 0 8px 24px rgba(35,58,46,.05); }
-.avatar { width: 54px; height: 54px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 14px; background: var(--green); color: white; font-size: 21px; font-weight: 800; }
-.identity-card small { color: #8c9791; font-size: 10px; }
-.identity-card h3 { margin: 4px 0; font-size: 19px; }
-.identity-card p { margin: 0; color: #66736c; font-size: 11px; }
-.verified { margin-left: auto; background: var(--mint); color: var(--green); padding: 8px 11px; border-radius: 99px; font-size: 10px; font-weight: 700; }
+.avatar { width: 54px; height: 54px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 14px; background: var(--green); color: white; font-size: 22px; font-weight: 800; }
+.identity-card small { color: #8c9791; font-size: 12px; }
+.identity-card h3 { margin: 4px 0; font-size: 20px; }
+.identity-card p { margin: 0; color: #66736c; font-size: 13px; }
+.verified { margin-left: auto; background: var(--mint); color: var(--green); padding: 8px 11px; border-radius: 99px; font-size: 12px; font-weight: 700; }
.verified.missing { color: #9b6b14; background: #fbf0d7; }
-.field-label { display: block; margin: 22px 0 8px; font-size: 11px; font-weight: 800; }
+.field-label { display: block; margin: 22px 0 8px; font-size: 13px; font-weight: 800; }
.machine-picker { height: 52px; display: flex; align-items: center; gap: 10px; border: 1px solid #cfd8d2; border-radius: 12px; background: white; padding: 0 15px; }
.machine-picker:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.08); }
-.machine-picker input { flex: 1; border: 0; outline: 0; background: transparent; font-size: 13px; }
+.machine-picker input { flex: 1; border: 0; outline: 0; background: transparent; font-size: 14px; }
.machine-picker b { color: var(--green); }
.machine-select-row { min-height: 62px; display: flex; align-items: center; gap: 12px; border: 1px solid #cfd8d2; border-radius: 12px; background: white; padding: 10px 12px; }
.machine-select-row.selected { border-color: #a9cbbb; background: #fbfefc; }
.machine-select-icon { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; background: #e9f4ef; color: var(--green); }
.machine-select-row > span:nth-child(2) { flex: 1; }
.machine-select-row small, .machine-select-row b { display: block; }
-.machine-select-row small { color: #89948e; font-size: 8px; margin-bottom: 4px; }
-.machine-select-row b { font-size: 12px; }
-.machine-select-row > button { border: 0; border-radius: 8px; background: var(--green); color: white; padding: 9px 12px; font-size: 9px; font-weight: 800; }
+.machine-select-row small { color: #89948e; font-size: 10px; margin-bottom: 4px; }
+.machine-select-row b { font-size: 14px; }
+.machine-select-row > button { border: 0; border-radius: 8px; background: var(--green); color: white; padding: 9px 12px; font-size: 11px; font-weight: 800; }
.machine-select-row > button.machine-refresh { border: 1px solid #cbd8d1; background: white; color: #65736b; }
.suggestions { margin-top: 6px; border: 1px solid var(--line); border-radius: 12px; background: white; padding: 5px; box-shadow: 0 15px 40px rgba(32,55,44,.1); }
-.suggestions button { width: 100%; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 8px; background: transparent; padding: 10px; text-align: left; font-size: 12px; }
+.suggestions button { width: 100%; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 8px; background: transparent; padding: 10px; text-align: left; font-size: 14px; }
.suggestions button:hover { background: #eef7f2; color: var(--green); }
.suggestions button small { margin-left: auto; color: #95a099; }
-.suggestions p { text-align: center; color: #7f8b84; font-size: 11px; }
-.full-primary { width: 100%; margin-top: 22px; border: 0; border-radius: 12px; background: var(--green); color: white; padding: 15px 18px; font-size: 13px; font-weight: 800; box-shadow: 0 10px 25px rgba(0,127,95,.18); }
+.suggestions p { text-align: center; color: #7f8b84; font-size: 13px; }
+.full-primary { width: 100%; margin-top: 22px; border: 0; border-radius: 12px; background: var(--green); color: white; padding: 15px 18px; font-size: 14px; font-weight: 800; box-shadow: 0 10px 25px rgba(0,127,95,.18); }
.full-primary span { float: right; }
-.notice { margin-top: 12px; padding: 10px 12px; border-radius: 8px; background: #f7edd8; color: #8b671e; font-size: 10px; }
+.notice { margin-top: 12px; padding: 10px 12px; border-radius: 8px; background: #f7edd8; color: #8b671e; font-size: 12px; }
.notice.compact { margin: 10px 0 -10px; }
.weigh-view { padding: 14px 32px 28px; }
@@ -134,124 +154,132 @@ h1 em { font-style: normal; color: var(--green); }
.weigh-meta > span { min-width: 170px; padding-right: 18px; margin-right: 18px; border-right: 1px solid var(--line); }
.weigh-meta > span.material-meta { min-width: 230px; }
.weigh-meta small, .weigh-meta b { display: block; }
-.weigh-meta small { color: #8a958f; font-size: 9px; margin-bottom: 4px; }
-.weigh-meta b { font-size: 11px; }
-.weigh-meta button { margin-left: auto; border: 0; background: none; color: var(--green); font-size: 10px; font-weight: 800; }
+.weigh-meta small { color: #8a958f; font-size: 11px; margin-bottom: 4px; }
+.weigh-meta b { font-size: 13px; }
+.weigh-meta button { margin-left: auto; border: 0; background: none; color: var(--green); font-size: 12px; font-weight: 800; }
.scale-display { border-radius: 18px; background: #112b23; color: white; padding: 17px 24px 15px; box-shadow: inset 0 1px rgba(255,255,255,.08), 0 16px 30px rgba(16,42,33,.17); }
.display-top { display: flex; justify-content: space-between; align-items: center; }
-.stable-badge { color: #e2c680; font-size: 10px; font-weight: 700; }
+.stable-badge { color: #e2c680; font-size: 12px; font-weight: 700; }
.stable-badge i { display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: #e4b64e; box-shadow: 0 0 0 4px rgba(228,182,78,.13); }
.stable-badge.ready { color: #83dfb8; }
.stable-badge.ready i { background: #48c78e; box-shadow: 0 0 0 4px rgba(72,199,142,.13); }
.unit-switch { display: flex; background: rgba(255,255,255,.07); border-radius: 8px; padding: 3px; }
-.unit-switch button { border: 0; border-radius: 6px; background: transparent; color: #849b91; font-size: 9px; padding: 5px 9px; }
+.unit-switch button { border: 0; border-radius: 6px; background: transparent; color: #849b91; font-size: 11px; padding: 5px 9px; }
.unit-switch button.active { background: #e8f0ea; color: #15352a; font-weight: 800; }
.weight-number { display: flex; justify-content: center; align-items: baseline; gap: 13px; margin: 2px 0; }
-.weight-number span { font-family: "Courier New", monospace; font-size: clamp(58px, 8vw, 82px); letter-spacing: -.08em; font-weight: 700; }
-.weight-number b { color: #8da298; font-size: 14px; }
+.weight-number span { font-family: "Courier New", monospace; font-size: clamp(60px, 8vw, 84px); letter-spacing: -.08em; font-weight: 700; }
+.weight-number b { color: #8da298; font-size: 15px; }
.stability-track { height: 3px; background: rgba(255,255,255,.08); border-radius: 4px; overflow: hidden; }
.stability-track span { display: block; height: 100%; background: #4bd19a; transition: width .25s; }
-.display-details { display: flex; justify-content: center; gap: 42px; margin-top: 12px; color: #789187; font-size: 9px; }
+.display-details { display: flex; justify-content: center; gap: 0; margin-top: 12px; color: #789187; font-size: 11px; }
+.display-details > span + span { margin-left: 42px; }
.display-details b { color: #dbe8e1; margin-left: 5px; }
.scale-tools { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 11px 0 16px; }
.scale-tools.has-cancel { grid-template-columns: repeat(4, 1fr); }
-.scale-tools button { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 10px 13px; color: var(--green); text-align: left; font-size: 18px; }
+.scale-tools button { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 10px 13px; color: var(--green); text-align: left; font-size: 19px; }
.scale-tools button:hover { border-color: #a8cabb; }
.scale-tools button.cancel-total { border-color: #e3c3bd; color: #b65748; background: #fff8f6; }
.scale-tools button.cancel-total b { color: #a6493c; }
.scale-tools span, .scale-tools b, .scale-tools small { display: block; }
-.scale-tools b { color: var(--ink); font-size: 11px; }
-.scale-tools small { color: #909a94; font-size: 8px; margin-top: 3px; }
+.scale-tools b { color: var(--ink); font-size: 13px; }
+.scale-tools small { color: #909a94; font-size: 10px; margin-top: 3px; }
.material-card { min-width: 0; display: flex; align-items: center; gap: 10px; position: relative; border: 1px solid var(--line); border-radius: 11px; background: white; padding: 14px 13px; text-align: left; }
.material-card:hover { transform: translateY(-1px); }
.material-card.selected { border: 2px solid var(--green); background: #f3faf7; padding: 13px 12px; }
-.material-icon { width: 33px; height: 33px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; font-size: 12px; font-weight: 900; background: #edf1ed; color: #536259; }
+.material-icon { width: 33px; height: 33px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; font-size: 14px; font-weight: 900; background: #edf1ed; color: #536259; }
.material-card.copper .material-icon { background: #f3e2d6; color: #a65e2e; }
.material-card.wire .material-icon { background: #f3ead2; color: #b27714; }
.material-card.aluminum .material-icon { background: #e5ecee; color: #60747c; }
.material-card.tin .material-icon { background: #e5e6ed; color: #62687c; }
.material-card b, .material-card small { display: block; white-space: nowrap; }
-.material-card b { font-size: 16px; letter-spacing: -.02em; }
-.material-card small { margin-top: 6px; color: #87928c; font-size: 8px; font-weight: 700; }
-.material-card i { margin-left: auto; font-size: 10px; color: var(--green); font-style: normal; }
+.material-card b { font-size: 17px; letter-spacing: -.02em; }
+.material-card small { margin-top: 6px; color: #87928c; font-size: 10px; font-weight: 700; }
+.material-card i { margin-left: auto; font-size: 12px; color: var(--green); font-style: normal; }
.selected-material-summary { display: flex; align-items: center; gap: 12px; border: 1px solid #bedacd; border-radius: 12px; background: #f3faf7; padding: 13px 16px; }
.selected-material-summary > span:nth-child(2) { flex: 1; }
.selected-material-summary small, .selected-material-summary b, .selected-material-summary em { display: block; }
-.selected-material-summary small { color: #7a8a82; font-size: 8px; }
-.selected-material-summary b { margin: 3px 0; font-size: 18px; }
-.selected-material-summary em { color: #8c9891; font-size: 7px; font-style: normal; }
-.selected-material-summary > i { color: var(--green); font-size: 9px; font-style: normal; font-weight: 800; }
+.selected-material-summary small { color: #7a8a82; font-size: 10px; }
+.selected-material-summary b { margin: 3px 0; font-size: 19px; }
+.selected-material-summary em { color: #8c9891; font-size: 9px; font-style: normal; }
+.selected-material-summary > i { color: var(--green); font-size: 11px; font-style: normal; font-weight: 800; }
.full-primary.upload { margin-top: 18px; }
.result-view { width: min(520px, calc(100% - 48px)); margin: auto; padding: 28px 0; text-align: center; }
.success-ring { width: 66px; height: 66px; display: grid; place-items: center; margin: 0 auto 13px; border-radius: 50%; background: var(--mint); box-shadow: 0 0 0 8px rgba(223,245,237,.55); }
-.success-ring span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; background: var(--green); color: white; font-size: 19px; }
+.success-ring span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; background: var(--green); color: white; font-size: 20px; }
.receipt { margin-top: 20px; border: 1px solid var(--line); border-radius: 15px; background: white; box-shadow: 0 15px 40px rgba(26,50,38,.08); overflow: hidden; text-align: left; }
-.receipt-head { display: flex; justify-content: space-between; padding: 12px 18px; border-bottom: 1px dashed #d9e0db; font-size: 10px; font-weight: 800; }
+.receipt-head { display: flex; justify-content: space-between; padding: 12px 18px; border-bottom: 1px dashed #d9e0db; font-size: 12px; font-weight: 800; }
.receipt-head small { color: #8c9691; font-weight: 400; }
.receipt-weight { display: flex; align-items: center; justify-content: space-between; padding: 15px 20px; background: #f2f8f5; }
.receipt-weight small, .receipt-weight b { display: block; }
-.receipt-weight small { color: var(--green); font-size: 10px; font-weight: 800; }
-.receipt-weight b { font-family: "Courier New", monospace; font-size: 28px; margin-top: 4px; }
-.receipt-weight em { font-family: Arial, sans-serif; font-size: 10px; font-style: normal; color: #7d8a83; }
+.receipt-weight small { color: var(--green); font-size: 12px; font-weight: 800; }
+.receipt-weight b { font-family: "Courier New", monospace; font-size: 29px; margin-top: 4px; }
+.receipt-weight em { font-family: Arial, sans-serif; font-size: 12px; font-style: normal; color: #7d8a83; }
.receipt-weight > i { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 11px; background: white; color: var(--green); font-style: normal; font-weight: 900; box-shadow: 0 4px 15px rgba(0,0,0,.05); }
.receipt dl { padding: 5px 20px 10px; margin: 0; }
-.receipt dl div { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #edf0ee; font-size: 10px; }
+.receipt dl div { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #edf0ee; font-size: 12px; }
.receipt dl div:last-child { border: 0; }
.receipt dt { color: #859189; }
.receipt dd { margin: 0; font-weight: 800; }
.upload-ok { color: var(--green); }
+.upload-pending { color: #b27714; }
.result-view .full-primary { margin-top: 15px; }
-.countdown { position: relative; margin-top: 10px; color: #8b9690; font-size: 9px; overflow: hidden; }
+.countdown { position: relative; margin-top: 10px; color: #8b9690; font-size: 11px; overflow: hidden; }
.countdown span { position: absolute; left: 0; bottom: -4px; height: 2px; background: var(--green); transition: width 1s linear; }
.settings-view { width: min(980px, calc(100% - 40px)); padding: 21px 0 22px; }
.settings-heading { margin-bottom: 14px; }
-.settings-heading h2 { font-size: 25px; }
+.settings-heading h2 { font-size: 26px; }
.settings-layout { display: grid; grid-template-columns: 165px minmax(0, 1fr); min-height: 456px; border: 1px solid var(--line); border-radius: 16px; background: white; overflow: hidden; box-shadow: 0 12px 35px rgba(30,55,43,.06); }
.settings-tabs { padding: 12px 9px; border-right: 1px solid var(--line); background: #f2f6f3; }
-.settings-tabs button { width: 100%; display: flex; align-items: center; gap: 9px; position: relative; border: 0; border-radius: 9px; background: transparent; color: #6e7a73; padding: 11px 10px; font-size: 10px; text-align: left; }
+.settings-tabs button { width: 100%; display: flex; align-items: center; gap: 9px; position: relative; border: 0; border-radius: 9px; background: transparent; color: #6e7a73; padding: 11px 10px; font-size: 12px; text-align: left; }
.settings-tabs button:hover { background: rgba(255,255,255,.8); color: var(--ink); }
.settings-tabs button.active { background: white; color: var(--green); font-weight: 800; box-shadow: 0 5px 18px rgba(30,55,43,.08); }
-.settings-tabs button > span { width: 21px; height: 21px; display: grid; place-items: center; border-radius: 6px; background: #e4ebe7; font-size: 10px; }
+.settings-tabs button > span { width: 21px; height: 21px; display: grid; place-items: center; border-radius: 6px; background: #e4ebe7; font-size: 12px; }
.settings-tabs button.active > span { background: var(--mint); }
-.settings-tabs button > b { margin-left: auto; min-width: 17px; height: 17px; display: grid; place-items: center; border-radius: 99px; background: #c9792d; color: white; font-size: 8px; }
+.settings-tabs button > b { margin-left: auto; min-width: 17px; height: 17px; display: grid; place-items: center; border-radius: 99px; background: #c9792d; color: white; font-size: 10px; }
.settings-panel { min-width: 0; padding: 19px 22px; }
.settings-panel-title { min-height: 42px; display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; }
.settings-panel-title.compact { min-height: 32px; margin-bottom: 10px; }
.settings-panel-title > span > b, .settings-panel-title > span > small { display: block; }
-.settings-panel-title > span > b { font-size: 13px; }
-.settings-panel-title > span > small { margin-top: 4px; color: #89948e; font-size: 9px; font-weight: 400; }
-.settings-panel-title > em { padding: 5px 8px; border-radius: 99px; background: #e9f5ef; color: var(--green); font-size: 8px; font-style: normal; font-weight: 800; }
+.settings-panel-title > span > b { font-size: 14px; }
+.settings-panel-title > span > small { margin-top: 4px; color: #89948e; font-size: 11px; font-weight: 400; }
+.settings-panel-title > em { padding: 5px 8px; border-radius: 99px; background: #e9f5ef; color: var(--green); font-size: 10px; font-style: normal; font-weight: 800; }
.settings-form-grid { display: grid; gap: 11px; }
.settings-form-grid.three { grid-template-columns: 1.2fr .85fr 1fr; }
.settings-form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-form-grid label, .password-settings label, .material-editor-row label { display: block; }
-.settings-form-grid label > span, .password-settings label > span, .material-editor-row label > span { display: block; margin-bottom: 6px; color: #647069; font-size: 9px; font-weight: 800; }
-.settings-form-grid input, .password-settings input, .material-editor-row input { width: 100%; height: 38px; border: 1px solid #d9e0dc; border-radius: 8px; background: #fafbf9; padding: 0 11px; outline: 0; font-size: 10px; color: var(--ink); }
+.settings-form-grid label > span, .password-settings label > span, .material-editor-row label > span { display: block; margin-bottom: 6px; color: #647069; font-size: 11px; font-weight: 800; }
+.settings-form-grid input, .password-settings input, .material-editor-row input { width: 100%; height: 38px; border: 1px solid #d9e0dc; border-radius: 8px; background: #fafbf9; padding: 0 11px; outline: 0; font-size: 12px; color: var(--ink); }
.settings-form-grid input:focus, .password-settings input:focus, .material-editor-row input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.06); background: white; }
.settings-divider { height: 1px; margin: 18px 0 14px; background: var(--line); }
.language-setting { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-top: 14px; padding: 11px 12px; border: 1px solid #e0e6e2; border-radius: 10px; background: #fbfcfb; }
.language-setting > span b, .language-setting > span small { display: block; }
-.language-setting > span b { font-size: 10px; }
-.language-setting > span small { margin-top: 4px; color: #89948e; font-size: 8px; }
+.language-setting > span b { font-size: 12px; }
+.language-setting > span small { margin-top: 4px; color: #89948e; font-size: 10px; }
.language-setting > div { display: flex; gap: 4px; padding: 3px; border-radius: 8px; background: #edf2ef; }
-.language-setting button { border: 0; border-radius: 6px; background: transparent; color: #718078; padding: 7px 9px; font-size: 8px; }
+.language-setting button { border: 0; border-radius: 6px; background: transparent; color: #718078; padding: 7px 9px; font-size: 10px; }
.language-setting button.active { background: white; color: var(--green); font-weight: 800; box-shadow: 0 2px 8px rgba(30,55,43,.08); }
+.system-tools { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; padding: 10px 12px; border: 1px solid #e0e6e2; border-radius: 10px; background: #fbfcfb; }
+.system-tools > span b, .system-tools > span small { display: block; }
+.system-tools > span b { font-size: 12px; }
+.system-tools > span small { margin-top: 4px; color: #89948e; font-size: 10px; }
+.system-tools > div { display: flex; }
+.system-tools > div button + button { margin-left: 8px; }
.material-editor { display: grid; gap: 9px; }
.material-editor-row { display: grid; grid-template-columns: 1fr 1.45fr 22px; align-items: end; gap: 12px; padding: 10px 12px; border: 1px solid #e1e7e3; border-radius: 10px; background: #fbfcfb; }
-.material-editor-row > i { align-self: center; color: #b2bbb6; font-size: 9px; font-style: normal; font-weight: 800; }
+.material-editor-row > i { align-self: center; color: #b2bbb6; font-size: 11px; font-style: normal; font-weight: 800; }
.material-icon.copper { background: #f3e2d6; color: #a65e2e; }
.material-icon.wire { background: #f3ead2; color: #b27714; }
.material-icon.aluminum { background: #e5ecee; color: #60747c; }
.material-icon.tin { background: #e5e6ed; color: #62687c; }
.feature-settings { display: grid; gap: 10px; }
.feature-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid #e0e6e2; border-radius: 11px; background: #fbfcfb; }
-.feature-icon { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; background: var(--mint); color: var(--green); font-size: 17px; }
+.feature-icon { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; background: var(--mint); color: var(--green); font-size: 18px; }
.feature-copy { flex: 1; }
.feature-copy b, .feature-copy small { display: block; }
-.feature-copy b { font-size: 11px; }
-.feature-copy small { margin-top: 4px; color: #8a958f; font-size: 8px; }
+.feature-copy b { font-size: 13px; }
+.feature-copy small { margin-top: 4px; color: #8a958f; font-size: 10px; }
.switch { width: 40px; height: 22px; position: relative; flex: 0 0 auto; }
.switch input { position: absolute; opacity: 0; pointer-events: none; }
.switch span { position: absolute; inset: 0; border-radius: 99px; background: #cbd4cf; transition: .2s; }
@@ -259,21 +287,21 @@ h1 em { font-style: normal; color: var(--green); }
.switch input:checked + span { background: var(--green); }
.switch input:checked + span::after { transform: translateX(18px); }
.path-box { display: grid; grid-template-columns: 100px 1fr auto; align-items: center; gap: 10px; margin: -3px 0 0 48px; padding: 9px 11px; border-radius: 8px; background: #f0f4f1; }
-.path-box small { color: #7f8b84; font-size: 8px; }
-.path-box code { color: #2d5748; font-family: "Courier New", monospace; font-size: 9px; font-weight: 800; }
-.path-box em { color: #929c96; font-size: 7px; font-style: normal; }
+.path-box small { color: #7f8b84; font-size: 10px; }
+.path-box code { color: #2d5748; font-family: "Courier New", monospace; font-size: 11px; font-weight: 800; }
+.path-box em { color: #929c96; font-size: 9px; font-style: normal; }
.print-fields { display: flex; flex-wrap: wrap; gap: 7px; margin: -3px 0 0 48px; }
-.print-fields span { padding: 5px 7px; border-radius: 6px; background: #edf5f1; color: #497061; font-size: 7px; }
+.print-fields span { padding: 5px 7px; border-radius: 6px; background: #edf5f1; color: #497061; font-size: 9px; }
.data-status-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.data-status-grid > div { display: grid; grid-template-columns: 38px 1fr auto; grid-template-rows: auto auto; align-items: center; gap: 2px 10px; padding: 14px; border: 1px solid #e1e7e3; border-radius: 11px; background: #fbfcfb; }
-.data-status-grid > div > span { grid-row: 1 / 3; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; background: var(--mint); color: var(--green); font-size: 17px; }
-.data-status-grid small { color: #7e8a83; font-size: 8px; }
-.data-status-grid b { grid-column: 2; font-size: 22px; }
-.data-status-grid em { grid-column: 3; grid-row: 1 / 3; color: #98a29c; font-size: 8px; font-style: normal; }
+.data-status-grid > div > span { grid-row: 1 / 3; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; background: var(--mint); color: var(--green); font-size: 18px; }
+.data-status-grid small { color: #7e8a83; font-size: 10px; }
+.data-status-grid b { grid-column: 2; font-size: 23px; }
+.data-status-grid em { grid-column: 3; grid-row: 1 / 3; color: #98a29c; font-size: 10px; font-style: normal; }
.path-box.data-path, .path-box.log-path { margin: 14px 0 0; }
.data-actions { display: flex; align-items: center; gap: 15px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
-.data-actions > span { color: #8a958f; font-size: 8px; }
-.secondary-action { border: 1px solid #bdd2c8; border-radius: 8px; background: white; color: var(--green); padding: 10px 13px; font-size: 9px; font-weight: 800; }
+.data-actions > span { color: #8a958f; font-size: 10px; }
+.secondary-action { border: 1px solid #bdd2c8; border-radius: 8px; background: white; color: var(--green); padding: 10px 13px; font-size: 11px; font-weight: 800; }
.secondary-action:hover { background: #f1f8f4; }
.secondary-action.small { padding: 8px 10px; }
.password-settings { max-width: 540px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 13px; }
@@ -283,7 +311,7 @@ h1 em { font-style: normal; color: var(--green); }
.log-title > span > b { display: flex; align-items: center; gap: 7px; }
.live-dot { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: #35b77c; box-shadow: 0 0 0 4px rgba(53,183,124,.12); }
.log-viewer { height: 286px; margin-top: 12px; overflow-y: auto; border-radius: 10px; background: #13231d; padding: 8px 12px; color: #dce9e2; font-family: "Courier New", monospace; }
-.log-entry { display: grid; grid-template-columns: 72px 36px 1fr; gap: 9px; align-items: start; padding: 8px 2px; border-bottom: 1px solid rgba(255,255,255,.06); font-size: 8px; }
+.log-entry { display: grid; grid-template-columns: 72px 36px 1fr; gap: 9px; align-items: start; padding: 8px 2px; border-bottom: 1px solid rgba(255,255,255,.06); font-size: 10px; }
.log-entry time { color: #6f8c80; }
.log-entry > span { color: #8aa398; }
.log-entry.success > span { color: #5bd19c; }
@@ -291,37 +319,82 @@ h1 em { font-style: normal; color: var(--green); }
.log-entry.error > span { color: #e67b6e; }
.log-entry p { margin: 0; line-height: 1.45; }
.settings-footer-actions { display: flex; align-items: center; justify-content: flex-end; gap: 14px; margin-top: 12px; }
-.settings-feedback { color: #87928c; font-size: 9px; }
+.settings-feedback { color: #87928c; font-size: 11px; }
.settings-feedback.visible { color: var(--green); }
-.save-settings { min-width: 180px; border: 0; border-radius: 10px; background: var(--green); color: white; padding: 12px 15px; font-size: 10px; font-weight: 800; box-shadow: 0 8px 20px rgba(0,127,95,.16); }
+.save-settings { min-width: 180px; border: 0; border-radius: 10px; background: var(--green); color: white; padding: 12px 15px; font-size: 12px; font-weight: 800; box-shadow: 0 8px 20px rgba(0,127,95,.16); }
.save-settings span { float: right; }
.modal-backdrop { position: fixed; inset: 0; z-index: 10; display: grid; place-items: center; background: rgba(13,31,24,.58); backdrop-filter: blur(5px); padding: 20px; }
.modal { width: min(390px, 100%); position: relative; background: #fff; border-radius: 20px; padding: 30px; text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.28); }
-.modal-close { position: absolute; right: 14px; top: 11px; border: 0; background: none; color: #89938e; font-size: 24px; }
-.modal-icon { width: 48px; height: 48px; display: grid; place-items: center; margin: 0 auto 12px; border-radius: 13px; background: var(--mint); color: var(--green); font-size: 22px; }
-.modal h2 { margin: 0 0 6px; font-size: 23px; }
-.modal p { color: #87928c; font-size: 11px; }
-.modal > input { width: 100%; margin-top: 12px; border: 1px solid #cfd8d2; border-radius: 11px; padding: 13px; text-align: center; font-size: 22px; letter-spacing: .45em; outline: 0; }
+.modal-close { position: absolute; right: 14px; top: 11px; border: 0; background: none; color: #89938e; font-size: 25px; }
+.modal-icon { width: 48px; height: 48px; display: grid; place-items: center; margin: 0 auto 12px; border-radius: 13px; background: var(--mint); color: var(--green); font-size: 23px; }
+.modal h2 { margin: 0 0 6px; font-size: 24px; }
+.modal p { color: #87928c; font-size: 13px; }
+.modal > input { width: 100%; margin-top: 12px; border: 1px solid #cfd8d2; border-radius: 11px; padding: 13px; text-align: center; font-size: 23px; letter-spacing: .45em; outline: 0; }
.modal > input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.08); }
-.password-error { display: block; color: #b44b3e; margin-top: 8px; font-size: 9px; }
-.machine-modal { width: min(560px, 100%); text-align: left; }
+.password-error { display: block; color: #b44b3e; margin-top: 8px; font-size: 11px; }
+.numeric-readonly { cursor: pointer; caret-color: transparent; background: #f3f8f5 !important; color: var(--green) !important; font-weight: 800; }
+.numeric-readonly:focus { border-color: var(--green); }
+.numeric-display {
+ min-height: 46px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 12px;
+ border: 1px solid #cfd8d2;
+ border-radius: 11px;
+ background: #f7faf8;
+ color: var(--ink);
+ font-family: "Courier New", monospace;
+ font-size: 23px;
+ font-weight: 800;
+ letter-spacing: .08em;
+}
+.numeric-display.masked { color: var(--green); letter-spacing: .22em; }
+.numeric-display.placeholder, .machine-number-display.placeholder { color: #98a39d; font-family: inherit; font-size: 13px; font-weight: 500; letter-spacing: 0; }
+.numeric-keypad { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
+.numeric-keypad button {
+ min-height: 48px;
+ border: 1px solid #d8e0db;
+ border-radius: 10px;
+ background: white;
+ color: var(--ink);
+ font-size: 22px;
+ font-weight: 800;
+ box-shadow: 0 3px 10px rgba(27, 52, 40, .05);
+}
+.numeric-keypad button:hover, .numeric-keypad button:active { border-color: #8dbda9; background: #edf8f3; color: var(--green); }
+.numeric-keypad .key-action { display: grid; place-items: center; color: #65726b; font-size: 16px; }
+.numeric-keypad .key-action small { display: block; font-size: 9px; font-weight: 700; }
+.numeric-keypad .key-clear { color: #a45649; font-size: 12px; }
+.admin-keypad-modal, .numeric-editor-modal { width: min(390px, 100%); padding: 22px 26px; }
+.admin-keypad-modal .modal-icon, .numeric-editor-modal .modal-icon { width: 40px; height: 40px; margin-bottom: 8px; }
+.admin-keypad-modal h2, .numeric-editor-modal h2 { font-size: 21px; }
+.admin-keypad-modal p { margin: 5px 0 7px; }
+.admin-keypad-modal .full-primary, .numeric-editor-modal .full-primary { margin-top: 12px; }
+.numeric-editor-backdrop { z-index: 20; }
+.machine-modal { width: min(760px, 100%); text-align: left; }
.machine-modal-heading { display: flex; align-items: center; gap: 12px; padding-right: 24px; }
.machine-modal-heading .modal-icon { margin: 0; flex: 0 0 auto; }
.machine-modal-heading > span:nth-child(2) { flex: 1; }
-.machine-modal-heading h2 { font-size: 20px; }
+.machine-modal-heading h2 { font-size: 21px; }
.machine-modal-heading p { margin: 4px 0 0; }
-.machine-search { height: 44px; display: flex; align-items: center; gap: 9px; margin-top: 18px; border: 1px solid #cfd8d2; border-radius: 10px; padding: 0 12px; }
+.machine-search { height: 46px; display: flex; align-items: center; gap: 9px; margin-top: 18px; border: 1px solid #cfd8d2; border-radius: 10px; background: white; padding: 0 12px; }
.machine-search:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.07); }
-.machine-search input { flex: 1; border: 0; outline: 0; background: transparent; font-size: 11px; }
-.machine-option-list { max-height: 280px; overflow-y: auto; display: grid; gap: 6px; margin-top: 10px; }
+.machine-number-display { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); caret-color: var(--green); font-family: "Courier New", monospace; font-size: 17px; font-weight: 800; }
+.machine-number-display::placeholder { color: #98a39d; font-family: Arial, "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 13px; font-weight: 500; }
+.machine-modal-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; margin-top: 10px; }
+.machine-modal-body.keypad-open { grid-template-columns: 220px minmax(0, 1fr); }
+.machine-modal-body > .numeric-keypad { align-self: start; margin-top: 0; }
+.machine-option-list { max-height: 250px; overflow-y: auto; display: grid; gap: 6px; margin-top: 0; align-content: start; }
+.machine-modal-body:not(.keypad-open) .machine-option-list { max-height: 320px; }
.machine-option-list > button { width: 100%; display: flex; align-items: center; gap: 11px; border: 1px solid #e0e6e2; border-radius: 9px; background: #fbfcfb; padding: 11px 12px; text-align: left; }
.machine-option-list > button:hover, .machine-option-list > button.selected { border-color: #a8cab9; background: #eff8f3; color: var(--green); }
.machine-option-list > button > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; background: #e7f1ec; }
-.machine-option-list > button b { flex: 1; font-size: 11px; }
-.machine-option-list > button i { font-size: 10px; font-style: normal; }
-.machine-option-list > p { text-align: center; color: #8a958f; font-size: 10px; }
-.machine-modal-close { width: 100%; margin-top: 12px; border: 1px solid #d4ddd7; border-radius: 9px; background: white; color: #68756e; padding: 10px; font-size: 9px; }
+.machine-option-list > button b { flex: 1; font-size: 13px; }
+.machine-option-list > button i { font-size: 12px; font-style: normal; }
+.machine-option-list > p { text-align: center; color: #8a958f; font-size: 12px; }
+.machine-modal-close { width: 100%; margin-top: 12px; border: 1px solid #d4ddd7; border-radius: 9px; background: white; color: #68756e; padding: 10px; font-size: 11px; }
@media (max-width: 760px) {
.app-shell { padding: 0; display: block; }
@@ -329,9 +402,9 @@ h1 em { font-style: normal; color: var(--green); }
.app-header { padding: 0 18px; }
.status-pill { font-size: 0; padding: 9px; }
.progress-strip { gap: 12px; padding-inline: 12px; }
- .progress-item { font-size: 9px; gap: 4px; }
+ .progress-item { font-size: 11px; gap: 4px; }
.home-view { padding: 38px 24px 34px; display: block; }
- h1 { font-size: 48px; }
+ h1 { font-size: 49px; }
.home-material-panel { margin-top: 30px; }
.home-start-hint { width: 100%; }
.panel-view { width: calc(100% - 32px); padding: 27px 0; }
@@ -339,8 +412,9 @@ h1 em { font-style: normal; color: var(--green); }
.refresh-button { margin-top: 15px; }
.weigh-view { padding: 16px; }
.weigh-meta > span { min-width: 0; flex: 1; }
- .weight-number span { font-size: 54px; }
- .display-details { gap: 15px; }
+ .weight-number span { font-size: 55px; }
+ .display-details { gap: 0; }
+ .display-details > span + span { margin-left: 15px; }
.scale-tools button { justify-content: center; padding: 10px 5px; }
.scale-tools button > span { display: none; }
.weigh-meta > span.material-meta { display: none; }
@@ -367,5 +441,310 @@ h1 em { font-style: normal; color: var(--green); }
.log-entry { grid-template-columns: 62px 34px 1fr; }
.settings-footer-actions { align-items: stretch; flex-direction: column; }
.save-settings { width: 100%; }
+ .machine-modal-body, .machine-modal-body.keypad-open { grid-template-columns: 1fr; }
+ .machine-option-list { max-height: 180px; }
footer { padding: 0 16px; }
}
+
+/* 工业 Android 终端的全屏横屏布局。 */
+@media (orientation: landscape) and (max-height: 820px) {
+ html, body, #root { width: 100%; min-height: 100%; }
+ body { overflow: hidden; }
+ .app-shell { width: 100vw; height: 100vh; min-height: 0; padding: 0; display: grid; }
+ .ambient { display: none; }
+ .device-frame {
+ width: 100%;
+ height: 100vh;
+ min-height: 0;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ }
+ .app-header { height: 40px; flex: 0 0 auto; padding: 0 clamp(20px, 3.5vw, 40px); }
+ .brand { gap: 0; }
+ .brand-mark { width: 32px; height: 32px; border-radius: 8px; font-size: 20px; }
+ .brand strong { font-size: 16px; }
+ .status-pill { padding: 7px 12px; }
+ .progress-strip { flex: 0 0 auto; gap: 0; padding: 9px 20px; }
+ .progress-item + .progress-item { margin-left: 28px; }
+ .content { min-height: 0; overflow-x: hidden; overflow-y: auto; }
+ footer { min-height: 18px; flex: 0 0 auto; padding: 0 clamp(20px, 3.5vw, 40px); }
+
+ .home-view {
+ min-height: 100%;
+ padding: clamp(14px, 2.6vh, 21px) clamp(28px, 4.5vw, 54px);
+ grid-template-columns: minmax(0, .82fr) minmax(430px, 1.18fr);
+ gap: clamp(26px, 4vw, 54px);
+ }
+ h1 { margin: 9px 0 11px; font-size: clamp(40px, 4.9vw, 56px); }
+ .home-copy > p { line-height: 1.42; }
+ .home-start-hint { margin-top: 10px; padding: 8px 12px; }
+ .home-start-hint span { width: 23px; height: 23px; }
+ .admin-link { margin-top: 12px; padding: 10px 13px; }
+ .home-material-panel { padding: clamp(19px, 2.5vw, 24px); }
+ .material-panel-heading h2 { margin-top: 5px; font-size: 25px; }
+ .material-panel-heading p { margin-bottom: 12px; font-size: 13px; }
+ .home-material-grid { gap: 11px; }
+ .home-material-grid .material-card { min-height: 78px; padding: 13px 15px; }
+ .home-material-grid .material-card.selected { padding: 12px 14px; }
+ .home-material-grid .material-card b { font-size: 19px; }
+ .home-material-grid .material-card small { margin-top: 6px; font-size: 11px; }
+ .material-selection-status { margin-top: 10px; padding: 8px 10px; }
+
+ .panel-view { width: min(900px, calc(100% - 40px)); padding: 12px 0; }
+ .back-button { min-height: 36px; margin-bottom: 7px; padding: 7px 13px; border-radius: 9px; }
+ .section-heading { margin-bottom: 12px; }
+ .section-heading h2, .result-view h2 { margin: 4px 0; font-size: 25px; }
+ .refresh-button { padding: 8px 12px; }
+ .identity-card { padding: 11px 14px; }
+ .avatar { width: 44px; height: 44px; border-radius: 11px; font-size: 19px; }
+ .identity-card h3 { margin: 2px 0; font-size: 18px; }
+ .field-label { margin: 12px 0 6px; }
+ .machine-select-row { min-height: 52px; padding: 7px 10px; }
+ .machine-select-icon { width: 32px; height: 32px; }
+ .full-primary { margin-top: 12px; padding: 12px 16px; }
+
+ .weigh-view { position: relative; width: min(1180px, calc(100% - 36px)); padding: 7px 0 10px; }
+ .weigh-back { position: absolute; top: 7px; left: 0; z-index: 1; margin: 0; }
+ .weigh-meta { margin: 0 0 7px 84px; padding: 7px 12px; }
+ .weigh-meta > span { min-width: 150px; }
+ .weigh-meta > span.material-meta { min-width: 200px; }
+ .scale-display { padding: 10px 20px 9px; border-radius: 14px; }
+ .weight-number { margin: 0; }
+ .weight-number span { font-size: clamp(48px, 7vw, 70px); }
+ .display-details { margin-top: 6px; }
+ .scale-tools { margin: 7px 0 8px; }
+ .scale-tools button { min-height: 42px; padding: 7px 11px; }
+ .selected-material-summary { padding: 8px 13px; }
+ .selected-material-summary b { font-size: 17px; }
+ .full-primary.upload { margin-top: 8px; padding: 12px 16px; }
+
+ .result-view {
+ width: min(980px, calc(100% - 40px));
+ padding: 12px 0;
+ display: grid;
+ grid-template-columns: 290px minmax(0, 1fr);
+ grid-template-rows: auto auto auto auto 1fr auto auto;
+ column-gap: 22px;
+ align-items: center;
+ }
+ .result-view .success-ring { grid-column: 1; grid-row: 1; }
+ .result-view > .step-kicker { grid-column: 1; grid-row: 2; }
+ .result-view > h2 { grid-column: 1; grid-row: 3; }
+ .result-view > p { grid-column: 1; grid-row: 4; }
+ .result-view > .receipt { grid-column: 2; grid-row: 1 / span 7; }
+ .result-view > .full-primary { grid-column: 1; grid-row: 6; }
+ .result-view > .countdown { grid-column: 1; grid-row: 7; }
+ .success-ring { width: 48px; height: 48px; margin-bottom: 7px; box-shadow: 0 0 0 6px rgba(223,245,237,.55); }
+ .success-ring span { width: 30px; height: 30px; font-size: 17px; }
+ .receipt { margin-top: 0; }
+ .receipt-head { padding: 9px 16px; }
+ .receipt-weight { padding: 10px 18px; }
+ .receipt-weight b { font-size: 25px; }
+ .receipt dl { padding: 3px 18px 6px; }
+ .receipt dl div { padding: 5px 0; }
+ .result-view .full-primary { margin-top: 9px; padding: 11px 16px; }
+ .countdown { margin-top: 6px; }
+
+ .settings-view {
+ width: min(1180px, calc(100% - 32px));
+ height: 100%;
+ padding: 9px 0 10px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .settings-heading { margin-bottom: 8px; }
+ .settings-heading h2 { font-size: 22px; }
+ .settings-layout { flex: 1; height: auto; min-height: 0; }
+ .settings-tabs, .settings-panel { min-height: 0; overflow-y: auto; }
+ .settings-panel { padding: 14px 18px; }
+ .settings-panel-title { min-height: 34px; margin-bottom: 9px; }
+ .settings-divider { margin: 12px 0 10px; }
+ .settings-footer-actions { margin-top: 8px; }
+ .log-viewer { height: calc(100vh - 292px); min-height: 150px; }
+
+ .modal-backdrop { padding: 12px; }
+ .admin-keypad-modal, .numeric-editor-modal { width: min(370px, 100%); padding: 15px 21px; }
+ .admin-keypad-modal .modal-icon, .numeric-editor-modal .modal-icon { width: 34px; height: 34px; margin-bottom: 5px; }
+ .admin-keypad-modal h2, .numeric-editor-modal h2 { margin-bottom: 3px; font-size: 19px; }
+ .admin-keypad-modal p { margin: 3px 0; font-size: 11px; line-height: 1.35; }
+ .numeric-display { min-height: 37px; margin-top: 7px; font-size: 19px; }
+ .numeric-keypad { gap: 6px; margin-top: 7px; }
+ .numeric-keypad button { min-height: 36px; border-radius: 8px; font-size: 18px; }
+ .numeric-keypad .key-action { font-size: 14px; }
+ .numeric-keypad .key-clear { font-size: 11px; }
+ .admin-keypad-modal .full-primary, .numeric-editor-modal .full-primary { margin-top: 8px; padding: 10px 14px; }
+
+ .machine-modal { width: min(780px, calc(100% - 20px)); max-height: calc(100vh - 24px); padding: 17px 20px; }
+ .machine-modal-heading .modal-icon { width: 36px; height: 36px; }
+ .machine-modal-heading h2 { font-size: 19px; }
+ .machine-search { height: 40px; margin-top: 9px; }
+ .machine-modal-body { grid-template-columns: minmax(0, 1fr); gap: 10px; margin-top: 8px; }
+ .machine-modal-body.keypad-open { grid-template-columns: 210px minmax(0, 1fr); }
+ .machine-modal-body:not(.keypad-open) .machine-option-list { max-height: 260px; }
+ .machine-modal-body .numeric-keypad button { min-height: 35px; }
+ .machine-option-list { max-height: 158px; }
+ .machine-option-list > button { padding: 7px 10px; }
+ .machine-modal-close { margin-top: 8px; padding: 8px; }
+}
+
+@media (orientation: landscape) and (min-width: 1400px) and (min-height: 761px) {
+ .device-frame { width: min(1460px, calc(100% - 64px)); min-height: min(880px, calc(100vh - 64px)); }
+ .home-view { grid-template-columns: minmax(0, 1fr) minmax(520px, .95fr); }
+ .panel-view { width: min(980px, calc(100% - 64px)); }
+ .weigh-view, .settings-view { width: min(1280px, calc(100% - 64px)); }
+}
+
+/* 同时兼容 Android 7.1 旧版 WebView 与现代浏览器的 Flexbox 布局。 */
+ html, body, #root { width: 100%; height: 100%; min-height: 100%; }
+ .app-shell {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ align-items: stretch;
+ justify-content: stretch;
+ padding: 0;
+ }
+ .device-frame {
+ width: 100%;
+ max-width: none;
+ height: 100%;
+ min-height: 0;
+ border: 0;
+ border-radius: 0;
+ }
+ .content { width: 100%; min-height: 0; display: block; overflow-x: hidden; overflow-y: auto; }
+
+ .brand-mark,
+ .progress-item span,
+ .home-start-hint span,
+ .material-selection-status span,
+ .avatar,
+ .machine-select-icon,
+ .material-icon,
+ .success-ring,
+ .success-ring span,
+ .receipt-weight > i,
+ .settings-tabs button > span,
+ .settings-tabs button > b,
+ .feature-icon,
+ .data-status-grid > div > span,
+ .modal-backdrop,
+ .modal-icon,
+ .numeric-keypad .key-action,
+ .machine-option-list > button > span {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .home-view {
+ width: 100%;
+ min-height: 100%;
+ display: flex;
+ align-items: center;
+ padding: 24px 4vw;
+ gap: 4vw;
+ }
+ .home-copy { width: 42%; flex: 0 1 42%; padding-right: 34px; }
+ .home-material-panel { width: 58%; flex: 1 1 58%; }
+ .home-material-grid { display: flex; flex-wrap: wrap; }
+ .home-material-grid .material-card { width: calc(50% - 6px); flex: 0 0 calc(50% - 6px); }
+ .home-material-grid .material-card:nth-child(odd) { margin-right: 12px; }
+ .home-material-grid .material-card:nth-child(n+3) { margin-top: 12px; }
+
+ .panel-view { width: calc(100% - 40px); max-width: 900px; }
+ .weigh-view { width: calc(100% - 36px); max-width: 1180px; margin: 0 auto; }
+ .result-view { width: calc(100% - 40px); max-width: 600px; }
+ .settings-view { width: calc(100% - 32px); max-width: 1180px; margin: 0 auto; }
+
+ .scale-tools { display: flex; }
+ .scale-tools button { min-width: 0; flex: 1 1 0; }
+ .scale-tools button + button { margin-left: 10px; }
+
+ .settings-layout { display: flex; }
+ .settings-tabs { width: 165px; flex: 0 0 165px; }
+ .settings-panel { flex: 1 1 auto; }
+ .settings-form-grid,
+ .material-editor,
+ .feature-settings,
+ .data-status-grid,
+ .password-settings {
+ display: flex;
+ flex-wrap: wrap;
+ }
+ .settings-form-grid > label,
+ .password-settings > label { flex: 1 1 calc(50% - 12px); }
+ .settings-form-grid.three > label { flex-basis: calc(33.33% - 12px); }
+ .material-editor-row { width: 100%; display: flex; align-items: flex-end; }
+ .material-editor-row label:first-child { flex: 1 1 40%; }
+ .material-editor-row label:nth-of-type(2) { flex: 1 1 55%; }
+ .material-editor-row > i { flex: 0 0 22px; }
+ .path-box { display: flex; }
+ .path-box small { flex: 0 0 100px; }
+ .path-box code { flex: 1 1 auto; }
+ .data-status-grid > div { width: calc(50% - 6px); display: flex; align-items: center; }
+ .data-status-grid > div > span { flex: 0 0 38px; margin-right: 10px; }
+ .data-status-grid > div > small { flex: 1 1 auto; }
+ .data-status-grid > div > b,
+ .data-status-grid > div > em { margin-left: 8px; }
+ .log-entry { display: flex; }
+ .log-entry time { flex: 0 0 72px; }
+ .log-entry > span { flex: 0 0 36px; }
+ .log-entry p { flex: 1 1 auto; }
+
+ .numeric-keypad { display: flex; flex-wrap: wrap; }
+ .numeric-keypad button { width: calc(33.33% - 6px); flex: 0 0 calc(33.33% - 6px); margin: 0 6px 6px 0; }
+ .numeric-keypad button:nth-child(3n) { margin-right: 0; }
+ .machine-modal { width: calc(100% - 20px); max-width: 780px; }
+ .machine-modal-body { display: flex; flex-direction: column; }
+ .machine-modal-body.keypad-open { flex-direction: row; }
+ .machine-modal-body.keypad-open > .numeric-keypad { width: 210px; flex: 0 0 210px; }
+ .machine-option-list { display: flex; flex: 1 1 auto; flex-direction: column; }
+
+@media (orientation: landscape) and (max-height: 820px) {
+ .weight-number span {
+ font-size: 44px;
+ font-weight: 900;
+ letter-spacing: -.05em;
+ }
+ .result-view {
+ position: relative;
+ display: block;
+ width: calc(100% - 40px);
+ max-width: 980px;
+ min-height: 100%;
+ padding: 16px 0;
+ }
+ .result-view > .step-kicker,
+ .result-view > h2,
+ .result-view > p,
+ .result-view > .full-primary,
+ .result-view > .countdown {
+ width: 290px;
+ }
+ .result-view > .success-ring { margin-left: 121px; margin-right: 0; }
+ .result-view > .full-primary {
+ position: absolute;
+ top: 230px;
+ left: 0;
+ display: block;
+ min-height: 44px;
+ margin: 0;
+ }
+ .result-view > .countdown {
+ position: absolute;
+ top: 282px;
+ left: 0;
+ margin: 0;
+ }
+ .result-view > .receipt {
+ position: absolute;
+ top: 14px;
+ right: 0;
+ width: calc(100% - 312px);
+ margin: 0;
+ }
+}
diff --git a/app/page.tsx b/app/page.tsx
index d88e1a9..2f28cb2 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,10 +1,18 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ createFileTimestamp,
+ evaluateStability,
+ formatWeight,
+ parseScaleMessage,
+ type WeightUnit,
+} from "./scale-utils";
type Step = "home" | "identity" | "weigh" | "result" | "settings";
-type Unit = "kg" | "g" | "lb";
+type Unit = WeightUnit;
type Language = "zh-CN" | "zh-TW" | "en";
+type ScaleStatus = "unconfigured" | "connecting" | "connected" | "error";
type SettingsTab = "device" | "materials" | "peripherals" | "data" | "security" | "logs";
type Material = {
id: string;
@@ -38,6 +46,22 @@ type LogEntry = {
level: "info" | "success" | "warning" | "error";
message: string;
};
+type PasswordField = "current" | "next" | "confirm";
+type NumericEditor =
+ | { kind: "material"; index: number; title: string; maxLength: number }
+ | { kind: "password"; field: PasswordField; title: string; maxLength: number };
+
+declare global {
+ interface Window {
+ WoerNative?: {
+ saveBackup: (fileName: string, content: string) => boolean;
+ appendLog: (fileName: string, content: string) => boolean;
+ exportLogs: (fileName: string, content: string) => boolean;
+ openSystemSettings: () => boolean;
+ openFileManager: () => boolean;
+ };
+ }
+}
const DEFAULT_MATERIALS: Material[] = [
{ id: "3", name: "废铜", tone: "copper" },
@@ -53,7 +77,7 @@ const DEFAULT_SETTINGS: AppSettings = {
upload: "",
deviceName: "智能称重终端 01",
deviceId: "SCALE-01",
- imei: "868120060012345",
+ imei: "",
photoEnabled: false,
printEnabled: false,
language: "zh-CN",
@@ -67,27 +91,30 @@ const TRANSLATIONS: Record> = {
loadingEmployee: "正在读取门禁人员信息", chooseAuto: "请选择一种废料类型,选择后自动进入下一步", employeeMachine: "人员与机台", confirmInfo: "确认本次称重信息",
identityDesc: "人员信息由门禁机自动获取,请选择对应生产机台。", refreshEmployee: "重新获取", currentEmployee: "当前操作员工", employeeId: "工号", verified: "已验证",
back: "返回", machineNo: "生产机台号", selectMachine: "选择机台", refreshMachine: "刷新机台", selectedMachine: "已选择机台", noMachine: "尚未选择生产机台",
- confirmWeigh: "确认并进入称重", employee: "员工", machine: "机台", materialType: "废料类型", editIdentity: "修改人员/机台", stable: "重量已稳定", stabilizing: "正在稳定采样",
+ confirmWeigh: "确认并进入称重", employee: "员工", machine: "机台", materialType: "废料类型", editIdentity: "修改人员/机台", stable: "重量已稳定",
net: "净重", total: "累计", tareLabel: "皮重", zero: "清零", zeroDesc: "消除空秤误差", tareAction: "去皮", tareDesc: "扣除容器重量",
accumulate: "累计", accumulateDesc: "加入当前重量", cancelAccum: "取消累计", cancelAccumDesc: "撤销上一次累计", currentMaterial: "本次废料类型", confirmedHome: "已在首页确认",
- uploading: "正在上传…", saveWeight: "保存称重数据", waitStable: "请等待重量稳定", weighComplete: "称重完成", dataSaved: "数据已保存", uploadQueue: "本次称重记录已进入上传队列",
- receipt: "称重凭证", employeeName: "员工姓名", productionMachine: "生产机台", uploadStatus: "上传状态", saved: "已保存", returnHome: "返回首页", autoReturn: "秒后自动返回首页",
+ uploading: "正在上传…", saveWeight: "保存称重数据", waitStable: "请等待重量稳定", weighComplete: "称重完成", dataSaved: "数据已保存", uploadQueue: "服务器暂时不可用,记录已备份并进入补传队列", uploadSuccess: "本次称重记录已上传并完成本地备份",
+ receipt: "称重凭证", employeeName: "员工姓名", productionMachine: "生产机台", uploadStatus: "上传状态", saved: "已上传", pendingUpload: "待补传", returnHome: "返回首页", autoReturn: "秒后自动返回首页",
adminCenter: "管理员中心", appSettings: "应用与设备设置", settingsDesc: "管理设备、废料字典、外设、数据安全与运行日志。", exitAdmin: "退出管理",
deviceTab: "设备与接口", materialsTab: "废料字典", peripheralsTab: "拍照与打印", dataTab: "补传与备份", securityTab: "安全设置", logsTab: "实时日志",
deviceInfo: "设备信息", deviceInfoDesc: "用于标识当前智能称重终端", localDevice: "本机", deviceName: "设备名称", deviceId: "设备号", imei: "IMEI 号",
- language: "界面语言", languageDesc: "切换后立即应用到操作界面", serverApi: "本地服务器接口", serverApiDesc: "留空时自动使用演示数据",
+ language: "界面语言", languageDesc: "切换后立即应用到操作界面", serverApi: "本地服务器接口", serverApiDesc: "接口未配置时不会生成模拟数据",
materialDict: "废料字典", materialDictDesc: "修改后将同步更新首页分类与称重数据", displayName: "显示名称",
- saveAll: "保存全部设置", saveHint: "修改完成后请保存设置", passwordTitle: "管理员登录", passwordDesc: "请输入管理员密码进入应用设置", initialPassword: "初始密码为 88888888", confirmLogin: "确认登录",
- employeeApi: "人员信息接口", machineApi: "机台列表接口", scaleApi: "电子秤数据接口", uploadApi: "称重上传接口",
+ saveAll: "保存全部设置", saveHint: "修改完成后请保存设置", passwordTitle: "管理员登录", passwordDesc: "请输入管理员密码进入应用设置", confirmLogin: "确认登录",
+ employeeApi: "人员信息接口", machineApi: "机台列表接口", scaleApi: "电子秤数据接口", uploadApi: "称重上传接口", apiAddressPlaceholder: "请输入完整接口地址",
peripheralTitle: "完成称重后的外设动作", peripheralDesc: "功能默认关闭,需由 Android 原生层接入 USB 设备", reserved: "预留接口", cameraTitle: "USB 摄像头拍照", cameraDesc: "提交成功后自动拍摄现场照片",
photoPath: "照片保存路径", fileName: "文件名", printTitle: "称重凭证打印", printDesc: "提交成功后调用 USB / 蓝牙打印机", scrapWeight: "废料重量", weighingTime: "称重时间",
dataTitle: "数据补传与本地备份", dataDesc: "网络异常时确保称重记录不丢失", autoProtect: "自动保护", pending: "待补传数据", localBackup: "本地备份", records: "条记录",
backupPath: "本地备份路径", autoWrite: "称重提交时自动写入", retrying: "正在补传…", retryAll: "立即补传全部数据", retryHint: "补传成功后自动清理待传队列,本地备份仍会保留。",
adminPassword: "管理员密码", passwordAdvice: "默认密码为 88888888,建议部署后立即修改", currentPassword: "当前密码", newPassword: "新密码", confirmPassword: "确认新密码", updatePassword: "更新管理员密码",
liveLogs: "实时运行日志", logDesc: "最多保留最近 100 条运行记录", exportLogs: "导出日志", androidLogPath: "Android 日志保存路径", dailySave: "按日期滚动保存",
- demoData: "当前使用模拟数据,接口接入后将自动切换", employeeError: "暂时无法连接本地服务器,请重新获取", machineError: "机台列表获取失败,请检查服务器连接", stableNeeded: "请等待重量稳定后再保存",
- zeroed: "秤台已清零", tared: "已去皮,仅显示净重", accumulationNeeded: "重量稳定后才能累计", loginError: "密码不正确,默认密码为 88888888",
- machineDialogTitle: "选择生产机台", machineDialogDesc: "支持输入机台号或名称进行模糊搜索", searchMachine: "搜索机台号或名称", close: "关闭",
+ employeeError: "人员信息获取失败,请检查人员接口", machineError: "机台列表获取失败,请检查机台接口", stableNeeded: "请等待重量稳定后再保存",
+ scaleUnconfigured: "电子秤接口未配置", scaleConnecting: "正在连接电子秤", scaleConnected: "正在稳定采样", scaleError: "电子秤连接异常",
+ zeroed: "秤台已清零", tared: "已去皮,仅显示净重", accumulationNeeded: "重量稳定后才能累计", loginError: "密码不正确",
+ systemTools: "系统工具", systemToolsDesc: "打开终端的 Android 系统功能", systemSettings: "系统设置", fileManager: "文件管理",
+ machineDialogTitle: "选择生产机台", machineDialogDesc: "点击搜索框后,使用数字键盘筛选机台号", searchMachine: "点击输入机台号", close: "关闭",
+ numberClear: "清空", numberDelete: "删除", numberConfirm: "确定", numberInput: "请输入数字",
},
"zh-TW": {
brandTitle: "智慧稱重終端", online: "設備在線", progressEmployee: "人員確認", progressWeight: "智慧稱重", progressResult: "結果上傳",
@@ -96,27 +123,30 @@ const TRANSLATIONS: Record> = {
loadingEmployee: "正在讀取門禁人員資訊", chooseAuto: "請選擇一種廢料類型,選擇後自動進入下一步", employeeMachine: "人員與機台", confirmInfo: "確認本次稱重資訊",
identityDesc: "人員資訊由門禁機自動取得,請選擇對應生產機台。", refreshEmployee: "重新取得", currentEmployee: "目前操作員工", employeeId: "工號", verified: "已驗證",
back: "返回", machineNo: "生產機台號", selectMachine: "選擇機台", refreshMachine: "刷新機台", selectedMachine: "已選擇機台", noMachine: "尚未選擇生產機台",
- confirmWeigh: "確認並進入稱重", employee: "員工", machine: "機台", materialType: "廢料類型", editIdentity: "修改人員/機台", stable: "重量已穩定", stabilizing: "正在穩定採樣",
+ confirmWeigh: "確認並進入稱重", employee: "員工", machine: "機台", materialType: "廢料類型", editIdentity: "修改人員/機台", stable: "重量已穩定",
net: "淨重", total: "累計", tareLabel: "皮重", zero: "清零", zeroDesc: "消除空秤誤差", tareAction: "去皮", tareDesc: "扣除容器重量",
accumulate: "累計", accumulateDesc: "加入目前重量", cancelAccum: "取消累計", cancelAccumDesc: "撤銷上一次累計", currentMaterial: "本次廢料類型", confirmedHome: "已在首頁確認",
- uploading: "正在上傳…", saveWeight: "儲存稱重資料", waitStable: "請等待重量穩定", weighComplete: "稱重完成", dataSaved: "資料已儲存", uploadQueue: "本次稱重記錄已進入上傳佇列",
- receipt: "稱重憑證", employeeName: "員工姓名", productionMachine: "生產機台", uploadStatus: "上傳狀態", saved: "已儲存", returnHome: "返回首頁", autoReturn: "秒後自動返回首頁",
+ uploading: "正在上傳…", saveWeight: "儲存稱重資料", waitStable: "請等待重量穩定", weighComplete: "稱重完成", dataSaved: "資料已儲存", uploadQueue: "伺服器暫時不可用,記錄已備份並進入補傳佇列", uploadSuccess: "本次稱重記錄已上傳並完成本機備份",
+ receipt: "稱重憑證", employeeName: "員工姓名", productionMachine: "生產機台", uploadStatus: "上傳狀態", saved: "已上傳", pendingUpload: "待補傳", returnHome: "返回首頁", autoReturn: "秒後自動返回首頁",
adminCenter: "管理員中心", appSettings: "應用與設備設定", settingsDesc: "管理設備、廢料字典、外設、資料安全與運行日誌。", exitAdmin: "退出管理",
deviceTab: "設備與介面", materialsTab: "廢料字典", peripheralsTab: "拍照與列印", dataTab: "補傳與備份", securityTab: "安全設定", logsTab: "即時日誌",
deviceInfo: "設備資訊", deviceInfoDesc: "用於識別目前智慧稱重終端", localDevice: "本機", deviceName: "設備名稱", deviceId: "設備號", imei: "IMEI 號",
- language: "介面語言", languageDesc: "切換後立即套用到操作介面", serverApi: "本地伺服器介面", serverApiDesc: "留空時自動使用示範資料",
+ language: "介面語言", languageDesc: "切換後立即套用到操作介面", serverApi: "本地伺服器介面", serverApiDesc: "介面未設定時不會產生模擬資料",
materialDict: "廢料字典", materialDictDesc: "修改後將同步更新首頁分類與稱重資料", displayName: "顯示名稱",
- saveAll: "儲存全部設定", saveHint: "修改完成後請儲存設定", passwordTitle: "管理員登入", passwordDesc: "請輸入管理員密碼進入應用設定", initialPassword: "初始密碼為 88888888", confirmLogin: "確認登入",
- employeeApi: "人員資訊介面", machineApi: "機台列表介面", scaleApi: "電子秤資料介面", uploadApi: "稱重上傳介面",
+ saveAll: "儲存全部設定", saveHint: "修改完成後請儲存設定", passwordTitle: "管理員登入", passwordDesc: "請輸入管理員密碼進入應用設定", confirmLogin: "確認登入",
+ employeeApi: "人員資訊介面", machineApi: "機台列表介面", scaleApi: "電子秤資料介面", uploadApi: "稱重上傳介面", apiAddressPlaceholder: "請輸入完整介面地址",
peripheralTitle: "完成稱重後的外設動作", peripheralDesc: "功能預設關閉,需由 Android 原生層接入 USB 設備", reserved: "預留介面", cameraTitle: "USB 攝影機拍照", cameraDesc: "提交成功後自動拍攝現場照片",
photoPath: "照片儲存路徑", fileName: "檔名", printTitle: "稱重憑證列印", printDesc: "提交成功後呼叫 USB / 藍牙印表機", scrapWeight: "廢料重量", weighingTime: "稱重時間",
dataTitle: "資料補傳與本地備份", dataDesc: "網路異常時確保稱重記錄不遺失", autoProtect: "自動保護", pending: "待補傳資料", localBackup: "本地備份", records: "條記錄",
backupPath: "本地備份路徑", autoWrite: "稱重提交時自動寫入", retrying: "正在補傳…", retryAll: "立即補傳全部資料", retryHint: "補傳成功後自動清理待傳佇列,本地備份仍會保留。",
adminPassword: "管理員密碼", passwordAdvice: "預設密碼為 88888888,建議部署後立即修改", currentPassword: "目前密碼", newPassword: "新密碼", confirmPassword: "確認新密碼", updatePassword: "更新管理員密碼",
liveLogs: "即時運行日誌", logDesc: "最多保留最近 100 條運行記錄", exportLogs: "匯出日誌", androidLogPath: "Android 日誌儲存路徑", dailySave: "依日期滾動儲存",
- demoData: "目前使用模擬資料,介面接入後將自動切換", employeeError: "暫時無法連接本地伺服器,請重新取得", machineError: "機台列表取得失敗,請檢查伺服器連線", stableNeeded: "請等待重量穩定後再儲存",
- zeroed: "秤台已清零", tared: "已去皮,僅顯示淨重", accumulationNeeded: "重量穩定後才能累計", loginError: "密碼不正確,預設密碼為 88888888",
- machineDialogTitle: "選擇生產機台", machineDialogDesc: "支援輸入機台號或名稱進行模糊搜尋", searchMachine: "搜尋機台號或名稱", close: "關閉",
+ employeeError: "人員資訊取得失敗,請檢查人員介面", machineError: "機台列表取得失敗,請檢查機台介面", stableNeeded: "請等待重量穩定後再儲存",
+ scaleUnconfigured: "電子秤介面未設定", scaleConnecting: "正在連接電子秤", scaleConnected: "正在穩定採樣", scaleError: "電子秤連線異常",
+ zeroed: "秤台已清零", tared: "已去皮,僅顯示淨重", accumulationNeeded: "重量穩定後才能累計", loginError: "密碼不正確",
+ systemTools: "系統工具", systemToolsDesc: "開啟終端的 Android 系統功能", systemSettings: "系統設定", fileManager: "檔案管理",
+ machineDialogTitle: "選擇生產機台", machineDialogDesc: "點擊搜尋框後,使用數字鍵盤篩選機台號", searchMachine: "點擊輸入機台號", close: "關閉",
+ numberClear: "清空", numberDelete: "刪除", numberConfirm: "確定", numberInput: "請輸入數字",
},
en: {
brandTitle: "Smart Weighing Terminal", online: "Device online", progressEmployee: "Operator", progressWeight: "Weighing", progressResult: "Upload",
@@ -125,27 +155,30 @@ const TRANSLATIONS: Record> = {
loadingEmployee: "Reading access-control operator data", chooseAuto: "Choose a scrap type to continue automatically", employeeMachine: "Operator & machine", confirmInfo: "Confirm weighing information",
identityDesc: "Operator data is read automatically. Select the production machine.", refreshEmployee: "Refresh operator", currentEmployee: "Current operator", employeeId: "Employee ID", verified: "Verified",
back: "Back", machineNo: "Production machine", selectMachine: "Select machine", refreshMachine: "Refresh", selectedMachine: "Selected machine", noMachine: "No production machine selected",
- confirmWeigh: "Confirm and start weighing", employee: "Operator", machine: "Machine", materialType: "Scrap type", editIdentity: "Edit operator/machine", stable: "Weight stable", stabilizing: "Stabilizing samples",
+ confirmWeigh: "Confirm and start weighing", employee: "Operator", machine: "Machine", materialType: "Scrap type", editIdentity: "Edit operator/machine", stable: "Weight stable",
net: "Net", total: "Accumulated", tareLabel: "Tare", zero: "Zero", zeroDesc: "Remove empty-scale offset", tareAction: "Tare", tareDesc: "Subtract container weight",
accumulate: "Accumulate", accumulateDesc: "Add current weight", cancelAccum: "Cancel total", cancelAccumDesc: "Undo last accumulation", currentMaterial: "Selected scrap type", confirmedHome: "Confirmed on home",
- uploading: "Uploading…", saveWeight: "Save weighing record", waitStable: "Wait for stable weight", weighComplete: "Weighing complete", dataSaved: "Data saved", uploadQueue: "This record has entered the upload queue",
- receipt: "Weighing receipt", employeeName: "Employee name", productionMachine: "Production machine", uploadStatus: "Upload status", saved: "Saved", returnHome: "Return home", autoReturn: "seconds to return home",
+ uploading: "Uploading…", saveWeight: "Save weighing record", waitStable: "Wait for stable weight", weighComplete: "Weighing complete", dataSaved: "Data saved", uploadQueue: "Server unavailable; the record is backed up and queued for retry", uploadSuccess: "The record was uploaded and backed up locally",
+ receipt: "Weighing receipt", employeeName: "Employee name", productionMachine: "Production machine", uploadStatus: "Upload status", saved: "Uploaded", pendingUpload: "Pending retry", returnHome: "Return home", autoReturn: "seconds to return home",
adminCenter: "ADMIN CENTER", appSettings: "Application & device settings", settingsDesc: "Manage the device, scrap dictionary, peripherals, data, and logs.", exitAdmin: "Exit settings",
deviceTab: "Device & APIs", materialsTab: "Scrap dictionary", peripheralsTab: "Photo & print", dataTab: "Retry & backup", securityTab: "Security", logsTab: "Live logs",
deviceInfo: "Device information", deviceInfoDesc: "Identifies this smart weighing terminal", localDevice: "Local", deviceName: "Device name", deviceId: "Device ID", imei: "IMEI",
- language: "Interface language", languageDesc: "Changes are applied to the interface immediately", serverApi: "Local server APIs", serverApiDesc: "Leave blank to use demo data",
+ language: "Interface language", languageDesc: "Changes are applied to the interface immediately", serverApi: "Local server APIs", serverApiDesc: "No sample data is generated when an API is not configured",
materialDict: "Scrap dictionary", materialDictDesc: "Changes update home categories and weighing records", displayName: "Display name",
- saveAll: "Save all settings", saveHint: "Save after making changes", passwordTitle: "Administrator login", passwordDesc: "Enter the administrator password to open settings", initialPassword: "Initial password: 88888888", confirmLogin: "Log in",
- employeeApi: "Operator API", machineApi: "Machine list API", scaleApi: "Scale data API", uploadApi: "Record upload API",
+ saveAll: "Save all settings", saveHint: "Save after making changes", passwordTitle: "Administrator login", passwordDesc: "Enter the administrator password to open settings", confirmLogin: "Log in",
+ employeeApi: "Operator API", machineApi: "Machine list API", scaleApi: "Scale data API", uploadApi: "Record upload API", apiAddressPlaceholder: "Enter the complete API address",
peripheralTitle: "Post-weighing peripheral actions", peripheralDesc: "Off by default; requires the Android native USB bridge", reserved: "Reserved", cameraTitle: "USB camera capture", cameraDesc: "Capture a site photo after successful submission",
photoPath: "Photo save path", fileName: "Filename", printTitle: "Print weighing receipt", printDesc: "Call a USB / Bluetooth printer after submission", scrapWeight: "Scrap weight", weighingTime: "Weighing time",
dataTitle: "Retry upload & local backup", dataDesc: "Protect weighing records during network failures", autoProtect: "Automatic", pending: "Pending uploads", localBackup: "Local backups", records: "records",
backupPath: "Local backup path", autoWrite: "Written automatically on submission", retrying: "Retrying…", retryAll: "Retry all uploads", retryHint: "Successful uploads are removed from the queue; local backups are retained.",
adminPassword: "Administrator password", passwordAdvice: "Default: 88888888. Change it after deployment.", currentPassword: "Current password", newPassword: "New password", confirmPassword: "Confirm password", updatePassword: "Update password",
liveLogs: "Live runtime logs", logDesc: "Keeps the latest 100 runtime entries", exportLogs: "Export logs", androidLogPath: "Android log path", dailySave: "Rotated by date",
- demoData: "Using demo data until the local API is connected", employeeError: "Cannot reach the local server. Please refresh.", machineError: "Could not load machines. Check the server connection.", stableNeeded: "Wait for a stable weight before saving",
- zeroed: "Scale zeroed", tared: "Tare applied; showing net weight", accumulationNeeded: "Weight must be stable before accumulation", loginError: "Incorrect password. Default: 88888888",
- machineDialogTitle: "Select production machine", machineDialogDesc: "Search by machine number or name", searchMachine: "Search machine number or name", close: "Close",
+ employeeError: "Could not load the operator. Check the operator API.", machineError: "Could not load machines. Check the machine API.", stableNeeded: "Wait for a stable weight before saving",
+ scaleUnconfigured: "Scale API is not configured", scaleConnecting: "Connecting to scale", scaleConnected: "Stabilizing samples", scaleError: "Scale connection error",
+ zeroed: "Scale zeroed", tared: "Tare applied; showing net weight", accumulationNeeded: "Weight must be stable before accumulation", loginError: "Incorrect password",
+ systemTools: "System tools", systemToolsDesc: "Open Android system functions", systemSettings: "System settings", fileManager: "File manager",
+ machineDialogTitle: "Select production machine", machineDialogDesc: "Tap the search field to open the number pad", searchMachine: "Tap to enter machine number", close: "Close",
+ numberClear: "Clear", numberDelete: "Delete", numberConfirm: "Confirm", numberInput: "Enter digits",
},
};
@@ -153,31 +186,52 @@ const CONFIG_KEY = "smart-scale-config";
const BACKUP_KEY = "smart-scale-backup-records";
const PENDING_KEY = "smart-scale-pending-records";
const LOG_KEY = "smart-scale-logs";
+const PRODUCTION_DATA_KEY = "smart-scale-production-data-v1";
const IMAGE_PATH = "/sdcard/smart/image/";
const CACHE_PATH = "/sdcard/smart/cache/";
const LOG_PATH = "/sdcard/smart/logs/";
-const machines = ["A01 绕线机", "A02 押出机", "B03 裁线机", "B06 编织机", "C12 包装机"];
+/**
+ * 数字输入统一使用应用内键盘,避免工业终端弹出系统输入法。
+ */
+function NumericKeypad({
+ value,
+ onChange,
+ maxLength,
+ clearLabel,
+ deleteLabel,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ maxLength: number;
+ clearLabel: string;
+ deleteLabel: string;
+}) {
+ const appendDigit = (digit: string) => {
+ if (value.length < maxLength) onChange(`${value}${digit}`);
+ };
-const unitFactors: Record = { kg: 1, g: 1000, lb: 2.20462262 };
-
-function formatWeight(valueKg: number, unit: Unit) {
- const value = Math.max(0, valueKg) * unitFactors[unit];
- const digits = unit === "g" ? 0 : 3;
- return value.toFixed(digits);
-}
-
-function createFileTimestamp(iso: string) {
- return iso.replace(/\D/g, "").slice(0, 14);
+ return (
+
+ {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((digit) => (
+
+ ))}
+
+
+
+
+ );
}
export default function Home() {
+ // 称重主流程状态:首页 → 人员/机台确认 → 称重 → 结果。
const [step, setStep] = useState("home");
const [employee, setEmployee] = useState({ name: "", id: "" });
const [machineQuery, setMachineQuery] = useState("");
const [machine, setMachine] = useState("");
- const [machineOptions, setMachineOptions] = useState(machines);
+ const [machineOptions, setMachineOptions] = useState([]);
const [machineModalOpen, setMachineModalOpen] = useState(false);
+ const [machineKeypadOpen, setMachineKeypadOpen] = useState(false);
const [machineLoading, setMachineLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState("");
@@ -190,6 +244,7 @@ export default function Home() {
const [material, setMaterial] = useState(null);
const [stable, setStable] = useState(false);
const [stableProgress, setStableProgress] = useState(0);
+ const [scaleStatus, setScaleStatus] = useState("unconfigured");
const [countdown, setCountdown] = useState(10);
const [passwordOpen, setPasswordOpen] = useState(false);
const [password, setPassword] = useState("");
@@ -201,24 +256,37 @@ export default function Home() {
const [settingsMessage, setSettingsMessage] = useState("");
const [settingsBusy, setSettingsBusy] = useState(false);
const [passwordForm, setPasswordForm] = useState({ current: "", next: "", confirm: "" });
+ const [numericEditor, setNumericEditor] = useState(null);
+ const [numericEditorValue, setNumericEditorValue] = useState("");
const [pendingRecords, setPendingRecords] = useState([]);
const [backupRecords, setBackupRecords] = useState([]);
const [lastRecord, setLastRecord] = useState(null);
+ const [lastUploadPending, setLastUploadPending] = useState(false);
const [logs, setLogs] = useState([
{
id: "boot",
time: new Date().toISOString(),
level: "success",
- message: "应用启动完成,称重设备连接正常",
+ message: "应用启动完成",
},
]);
const stableSamples = useRef([]);
+ const netWeightRef = useRef(0);
const netWeight = Math.max(0, rawWeight - zeroOffset - tare);
+ netWeightRef.current = netWeight;
const displayedWeight = totalKg + netWeight;
const selectedMaterial = material ?? materialOptions[0];
const language = appSettings.language;
const t = (key: string) => TRANSLATIONS[language]?.[key] ?? TRANSLATIONS["zh-CN"][key] ?? key;
+ const scaleStatusText = stable
+ ? t("stable")
+ : t({
+ unconfigured: "scaleUnconfigured",
+ connecting: "scaleConnecting",
+ connected: "scaleConnected",
+ error: "scaleError",
+ }[scaleStatus]);
const materialName = (item: Material) => {
const names: Record> = {
"zh-CN": { copper: "废铜", wire: "废电线", aluminum: "废铝", tin: "废锡" },
@@ -238,59 +306,122 @@ export default function Home() {
);
useEffect(() => {
- try {
- const savedConfig = window.localStorage.getItem(CONFIG_KEY);
- if (savedConfig) {
- const parsed = JSON.parse(savedConfig);
- if (parsed.settings) setAppSettings({ ...DEFAULT_SETTINGS, ...parsed.settings });
- if (parsed.version >= 2 && Array.isArray(parsed.materials) && parsed.materials.length === 4) {
- setMaterialOptions(parsed.materials.map((item: Material) => ({ id: item.id, name: item.name, tone: item.tone })));
- } else {
- setMaterialOptions(DEFAULT_MATERIALS);
+ // 延迟到首帧之后恢复配置,避免服务端渲染与本地配置产生水合差异。
+ const timer = window.setTimeout(() => {
+ try {
+ /*
+ * 早期测试版会自动生成模拟人员、机台和重量记录。
+ * 首次升级到正式数据模式时仅清理这些历史业务记录,
+ * 管理员密码、接口地址和废料字典等配置继续保留。
+ */
+ if (window.localStorage.getItem(PRODUCTION_DATA_KEY) !== "ready") {
+ window.localStorage.removeItem(PENDING_KEY);
+ window.localStorage.removeItem(BACKUP_KEY);
+ window.localStorage.removeItem(LOG_KEY);
+ window.localStorage.setItem(PRODUCTION_DATA_KEY, "ready");
}
- if (typeof parsed.password === "string") setAdminPassword(parsed.password);
+
+ const savedConfig = window.localStorage.getItem(CONFIG_KEY);
+ if (savedConfig) {
+ const parsed = JSON.parse(savedConfig);
+ if (parsed.settings) setAppSettings({ ...DEFAULT_SETTINGS, ...parsed.settings });
+ if (parsed.version >= 2 && Array.isArray(parsed.materials) && parsed.materials.length === 4) {
+ setMaterialOptions(parsed.materials.map((item: Partial, index: number) => ({
+ id: String(item.id ?? DEFAULT_MATERIALS[index].id),
+ name: String(item.name ?? DEFAULT_MATERIALS[index].name),
+ tone: DEFAULT_MATERIALS[index].tone,
+ })));
+ } else {
+ setMaterialOptions(DEFAULT_MATERIALS);
+ }
+ if (typeof parsed.password === "string") setAdminPassword(parsed.password);
+ }
+ const savedPending = window.localStorage.getItem(PENDING_KEY);
+ const savedBackup = window.localStorage.getItem(BACKUP_KEY);
+ const savedLogs = window.localStorage.getItem(LOG_KEY);
+ if (savedPending) {
+ const parsedPending = JSON.parse(savedPending);
+ if (Array.isArray(parsedPending)) setPendingRecords(parsedPending);
+ }
+ if (savedBackup) {
+ const parsedBackup = JSON.parse(savedBackup);
+ if (Array.isArray(parsedBackup)) setBackupRecords(parsedBackup);
+ }
+ if (savedLogs) {
+ const parsedLogs = JSON.parse(savedLogs);
+ if (Array.isArray(parsedLogs)) setLogs(parsedLogs);
+ }
+ } catch {
+ addLog("本地配置读取失败,已使用默认设置", "warning");
}
- const savedPending = window.localStorage.getItem(PENDING_KEY);
- const savedBackup = window.localStorage.getItem(BACKUP_KEY);
- const savedLogs = window.localStorage.getItem(LOG_KEY);
- if (savedPending) setPendingRecords(JSON.parse(savedPending));
- if (savedBackup) setBackupRecords(JSON.parse(savedBackup));
- if (savedLogs) setLogs(JSON.parse(savedLogs));
- } catch {
- addLog("本地配置读取失败,已使用默认设置", "warning");
- }
+ }, 0);
+ return () => window.clearTimeout(timer);
}, []);
+ /*
+ * 称重数据只接受已配置的 WebSocket 接口,不再注入任何模拟重量。
+ * 服务端可发送纯数字或带 weight / weightKg / value 字段的 JSON。
+ */
+ useEffect(() => {
+ if (step !== "weigh") return;
+ if (!appSettings.weight.trim()) return;
+
+ let socket: WebSocket;
+ let disposed = false;
+
+ try {
+ socket = new WebSocket(appSettings.weight.trim());
+ } catch {
+ window.setTimeout(() => {
+ setScaleStatus("error");
+ addLog("电子秤接口地址无效", "error");
+ }, 0);
+ return;
+ }
+
+ socket.onopen = () => {
+ if (disposed) return;
+ setScaleStatus("connected");
+ addLog("电子秤数据接口连接成功", "success");
+ };
+ socket.onmessage = (event) => {
+ const nextWeight = parseScaleMessage(String(event.data ?? ""));
+ if (nextWeight !== null) setRawWeight(nextWeight);
+ };
+ socket.onerror = () => {
+ if (disposed) return;
+ setScaleStatus("error");
+ addLog("电子秤数据接口连接异常", "error");
+ };
+ socket.onclose = () => {
+ if (disposed) return;
+ setScaleStatus("error");
+ };
+
+ return () => {
+ disposed = true;
+ socket.close();
+ };
+ }, [appSettings.weight, step]);
+
+ /*
+ * 按固定周期连续采样,而不是依赖 React 的重量变化事件。
+ * 即使电子秤连续返回完全相同的数值,也能正确完成稳定判断。
+ */
useEffect(() => {
if (step !== "weigh") return;
- const start = Date.now();
const timer = window.setInterval(() => {
- const elapsed = (Date.now() - start) / 1000;
- const target = elapsed < 1.4 ? 0 : 2.486;
- const movement = elapsed < 3 ? Math.sin(elapsed * 9) * 0.085 : Math.sin(elapsed * 3) * 0.0012;
- setRawWeight(Math.max(0, target + movement));
- }, 180);
+ stableSamples.current = [...stableSamples.current.slice(-7), netWeightRef.current];
+ const result = evaluateStability(stableSamples.current);
+ setStable(result.stable);
+ setStableProgress(result.progress);
+ }, 200);
return () => window.clearInterval(timer);
}, [step]);
- useEffect(() => {
- if (step !== "weigh") return;
- stableSamples.current = [...stableSamples.current.slice(-7), netWeight];
- const samples = stableSamples.current;
- if (samples.length < 6 || netWeight < 0.01) {
- setStable(false);
- setStableProgress(Math.min(85, samples.length * 14));
- return;
- }
- const spread = Math.max(...samples) - Math.min(...samples);
- const isStable = spread < 0.008;
- setStable(isStable);
- setStableProgress(isStable ? 100 : Math.max(25, 90 - spread * 500));
- }, [netWeight, step]);
-
+ // 结果页停留 10 秒后自动复位,避免上一位员工的数据残留。
useEffect(() => {
if (step !== "result") return;
- setCountdown(10);
const timer = window.setInterval(() => {
setCountdown((value) => {
if (value <= 1) {
@@ -302,22 +433,35 @@ export default function Home() {
});
}, 1000);
return () => window.clearInterval(timer);
+ // resetToHome 只读取当前状态并执行复位,不参与计时器创建。
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
function resetToHome() {
setStep("home");
setMaterial(null);
+ setEmployee({ name: "", id: "" });
setMachine("");
setMachineQuery("");
+ setMachineKeypadOpen(false);
+ setMachineModalOpen(false);
setTotalKg(0);
setRawWeight(0);
setZeroOffset(0);
setTare(0);
setStable(false);
+ setStableProgress(0);
+ setScaleStatus(appSettings.weight.trim() ? "connecting" : "unconfigured");
setLastAccumulation(null);
+ setLastUploadPending(false);
+ setNotice("");
stableSamples.current = [];
}
+ /**
+ * 同时记录到界面、本机缓存和 Android 日志目录。
+ * 任一存储渠道失败都不能阻断称重主流程。
+ */
function addLog(message: string, level: LogEntry["level"] = "info") {
const entry: LogEntry = {
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
@@ -325,29 +469,59 @@ export default function Home() {
level,
message,
};
+ try {
+ const day = createFileTimestamp(entry.time).slice(0, 8);
+ const line = `[${new Date(entry.time).toLocaleString("zh-CN", { hour12: false })}] [${level.toUpperCase()}] ${message}`;
+ window.WoerNative?.appendLog(`smart-scale-${day}.log`, line);
+ } catch {
+ // 浏览器预览环境没有 Android 文件桥接,只保留 localStorage 日志。
+ }
setLogs((current) => {
const next = [entry, ...current].slice(0, 100);
try {
window.localStorage.setItem(LOG_KEY, JSON.stringify(next));
} catch {
- // Native Android builds write to LOG_PATH through the reserved bridge.
+ // 本机缓存不可用时,Android 原生日志文件仍可继续保存。
}
return next;
});
}
function persistConfiguration(nextPassword = adminPassword) {
- window.localStorage.setItem(
- CONFIG_KEY,
- JSON.stringify({
- version: 2,
- settings: appSettings,
- materials: materialOptions,
- password: nextPassword,
- }),
- );
+ const normalizedMaterials = materialOptions.map((item) => ({
+ ...item,
+ id: item.id.trim(),
+ name: item.name.trim(),
+ }));
+ if (normalizedMaterials.some((item) => !item.name || !/^\d+$/.test(item.id))) {
+ setSettingsMessage("废料名称不能为空,Type ID 必须为数字");
+ addLog("设置保存失败:废料字典存在无效内容", "warning");
+ return false;
+ }
+ if (new Set(normalizedMaterials.map((item) => item.id)).size !== normalizedMaterials.length) {
+ setSettingsMessage("Type ID 不能重复,请修改后再保存");
+ addLog("设置保存失败:废料 Type ID 重复", "warning");
+ return false;
+ }
+ try {
+ window.localStorage.setItem(
+ CONFIG_KEY,
+ JSON.stringify({
+ version: 3,
+ settings: appSettings,
+ materials: normalizedMaterials,
+ password: nextPassword,
+ }),
+ );
+ } catch {
+ setSettingsMessage("设置保存失败,请检查本机存储空间");
+ addLog("管理员设置写入失败", "error");
+ return false;
+ }
+ setMaterialOptions(normalizedMaterials);
addLog("管理员设置已保存", "success");
setSettingsMessage("全部设置已保存,本机配置已更新");
+ return true;
}
function updateMaterial(index: number, field: "name" | "id", value: string) {
@@ -357,6 +531,23 @@ export default function Home() {
setSettingsMessage("");
}
+ function openNumericEditor(editor: NumericEditor, value: string) {
+ setNumericEditor(editor);
+ setNumericEditorValue(value.replace(/\D/g, "").slice(0, editor.maxLength));
+ }
+
+ function applyNumericEditor() {
+ if (!numericEditor) return;
+ if (numericEditor.kind === "material") {
+ updateMaterial(numericEditor.index, "id", numericEditorValue);
+ } else {
+ setPasswordForm((current) => ({ ...current, [numericEditor.field]: numericEditorValue }));
+ setSettingsMessage("");
+ }
+ setNumericEditor(null);
+ setNumericEditorValue("");
+ }
+
async function fetchIdentity(chosenMaterial?: Material) {
const activeMaterial = chosenMaterial ?? material;
if (!activeMaterial) {
@@ -364,22 +555,21 @@ export default function Home() {
return;
}
if (chosenMaterial) setMaterial(chosenMaterial);
+ setEmployee({ name: "", id: "" });
setLoading(true);
setNotice("");
try {
- if (appSettings.employee) {
- const response = await fetch(appSettings.employee);
- if (!response.ok) throw new Error("接口不可用");
- const data = await response.json();
- setEmployee({ name: data.name, id: data.employeeId });
- } else {
- await new Promise((resolve) => window.setTimeout(resolve, 650));
- setEmployee({ name: "张伟", id: "E-0826" });
- setNotice(t("demoData"));
- }
- addLog(`人员信息获取成功:${employee.name || "张伟"}`, "success");
+ if (!appSettings.employee.trim()) throw new Error("人员接口未配置");
+ const response = await fetch(appSettings.employee.trim());
+ if (!response.ok) throw new Error("人员接口不可用");
+ const data = await response.json();
+ if (!data?.name || !data?.employeeId) throw new Error("人员数据不完整");
+ const nextEmployee = { name: String(data.name), id: String(data.employeeId) };
+ setEmployee(nextEmployee);
+ addLog(`人员信息获取成功:${nextEmployee.name}`, "success");
setStep("identity");
} catch {
+ setEmployee({ name: "", id: "" });
addLog("门禁人员信息获取失败", "error");
setNotice(t("employeeError"));
setStep("identity");
@@ -392,21 +582,22 @@ export default function Home() {
setMachineLoading(true);
setSettingsMessage("");
try {
- if (appSettings.machines) {
- const response = await fetch(appSettings.machines);
- if (!response.ok) throw new Error("machine api unavailable");
- const data = await response.json();
- const next = Array.isArray(data) ? data : data.machines;
- if (!Array.isArray(next)) throw new Error("invalid machine data");
- setMachineOptions(next.map((item: string | { id?: string; name?: string }) =>
- typeof item === "string" ? item : `${item.id ?? ""} ${item.name ?? ""}`.trim(),
- ));
- } else {
- await new Promise((resolve) => window.setTimeout(resolve, 550));
- setMachineOptions(machines);
- }
+ if (!appSettings.machines.trim()) throw new Error("机台接口未配置");
+ const response = await fetch(appSettings.machines.trim());
+ if (!response.ok) throw new Error("机台接口不可用");
+ const data = await response.json();
+ const next = Array.isArray(data) ? data : data.machines;
+ if (!Array.isArray(next)) throw new Error("机台数据格式无效");
+ const parsedMachines = next
+ .map((item: string | { id?: string; name?: string }) =>
+ typeof item === "string" ? item.trim() : `${item.id ?? ""} ${item.name ?? ""}`.trim(),
+ )
+ .filter(Boolean);
+ if (!parsedMachines.length) throw new Error("机台数据为空");
+ setMachineOptions(parsedMachines);
addLog("生产机台列表刷新成功", "success");
} catch {
+ setMachineOptions([]);
setNotice(t("machineError"));
addLog("生产机台列表刷新失败", "error");
} finally {
@@ -414,11 +605,28 @@ export default function Home() {
}
}
+ function closePasswordDialog() {
+ setPasswordOpen(false);
+ setPassword("");
+ setPasswordError("");
+ }
+
+ function beginWeighing() {
+ // 每次进入称重页都从干净采样窗口开始,避免上一次读数影响稳定判断。
+ setRawWeight(0);
+ setZeroOffset(0);
+ setTare(0);
+ setStable(false);
+ setStableProgress(0);
+ stableSamples.current = [];
+ setScaleStatus(appSettings.weight.trim() ? "connecting" : "unconfigured");
+ setNotice("");
+ setStep("weigh");
+ }
+
function enterAdmin() {
if (password === adminPassword) {
- setPasswordOpen(false);
- setPassword("");
- setPasswordError("");
+ closePasswordDialog();
setSettingsMessage("");
addLog("管理员登录成功", "success");
setStep("settings");
@@ -429,36 +637,62 @@ export default function Home() {
}
function backupRecord(record: WeightRecord) {
+ let nativeSaved = true;
+ try {
+ const suffix = record.employeeId.replace(/[^A-Za-z0-9_-]/g, "_");
+ nativeSaved = window.WoerNative?.saveBackup(
+ `${createFileTimestamp(record.createdAt)}-${suffix}.json`,
+ JSON.stringify(record, null, 2),
+ ) ?? true;
+ } catch {
+ nativeSaved = false;
+ }
setBackupRecords((current) => {
const next = [...current, record].slice(-500);
- window.localStorage.setItem(BACKUP_KEY, JSON.stringify(next));
+ try {
+ window.localStorage.setItem(BACKUP_KEY, JSON.stringify(next));
+ } catch {
+ nativeSaved = false;
+ }
return next;
});
- addLog(`称重数据已备份至 ${CACHE_PATH}`, "success");
+ addLog(
+ nativeSaved ? `称重数据已备份至 ${CACHE_PATH}` : `本地备份文件写入失败:${CACHE_PATH}`,
+ nativeSaved ? "success" : "error",
+ );
}
function queuePendingRecord(record: WeightRecord) {
setPendingRecords((current) => {
const next = [...current, record];
- window.localStorage.setItem(PENDING_KEY, JSON.stringify(next));
+ try {
+ window.localStorage.setItem(PENDING_KEY, JSON.stringify(next));
+ } catch {
+ // Android 文件备份已先完成;缓存写入失败不阻断结果页。
+ }
return next;
});
addLog("上传失败,数据已加入补传队列", "warning");
}
function runCompletionHooks(record: WeightRecord) {
+ // 拍照与打印当前仅保留触发点,等待原生 USB 驱动接入。
if (appSettings.photoEnabled) {
const filename = `${createFileTimestamp(record.createdAt)}.jpg`;
addLog(`USB 摄像头拍照调用:${IMAGE_PATH}${filename}`, "success");
}
if (appSettings.printEnabled) {
addLog(
- `打印任务已发送:员工 ${record.employeeName},工号 ${record.employeeId},机台 ${record.machineId},废料 ${record.materialType},重量 ${record.weightKg.toFixed(3)} kg,时间 ${new Date(record.createdAt).toLocaleString("zh-CN", { hour12: false })}`,
+ `打印任务已触发:员工 ${record.employeeName},工号 ${record.employeeId},机台 ${record.machineId},废料 ${record.materialType},重量 ${record.weightKg.toFixed(2)} kg,时间 ${new Date(record.createdAt).toLocaleString("zh-CN", { hour12: false })}`,
"success",
);
}
}
+ /**
+ * 先做本地备份,再尝试上传;接口异常时保留到补传队列。
+ * 这样网络故障不会造成称重记录丢失。
+ */
async function submitWeight() {
if (!stable) {
setNotice(t("stableNeeded"));
@@ -472,29 +706,28 @@ export default function Home() {
machineId: machine,
materialType: selectedMaterial.name,
materialTypeId: selectedMaterial.id,
- weightKg: Number(displayedWeight.toFixed(3)),
+ weightKg: Number(displayedWeight.toFixed(2)),
createdAt: new Date().toISOString(),
};
setLastRecord(payload);
+ setLastUploadPending(false);
backupRecord(payload);
try {
- if (appSettings.upload) {
- const response = await fetch(appSettings.upload, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- if (!response.ok) throw new Error("上传失败");
- } else {
- await new Promise((resolve) => window.setTimeout(resolve, 700));
- }
- addLog(`称重数据上传成功:${payload.weightKg.toFixed(3)} kg`, "success");
- runCompletionHooks(payload);
- setStep("result");
+ if (!appSettings.upload.trim()) throw new Error("上传接口未配置");
+ const response = await fetch(appSettings.upload.trim(), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) throw new Error("上传失败");
+ addLog(`称重数据上传成功:${payload.weightKg.toFixed(2)} kg`, "success");
} catch {
queuePendingRecord(payload);
- setNotice("数据上传失败,请检查服务器连接后重试");
+ setLastUploadPending(true);
} finally {
+ runCompletionHooks(payload);
+ setCountdown(10);
+ setStep("result");
setLoading(false);
}
}
@@ -528,20 +761,21 @@ export default function Home() {
addLog("手动检查补传队列:无待补传数据");
return;
}
+ if (!appSettings.upload.trim()) {
+ setSettingsMessage("称重上传接口未配置,待补传数据已保留");
+ addLog("数据补传已取消:上传接口未配置", "warning");
+ return;
+ }
setSettingsBusy(true);
setSettingsMessage("");
try {
- if (appSettings.upload) {
- for (const record of pendingRecords) {
- const response = await fetch(appSettings.upload, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(record),
- });
- if (!response.ok) throw new Error("补传失败");
- }
- } else {
- await new Promise((resolve) => window.setTimeout(resolve, 900));
+ for (const record of pendingRecords) {
+ const response = await fetch(appSettings.upload.trim(), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(record),
+ });
+ if (!response.ok) throw new Error("补传失败");
}
const count = pendingRecords.length;
setPendingRecords([]);
@@ -569,8 +803,8 @@ export default function Home() {
setSettingsMessage("两次输入的新密码不一致");
return;
}
+ if (!persistConfiguration(passwordForm.next)) return;
setAdminPassword(passwordForm.next);
- persistConfiguration(passwordForm.next);
setPasswordForm({ current: "", next: "", confirm: "" });
setSettingsMessage("管理员密码已修改,下次登录时生效");
addLog("管理员密码已修改", "success");
@@ -580,15 +814,41 @@ export default function Home() {
const content = logs
.map((entry) => `[${new Date(entry.time).toLocaleString("zh-CN", { hour12: false })}] [${entry.level.toUpperCase()}] ${entry.message}`)
.join("\n");
- const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = `smart-scale-${createFileTimestamp(new Date().toISOString())}.log`;
- link.click();
- URL.revokeObjectURL(url);
- addLog(`日志已导出,目标目录 ${LOG_PATH}`, "success");
- setSettingsMessage(`日志已导出,Android 设备保存至 ${LOG_PATH}`);
+ const fileName = `smart-scale-export-${createFileTimestamp(new Date().toISOString())}.log`;
+ let exported = false;
+ try {
+ exported = window.WoerNative?.exportLogs(fileName, content) ?? false;
+ } catch {
+ exported = false;
+ }
+ if (!exported) {
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = fileName;
+ link.click();
+ URL.revokeObjectURL(url);
+ }
+ addLog(
+ exported ? `日志已导出,目标目录 ${LOG_PATH}` : "日志已通过浏览器下载",
+ exported ? "success" : "warning",
+ );
+ setSettingsMessage(exported ? `日志已导出至 ${LOG_PATH}` : "日志已通过浏览器下载");
+ }
+
+ function openSystemDestination(destination: "settings" | "files") {
+ let opened = false;
+ try {
+ opened = destination === "settings"
+ ? window.WoerNative?.openSystemSettings() ?? false
+ : window.WoerNative?.openFileManager() ?? false;
+ } catch {
+ opened = false;
+ }
+ const label = destination === "settings" ? t("systemSettings") : t("fileManager");
+ setSettingsMessage(opened ? `${label}已打开` : `${label}无法打开`);
+ addLog(`${label}${opened ? "已打开" : "打开失败"}`, opened ? "success" : "error");
}
return (
@@ -636,7 +896,7 @@ export default function Home() {
{loading ? "···" : "1"}
{loading ? t("loadingEmployee") : t("clickMaterial")}
-
+
{notice && ⓘ {notice}
}
@@ -649,14 +909,14 @@ export default function Home() {
{materialOptions.map((item) => (
))}
@@ -688,13 +948,13 @@ export default function Home() {
▣
{machine ? t("selectedMachine") : t("machineNo")}{machine || t("noMachine")}
-
+
@@ -715,7 +975,7 @@ export default function Home() {
- {stable ? t("stable") : t("stabilizing")}
+ {scaleStatusText}
{(["kg", "g", "lb"] as Unit[]).map((item) => (
@@ -735,7 +995,7 @@ export default function Home() {
-
+
{lastAccumulation &&
}
@@ -761,18 +1021,18 @@ export default function Home() {
✓
{t("weighComplete")}
{t("dataSaved")}
-
{t("uploadQueue")}
+
{t(lastUploadPending ? "uploadQueue" : "uploadSuccess")}
{t("receipt")}{new Date(lastRecord?.createdAt ?? new Date()).toLocaleString(language, { hour12: false })}
- {materialName(selectedMaterial)}{formatWeight(displayedWeight, unit)} {unit}
+ {materialName(selectedMaterial)}{formatWeight(lastRecord?.weightKg ?? displayedWeight, unit)} {unit}
- {t("employeeName")}
- {employee.name}
- {t("employeeId")}
- {employee.id}
- {t("productionMachine")}
- {machine}
- {t("materialType")} ID
- {selectedMaterial.id}
- - {t("uploadStatus")}
- ● {t("saved")}
+ - {t("uploadStatus")}
- ● {t(lastUploadPending ? "pendingUpload" : "saved")}
@@ -841,23 +1101,30 @@ export default function Home() {
))}
+
+
{t("systemTools")}{t("systemToolsDesc")}
+
+
+
+
+
{t("serverApi")}{t("serverApiDesc")}
{[
- ["employee", t("employeeApi"), "http://192.168.1.20/api/employee"],
- ["machines", t("machineApi"), "http://192.168.1.20/api/machines"],
- ["weight", t("scaleApi"), "ws://192.168.1.21/weight"],
- ["upload", t("uploadApi"), "http://192.168.1.20/api/records"],
- ].map(([key, label, placeholder]) => (
+ ["employee", t("employeeApi")],
+ ["machines", t("machineApi")],
+ ["weight", t("scaleApi")],
+ ["upload", t("uploadApi")],
+ ].map(([key, label]) => (
))}
@@ -875,7 +1142,17 @@ export default function Home() {
{materialOptions.map((item, index) => (
-
+
0{index + 1}
))}
@@ -935,9 +1212,25 @@ export default function Home() {
8–12 位
-
-
-
+ {([
+ ["current", t("currentPassword")],
+ ["next", t("newPassword")],
+ ["confirm", t("confirmPassword")],
+ ] as [PasswordField, string][]).map(([field, label]) => (
+
+ ))}
>
@@ -975,23 +1268,43 @@ export default function Home() {
{passwordOpen && (
-
setPasswordOpen(false)}>
-
event.stopPropagation()}>
-
+
+
event.stopPropagation()}>
+
⌁
{t("passwordTitle")}
-
{t("passwordDesc")}
{t("initialPassword")}
-
setPassword(event.target.value.replace(/\D/g, ""))} onKeyDown={(event) => event.key === "Enter" && enterAdmin()} placeholder="••••••••" aria-label="管理员密码" />
+
{t("passwordDesc")}
+
+ {password ? "●".repeat(password.length) : t("numberInput")}
+
+
{ setPassword(value); setPasswordError(""); }} maxLength={12} clearLabel={t("numberClear")} deleteLabel={t("numberDelete")} />
{passwordError && {passwordError}}
)}
+ {numericEditor && (
+
setNumericEditor(null)}>
+
event.stopPropagation()}>
+
+
#
+
{numericEditor.title}
+
+ {numericEditorValue
+ ? numericEditor.kind === "password" ? "●".repeat(numericEditorValue.length) : numericEditorValue
+ : t("numberInput")}
+
+
+
+
+
+ )}
+
{machineModalOpen && (
-
setMachineModalOpen(false)}>
+
{ setMachineKeypadOpen(false); setMachineModalOpen(false); }}>
event.stopPropagation()}>
-
+
▣
{t("machineDialogTitle")}
{t("machineDialogDesc")}
@@ -999,25 +1312,39 @@ export default function Home() {
⌕
- setMachineQuery(event.target.value)} placeholder={t("searchMachine")} autoFocus />
+ setMachineKeypadOpen(true)}
+ onClick={() => setMachineKeypadOpen(true)}
+ />
-
- {filteredMachines.map((item) => (
-
- ))}
- {!filteredMachines.length &&
{t("noMachine")}
}
+
+ {machineKeypadOpen &&
}
+
+ {filteredMachines.map((item) => (
+
+ ))}
+ {!filteredMachines.length &&
{t("noMachine")}
}
+
-
+
)}
diff --git a/app/scale-utils.ts b/app/scale-utils.ts
new file mode 100644
index 0000000..21c270a
--- /dev/null
+++ b/app/scale-utils.ts
@@ -0,0 +1,97 @@
+export type WeightUnit = "kg" | "g" | "lb";
+
+const UNIT_FACTORS: Record
= {
+ kg: 1,
+ g: 1000,
+ lb: 2.20462262,
+};
+
+/**
+ * 将内部统一保存的千克值转换为界面单位,并固定显示两位小数。
+ */
+export function formatWeight(valueKg: number, unit: WeightUnit) {
+ const safeValue = Number.isFinite(valueKg) ? Math.max(0, valueKg) : 0;
+ return (safeValue * UNIT_FACTORS[unit]).toFixed(2);
+}
+
+/**
+ * 把 ISO 时间转换为终端文件名使用的本地时间格式:YYYYMMDDHHmmss。
+ */
+export function createFileTimestamp(iso: string) {
+ const date = new Date(iso);
+ const pad = (value: number) => value < 10 ? `0${value}` : String(value);
+
+ return [
+ date.getFullYear(),
+ pad(date.getMonth() + 1),
+ pad(date.getDate()),
+ pad(date.getHours()),
+ pad(date.getMinutes()),
+ pad(date.getSeconds()),
+ ].join("");
+}
+
+/**
+ * 兼容电子秤服务常见的消息格式:
+ * - 纯数字,例如 `2.49`
+ * - JSON,例如 `{"weight": 2490, "unit": "g"}`
+ * - JSON 千克字段,例如 `{"weightKg": 2.49}`
+ *
+ * 返回值统一为千克;无效值、负数或不支持的单位返回 null。
+ */
+export function parseScaleMessage(message: string): number | null {
+ const trimmed = message.trim();
+ if (!trimmed) return null;
+
+ let payload: unknown = trimmed;
+ try {
+ payload = JSON.parse(trimmed);
+ } catch {
+ // 非 JSON 消息继续按纯数字处理。
+ }
+
+ if (typeof payload === "number" || typeof payload === "string") {
+ const value = Number(payload);
+ return Number.isFinite(value) && value >= 0 ? value : null;
+ }
+
+ if (!payload || typeof payload !== "object") return null;
+
+ const record = payload as Record;
+ const rawValue = record.weightKg ?? record.weight ?? record.value;
+ const value = Number(rawValue);
+ if (!Number.isFinite(value) || value < 0) return null;
+
+ const unit = String(record.unit ?? (record.weightKg != null ? "kg" : "kg")).toLowerCase();
+ if (unit === "kg") return value;
+ if (unit === "g") return value / 1000;
+ if (unit === "lb" || unit === "lbs") return value / UNIT_FACTORS.lb;
+ return null;
+}
+
+export type StabilityResult = {
+ stable: boolean;
+ progress: number;
+};
+
+/**
+ * 根据连续采样窗口判断重量是否稳定。
+ * 至少需要 6 个有效样本,样本极差小于 8 克时判定稳定。
+ */
+export function evaluateStability(samples: number[]): StabilityResult {
+ // 不使用 Array.prototype.at,确保兼容终端内置的 Chrome 52 WebView。
+ const latest = samples.length ? samples[samples.length - 1] : 0;
+ if (samples.length < 6 || latest < 0.01) {
+ return {
+ stable: false,
+ progress: Math.min(85, samples.length * 14),
+ };
+ }
+
+ const spread = Math.max(...samples) - Math.min(...samples);
+ const stable = spread < 0.008;
+ return {
+ stable,
+ progress: stable ? 100 : Math.max(25, 90 - spread * 500),
+ };
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 05e726d..39963ec 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -9,8 +9,16 @@ const eslintConfig = defineConfig([
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
+ ".vinext/**",
+ "dist/**",
"out/**",
"build/**",
+ "android-app/dist/**",
+ "android-app/node_modules/**",
+ "android-app/android/.gradle/**",
+ "android-app/android/build/**",
+ "android-app/android/app/build/**",
+ "android-app/android/app/src/main/assets/**",
"next-env.d.ts",
]),
]);
diff --git a/package.json b/package.json
index ae4351f..7117bfa 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
- "test": "npm run build && node --test tests/rendered-html.test.mjs",
+ "test": "pnpm run build && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate"
},
diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs
index 11ec629..e095e93 100644
--- a/tests/rendered-html.test.mjs
+++ b/tests/rendered-html.test.mjs
@@ -1,13 +1,8 @@
import assert from "node:assert/strict";
-import { access, readFile, readdir } from "node:fs/promises";
+import { readFile } from "node:fs/promises";
import test from "node:test";
-const developmentPreviewMeta =
- /]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
-const templateRoot = new URL("../", import.meta.url);
-const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
-
-async function render() {
+async function renderHome() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
@@ -28,64 +23,31 @@ async function render() {
);
}
-test("server-renders the starter loading skeleton", async () => {
- const response = await render();
+test("首页可正常服务端渲染正式称重界面", async () => {
+ const response = await renderHome();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
- assert.match(html, developmentPreviewMeta);
- assert.match(html, /Your site is taking shape<\/title>/i);
- assert.match(html, /Building your site/);
- assert.match(html, /Your site is taking shape/);
- assert.match(
- html,
- /Your first version will appear here automatically when it’s ready\./,
- );
- assert.doesNotMatch(html, /Codex/);
- assert.match(html, /react-loading-skeleton/);
- assert.match(html, /role="status"/);
+ assert.match(html, /智能称重终端|沃尔核材<\/title>/);
+ assert.match(html, /选择废料类型/);
+ assert.match(html, /管理员登录/);
+ assert.match(html, /TYPE ID · (?:)?3/);
+ assert.match(html, /TYPE ID · (?:)?6/);
});
-test("keeps the loading skeleton scoped and disposable", async () => {
- const [preview, css, page, layout, packageJson, files] = await Promise.all([
- readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
- readFile(new URL("preview.css", previewRoot), "utf8"),
+test("正式代码不包含历史人员、机台或重量模拟数据", async () => {
+ const [page, utilities] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
- readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
- readFile(new URL("../package.json", import.meta.url), "utf8"),
- readdir(previewRoot),
+ readFile(new URL("../app/scale-utils.ts", import.meta.url), "utf8"),
]);
- assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
- assert.match(preview, /from "react-loading-skeleton"/);
- assert.match(preview, /baseColor="#eceae7"/);
- assert.match(preview, /highlightColor="#f9f8f6"/);
- assert.match(preview, /duration=\{2\.8\}/);
- assert.match(preview, /sites-skeleton-search-placeholder/);
- assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
+ for (const legacySample of ["张伟", "E-0826", "A01 绕线机", "2.486"]) {
+ assert.doesNotMatch(page, new RegExp(legacySample));
+ }
- const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
- const statusIndex = preview.indexOf('className="sites-skeleton-status"');
- assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
- assert.match(css, /position:\s*fixed/);
- assert.match(css, /inset:\s*0/);
- assert.match(css, /opacity:\s*0\.52/);
- assert.match(css, /prefers-reduced-motion:\s*reduce/);
- assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
- assert.doesNotMatch(
- preview,
- /loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
- );
-
- assert.match(page, /export const metadata:\s*Metadata/);
- assert.match(page, /"codex-preview": "development"/);
- assert.match(page, //);
- assert.match(layout, /title:\s*"Starter Project"/);
- assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
- assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
-
- await assert.rejects(
- access(new URL("public/_sites-preview", templateRoot)),
- );
+ assert.match(page, /new WebSocket\(appSettings\.weight\.trim\(\)\)/);
+ assert.match(page, /if \(!appSettings\.upload\.trim\(\)\) throw/);
+ assert.match(page, /evaluateStability\(stableSamples\.current\)/);
+ assert.match(utilities, /返回值统一为千克/);
});
diff --git a/tests/scale-utils.test.mjs b/tests/scale-utils.test.mjs
new file mode 100644
index 0000000..43848b9
--- /dev/null
+++ b/tests/scale-utils.test.mjs
@@ -0,0 +1,44 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ createFileTimestamp,
+ evaluateStability,
+ formatWeight,
+ parseScaleMessage,
+} from "../app/scale-utils.ts";
+
+test("电子秤消息统一转换为千克", () => {
+ assert.equal(parseScaleMessage("2.49"), 2.49);
+ assert.equal(parseScaleMessage('{"weight":2490,"unit":"g"}'), 2.49);
+ assert.equal(parseScaleMessage('{"weightKg":2.49}'), 2.49);
+ assert.ok(Math.abs(parseScaleMessage('{"value":2.20462262,"unit":"lb"}') - 1) < 1e-9);
+});
+
+test("电子秤消息拒绝非法数据", () => {
+ assert.equal(parseScaleMessage(""), null);
+ assert.equal(parseScaleMessage("-1"), null);
+ assert.equal(parseScaleMessage('{"weight":"abc"}'), null);
+ assert.equal(parseScaleMessage('{"weight":1,"unit":"oz"}'), null);
+});
+
+test("连续相同读数可完成稳定判断", () => {
+ assert.deepEqual(evaluateStability([2.49, 2.49, 2.49, 2.49, 2.49]), {
+ stable: false,
+ progress: 70,
+ });
+ assert.deepEqual(evaluateStability([2.49, 2.49, 2.49, 2.49, 2.49, 2.49]), {
+ stable: true,
+ progress: 100,
+ });
+ assert.equal(evaluateStability([2.4, 2.5, 2.42, 2.51, 2.43, 2.5]).stable, false);
+});
+
+test("重量格式和文件时间戳符合终端规范", () => {
+ assert.equal(formatWeight(2.486, "kg"), "2.49");
+ assert.equal(formatWeight(2.486, "g"), "2486.00");
+ assert.equal(formatWeight(Number.NaN, "kg"), "0.00");
+
+ const localTime = new Date(2026, 6, 28, 16, 9, 5).toISOString();
+ assert.equal(createFileTimestamp(localTime), "20260728160905");
+});
diff --git a/tsconfig.json b/tsconfig.json
index 3a13f90..8603368 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,5 +30,11 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
- "exclude": ["node_modules"]
+ "exclude": [
+ "node_modules",
+ "android-app",
+ "db",
+ "examples",
+ "worker"
+ ]
}