Prepare production smart scale Android app

This commit is contained in:
FinleyHsu
2026-07-28 16:44:40 +08:00
parent af50188332
commit eab3ca651a
70 changed files with 4286 additions and 459 deletions
+1
View File
@@ -36,6 +36,7 @@ yarn-error.log*
# typescript
next-env.d.ts
*.tsbuildinfo
/dist/
/.wrangler/
/outputs/
+67 -78
View File
@@ -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`
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
outputs/
android/.gradle/
android/.idea/
android/build/
android/app/build/
android/local.properties
+101
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -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")
}
@@ -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()
}
+21
View File
@@ -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
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<receiver
android:name=".BootReceiver"
android:directBootAware="true"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:screenOrientation="sensorLandscape"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
</manifest>
@@ -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);
}
}
}
@@ -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();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">WOER 智能称重</string>
<string name="title_activity_main">WOER 智能称重</string>
<string name="package_name">com.woer.smartscale</string>
<string name="custom_url_scheme">com.woer.smartscale</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+29
View File
@@ -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
}
@@ -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')
+22
View File
@@ -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
Binary file not shown.
@@ -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
Vendored Executable
+251
View File
@@ -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" "$@"
+94
View File
@@ -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
+5
View File
@@ -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'
+16
View File
@@ -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'
}
+15
View File
@@ -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;
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<meta name="theme-color" content="#07111f" />
<title>WOER 智能称重</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -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"
}
}
+1694
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>,
);
+20
View File
@@ -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"]
}
+31
View File
@@ -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(
/<script type="module" crossorigin src="([^"]+)"><\/script>/,
'<script defer src="$1"></script>',
);
},
},
},
],
resolve: {
dedupe: ["react", "react-dom"],
},
build: {
// 生产终端为 Android 7.1,内置 Chromium WebView 52。
target: "chrome52",
cssTarget: "chrome52",
sourcemap: false,
},
});
+495 -116
View File
@@ -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;
}
}
+533 -206
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
export type WeightUnit = "kg" | "g" | "lb";
const UNIT_FACTORS: Record<WeightUnit, number> = {
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<string, unknown>;
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),
};
}
+8
View File
@@ -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",
]),
]);
+1 -1
View File
@@ -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"
},
+19 -57
View File
@@ -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 =
/<meta(?=[^>]*\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, /<title>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>智能称重终端|沃尔核材<\/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, /<SkeletonPreview \/>/);
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, /返回值统一为千克/);
});
+44
View File
@@ -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");
});
+7 -1
View File
@@ -30,5 +30,11 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules",
"android-app",
"db",
"examples",
"worker"
]
}