完善智能称重终端界面与电子秤接入

This commit is contained in:
FinleyHsu
2026-08-06 10:48:47 +08:00
parent 839edd1e4a
commit 3b856f0c11
8 changed files with 967 additions and 181 deletions
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<package android:name="com.android.hct.android_hid_scale_service" />
</queries>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -0,0 +1,302 @@
/*
* modbus_poller.c — RS-485 Modbus RTU 串口轮询器
* 针对 Firefly AIO-3399C 工业终端优化
* RS485 端口: /dev/ttysWK0 (wk2xxx SPI-UART 桥接芯片)
*
* 编译: aarch64-linux-android26-clang -O2 -o modbus_poller modbus_poller.c
*
* 用法:
* modbus_poller /dev/ttysWK0 [baudRate] [slaveId] [funcCode] [regAddr] [regCount] [dataType] [byteOrder] [pollMs]
*
* 默认: /dev/ttysWK0 9600 1 4 0 2 float32 ABCD 125
*
* 输出 (stdout, 每行一个 JSON):
* {"status":"connecting","message":"..."}
* {"status":"connected","message":"..."}
* {"status":"sample","weightKg":12.34}
* {"status":"error","message":"..."}
* {"status":"disconnected"}
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <termios.h>
#include <sys/ioctl.h>
#include <time.h>
#include <stdint.h>
static volatile int g_running = 1;
static void on_signal(int sig) {
(void)sig;
g_running = 0;
}
/* ---- Modbus CRC-16 (polynomial 0xA001, init 0xFFFF) ---- */
static uint16_t crc16(const uint8_t *data, int len) {
uint16_t crc = 0xFFFF;
for (int i = 0; i < len; i++) {
crc ^= (uint16_t)data[i];
for (int j = 0; j < 8; j++) {
if (crc & 1)
crc = (crc >> 1) ^ 0xA001;
else
crc >>= 1;
}
}
return crc;
}
/* ---- 设置串口参数 ---- */
static int setup_serial(int fd, int baud_rate) {
struct termios t;
memset(&t, 0, sizeof(t)); /* 先清零,防止 tcgetattr 失败时使用垃圾值 */
if (tcgetattr(fd, &t) < 0) {
fprintf(stderr, "[modbus] tcgetattr warning: %s (using defaults)\n", strerror(errno));
/* tcgetattr 失败时 t 已经是零,继续配置 */
}
cfmakeraw(&t);
t.c_cflag &= ~(CSIZE | CSTOPB | PARENB | PARODD);
t.c_cflag |= CS8 | CLOCAL | CREAD;
t.c_iflag = 0;
t.c_oflag = 0;
t.c_lflag = 0;
t.c_cc[VMIN] = 0;
t.c_cc[VTIME] = 0;
/* 设置波特率 */
speed_t speed;
switch (baud_rate) {
case 1200: speed = B1200; break;
case 2400: speed = B2400; break;
case 4800: speed = B4800; break;
case 9600: speed = B9600; break;
case 19200: speed = B19200; break;
case 38400: speed = B38400; break;
case 57600: speed = B57600; break;
case 115200: speed = B115200; break;
default: speed = B9600; break;
}
cfsetispeed(&t, speed);
cfsetospeed(&t, speed);
if (tcsetattr(fd, TCSANOW, &t) < 0) {
fprintf(stderr, "[modbus] tcsetattr warning: %s\n", strerror(errno));
}
/* 确保非阻塞模式 */
int fl = fcntl(fd, F_GETFL, 0);
if (fl >= 0) fcntl(fd, F_SETFL, fl | O_NONBLOCK);
return 0;
}
/* ---- 发送 Modbus RTU 请求并读取响应 ---- */
static int modbus_transaction(int fd, int slave_id, int func_code,
int reg_addr, int reg_count,
uint8_t *response, int resp_max) {
uint8_t request[8];
request[0] = (uint8_t)slave_id;
request[1] = (uint8_t)func_code;
request[2] = (uint8_t)(reg_addr >> 8);
request[3] = (uint8_t)(reg_addr & 0xFF);
request[4] = (uint8_t)(reg_count >> 8);
request[5] = (uint8_t)(reg_count & 0xFF);
uint16_t crc = crc16(request, 6);
request[6] = (uint8_t)(crc & 0xFF);
request[7] = (uint8_t)(crc >> 8);
/* 清空接收缓冲区 */
tcflush(fd, TCIOFLUSH);
/* 发送请求(Firefly RS485 硬件自动方向控制,无需手动 RTS)*/
int written = write(fd, request, 8);
if (written != 8) {
return -1;
}
/* 等待发送完成 */
tcdrain(fd);
/* 短暂延时等待 RS485 硬件方向切换 */
usleep(5000);
/* 读取响应(非阻塞 + 手动超时)*/
int expected = 3 + 2 * reg_count + 2;
int total_read = 0;
struct timespec t_start, t_now;
clock_gettime(CLOCK_MONOTONIC, &t_start);
while (total_read < expected && g_running) {
int n = read(fd, response + total_read, resp_max - total_read);
if (n > 0) {
total_read += n;
if (total_read >= 3) {
int byte_count = response[2];
int actual_len = 3 + byte_count + 2;
if (total_read >= actual_len) break;
}
} else {
usleep(2000);
}
clock_gettime(CLOCK_MONOTONIC, &t_now);
double elapsed = (t_now.tv_sec - t_start.tv_sec) * 1000.0 +
(t_now.tv_nsec - t_start.tv_nsec) / 1000000.0;
if (elapsed > 500.0) break;
}
if (total_read < 5) return -2;
if (total_read < 3) return -2;
/* 验证 CRC */
uint16_t resp_crc = crc16(response, total_read - 2);
uint16_t recv_crc = (uint16_t)response[total_read - 2] |
((uint16_t)response[total_read - 1] << 8);
if (resp_crc != recv_crc) return -3;
if (response[0] != slave_id) return -4;
if (response[1] != func_code) return -5;
return total_read;
}
/* ---- 从寄存器数据解析重量值 ---- */
static double parse_weight(const uint8_t *data, int data_len,
const char *data_type, const char *byte_order) {
if (data_len < 2) return -1.0;
if (strcmp(data_type, "uint16") == 0) {
return (double)((data[0] << 8) | data[1]);
}
if (strcmp(data_type, "int16") == 0) {
int16_t v = (int16_t)((data[0] << 8) | data[1]);
return (double)v;
}
if (data_len < 4) return -1.0;
uint8_t b[4];
if (strcmp(byte_order, "ABCD") == 0) {
b[0]=data[0]; b[1]=data[1]; b[2]=data[2]; b[3]=data[3];
} else if (strcmp(byte_order, "DCBA") == 0) {
b[0]=data[3]; b[1]=data[2]; b[2]=data[1]; b[3]=data[0];
} else if (strcmp(byte_order, "BADC") == 0) {
b[0]=data[1]; b[1]=data[0]; b[2]=data[3]; b[3]=data[2];
} else if (strcmp(byte_order, "CDAB") == 0) {
b[0]=data[2]; b[1]=data[3]; b[2]=data[0]; b[3]=data[1];
} else {
b[0]=data[0]; b[1]=data[1]; b[2]=data[2]; b[3]=data[3];
}
if (strcmp(data_type, "float32") == 0) {
uint32_t raw = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
((uint32_t)b[2] << 8) | (uint32_t)b[3];
float f;
memcpy(&f, &raw, 4);
return (double)f;
}
if (strcmp(data_type, "uint32") == 0) {
uint32_t v = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
((uint32_t)b[2] << 8) | (uint32_t)b[3];
return (double)v;
}
if (strcmp(data_type, "int32") == 0) {
int32_t v = (int32_t)(((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
((uint32_t)b[2] << 8) | (uint32_t)b[3]);
return (double)v;
}
return -1.0;
}
int main(int argc, char *argv[]) {
const char *port = argc > 1 ? argv[1] : "/dev/ttysWK0";
int baud_rate = argc > 2 ? atoi(argv[2]) : 9600;
int slave_id = argc > 3 ? atoi(argv[3]) : 1;
int func_code = argc > 4 ? atoi(argv[4]) : 4;
int reg_addr = argc > 5 ? atoi(argv[5]) : 0;
int reg_count = argc > 6 ? atoi(argv[6]) : 2;
const char *data_type = argc > 7 ? argv[7] : "float32";
const char *byte_order = argc > 8 ? argv[8] : "ABCD";
int poll_ms = argc > 9 ? atoi(argv[9]) : 125;
signal(SIGTERM, on_signal);
signal(SIGINT, on_signal);
printf("{\"status\":\"connecting\",\"message\":\"Opening %s at %d baud\"}\n", port, baud_rate);
fflush(stdout);
int fd = open(port, O_RDWR | O_NOCTTY | O_NONBLOCK);
if (fd < 0) {
printf("{\"status\":\"error\",\"message\":\"Cannot open %s: %s\"}\n", port, strerror(errno));
fflush(stdout);
return 1;
}
if (setup_serial(fd, baud_rate) < 0) {
printf("{\"status\":\"error\",\"message\":\"Cannot configure serial port\"}\n");
fflush(stdout);
close(fd);
return 1;
}
tcflush(fd, TCIOFLUSH);
printf("{\"status\":\"connected\",\"message\":\"%s ready, %d baud, slave %d, func %d, addr 0x%X\"}\n",
port, baud_rate, slave_id, func_code, reg_addr);
fflush(stdout);
usleep(100000); /* 100ms 稳定等待 */
uint8_t response[256];
int error_count = 0;
int poll_interval = poll_ms < 50 ? 50 : poll_ms;
while (g_running) {
int resp_len = modbus_transaction(fd, slave_id, func_code,
reg_addr, reg_count,
response, sizeof(response));
if (resp_len > 0) {
error_count = 0;
int byte_count = response[2];
if (byte_count >= 2 && resp_len >= byte_count + 5) {
double weight = parse_weight(response + 3, byte_count,
data_type, byte_order);
if (weight >= 0) {
printf("{\"status\":\"sample\",\"weightKg\":%.4f}\n", weight);
} else {
printf("{\"status\":\"error\",\"message\":\"Invalid weight data\"}\n");
}
}
} else {
error_count++;
if (error_count <= 3 || error_count % 20 == 0) {
const char *err;
switch (resp_len) {
case -1: err = "Write failed"; break;
case -2: err = "No response (timeout)"; break;
case -3: err = "CRC error"; break;
case -4: err = "Wrong slave ID"; break;
case -5: err = "Modbus exception"; break;
default: err = "Unknown error"; break;
}
printf("{\"status\":\"error\",\"message\":\"%s\"}\n", err);
}
}
fflush(stdout);
usleep(poll_interval * 1000);
}
tcflush(fd, TCIOFLUSH);
close(fd);
printf("{\"status\":\"disconnected\"}\n");
fflush(stdout);
return 0;
}
@@ -9,7 +9,6 @@ import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import android.provider.Settings; import android.provider.Settings;
import android.util.DisplayMetrics;
import android.util.Log; import android.util.Log;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.webkit.JavascriptInterface; import android.webkit.JavascriptInterface;
@@ -29,7 +28,10 @@ import java.nio.charset.StandardCharsets;
public class MainActivity extends Activity { public class MainActivity extends Activity {
// Android 6 到 Android 9 写入公共目录时需要动态申请存储权限。 // Android 6 到 Android 9 写入公共目录时需要动态申请存储权限。
private static final int STORAGE_PERMISSION_REQUEST = 1001; private static final int STORAGE_PERMISSION_REQUEST = 1001;
private static final int DESIGN_VIEWPORT_WIDTH = 1024;
private static final int DESIGN_VIEWPORT_HEIGHT = 600;
private WebView webView; private WebView webView;
private ScaleBroadcastBridge scaleBroadcastBridge;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -63,6 +65,7 @@ public class MainActivity extends Activity {
settings.setUseWideViewPort(true); settings.setUseWideViewPort(true);
settings.setLoadWithOverviewMode(false); settings.setLoadWithOverviewMode(false);
// 向前端暴露受控的文件保存和系统页面跳转能力。 // 向前端暴露受控的文件保存和系统页面跳转能力。
scaleBroadcastBridge = new ScaleBroadcastBridge(this, webView);
webView.addJavascriptInterface(new AndroidBridge(), "WoerNative"); webView.addJavascriptInterface(new AndroidBridge(), "WoerNative");
setContentView(webView); setContentView(webView);
@@ -115,6 +118,21 @@ public class MainActivity extends Activity {
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
return launchSystemActivity(intent); return launchSystemActivity(intent);
} }
@JavascriptInterface
public boolean startScale() {
return scaleBroadcastBridge != null && scaleBroadcastBridge.start();
}
@JavascriptInterface
public boolean stopScale() {
return scaleBroadcastBridge != null && scaleBroadcastBridge.stop();
}
@JavascriptInterface
public boolean scaleCommand(String command) {
return scaleBroadcastBridge != null && scaleBroadcastBridge.sendCommand(command);
}
} }
private boolean launchSystemActivity(final Intent intent) { private boolean launchSystemActivity(final Intent intent) {
@@ -167,17 +185,12 @@ public class MainActivity extends Activity {
} }
private void loadApp() { private void loadApp() {
/* // 操作界面按 1024×600 横屏终端标定,保持控件在目标设备上的一致尺寸。
* 终端的 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 { try {
String html = readAsset("public/index.html") String html = readAsset("public/index.html")
.replace("width=device-width", "width=" + landscapeWidth) .replace(
"width=device-width",
"width=" + DESIGN_VIEWPORT_WIDTH + ", height=" + DESIGN_VIEWPORT_HEIGHT)
.replace(", viewport-fit=cover", ""); .replace(", viewport-fit=cover", "");
webView.loadDataWithBaseURL( webView.loadDataWithBaseURL(
"file:///android_asset/public/", "file:///android_asset/public/",
@@ -205,6 +218,10 @@ public class MainActivity extends Activity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
if (scaleBroadcastBridge != null) {
scaleBroadcastBridge.destroy();
scaleBroadcastBridge = null;
}
if (webView != null) { if (webView != null) {
// 主动销毁 WebView,释放页面定时器和 JavaScript 上下文。 // 主动销毁 WebView,释放页面定时器和 JavaScript 上下文。
webView.destroy(); webView.destroy();
@@ -0,0 +1,236 @@
package com.woer.smartscale;
import android.app.Activity;
import android.os.Build;
import android.util.Log;
import android.webkit.WebView;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.concurrent.atomic.AtomicReference;
/**
* RS-485 Modbus RTU 桥接器。
*
* 通过启动原生 modbus_poller 子进程读取电子秤重量,
* 将结果以 JavaScript CustomEvent 形式派发到 WebView。
*
* 配置 JSON 格式(留空使用默认值):
* {
* "portPath": "/dev/ttysWK0",
* "baudRate": 9600,
* "slaveId": 1,
* "functionCode": 4,
* "registerAddress": 0,
* "registerCount": 2,
* "dataType": "float32",
* "byteOrder": "ABCD",
* "pollIntervalMs": 125
* }
*/
public class ModbusRtuBridge {
private static final String TAG = "ModbusRtuBridge";
private static final String POLLER_PATH = "/data/local/tmp/modbus_poller";
private final Activity activity;
private final WebView webView;
private final AtomicReference<Process> processRef = new AtomicReference<>();
private volatile boolean running = false;
public ModbusRtuBridge(Activity activity, WebView webView) {
this.activity = activity;
this.webView = webView;
}
/**
* 启动 Modbus 轮询。
* @param configJson 前端传入的配置 JSON 字符串
* @return true 如果成功启动
*/
public boolean start(String configJson) {
if (running) {
Log.w(TAG, "Already running");
return true;
}
// 解析配置
String portPath = "/dev/ttysWK0";
int baudRate = 9600;
int slaveId = 1;
int functionCode = 4;
int registerAddress = 0;
int registerCount = 2;
String dataType = "float32";
String byteOrder = "ABCD";
int pollIntervalMs = 125;
if (configJson != null && !configJson.trim().isEmpty()) {
try {
JSONObject cfg = new JSONObject(configJson);
portPath = cfg.optString("portPath", portPath);
baudRate = cfg.optInt("baudRate", baudRate);
slaveId = cfg.optInt("slaveId", slaveId);
functionCode = cfg.optInt("functionCode", functionCode);
registerAddress = cfg.optInt("registerAddress", registerAddress);
registerCount = cfg.optInt("registerCount", registerCount);
dataType = cfg.optString("dataType", dataType);
byteOrder = cfg.optString("byteOrder", byteOrder);
pollIntervalMs = cfg.optInt("pollIntervalMs", pollIntervalMs);
} catch (JSONException e) {
Log.e(TAG, "Config parse error", e);
dispatchEvent("error", "Modbus 配置 JSON 解析失败: " + e.getMessage(), 0);
return false;
}
}
final String[] cmd = {
"su", "0", POLLER_PATH,
portPath,
String.valueOf(baudRate),
String.valueOf(slaveId),
String.valueOf(functionCode),
String.valueOf(registerAddress),
String.valueOf(registerCount),
dataType,
byteOrder,
String.valueOf(pollIntervalMs)
};
Log.i(TAG, "Starting modbus_poller: " + portPath + " " + baudRate + " " +
slaveId + " " + functionCode + " " + registerAddress + " " +
registerCount + " " + dataType + " " + byteOrder + " " + pollIntervalMs);
try {
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
Process process = pb.start();
processRef.set(process);
running = true;
// 读取子进程输出的线程
Thread readerThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while (running && (line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
handlePollerOutput(line);
}
} catch (IOException e) {
if (running) {
Log.e(TAG, "Read error", e);
dispatchEvent("error", "串口读取异常: " + e.getMessage(), 0);
}
} finally {
running = false;
processRef.set(null);
}
}, "ModbusReader");
readerThread.setDaemon(true);
readerThread.start();
return true;
} catch (IOException e) {
Log.e(TAG, "Failed to start modbus_poller", e);
dispatchEvent("error", "无法启动串口程序: " + e.getMessage(), 0);
return false;
}
}
/**
* 停止 Modbus 轮询。
*/
public boolean stop() {
running = false;
Process process = processRef.getAndSet(null);
if (process != null) {
process.destroy();
Log.i(TAG, "modbus_poller stopped");
}
return true;
}
/**
* 销毁桥接器,释放资源。
*/
public void destroy() {
stop();
}
/**
* 处理 modbus_poller 输出的一行 JSON。
*/
private void handlePollerOutput(String json) {
try {
JSONObject obj = new JSONObject(json);
String status = obj.optString("status", "");
String message = obj.optString("message", "");
double weightKg = obj.optDouble("weightKg", -1);
switch (status) {
case "connecting":
dispatchEvent("connecting", message, 0);
break;
case "connected":
dispatchEvent("connected", message, 0);
break;
case "sample":
if (weightKg >= 0) {
dispatchEvent("sample", message, weightKg);
}
break;
case "error":
dispatchEvent("error", message, 0);
break;
case "disconnected":
dispatchEvent("disconnected", "串口已断开", 0);
break;
}
} catch (JSONException e) {
Log.w(TAG, "Cannot parse: " + json);
}
}
/**
* 向 WebView 派发 woer-modbus CustomEvent。
*/
private void dispatchEvent(String status, String message, double weightKg) {
if (webView == null) return;
// 转义消息中的特殊字符
String safeMessage = message == null ? "" : message
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "");
StringBuilder js = new StringBuilder();
js.append("(function(){");
js.append(" var detail = {");
js.append(" status: '").append(status).append("'");
if (!safeMessage.isEmpty()) {
js.append(" , message: '").append(safeMessage).append("'");
}
if (weightKg > 0 || (status.equals("sample") && weightKg == 0)) {
js.append(" , weightKg: ").append(weightKg);
}
js.append(" };");
js.append(" window.dispatchEvent(new CustomEvent('woer-modbus', { detail: detail }));");
js.append("})();");
final String jsCode = js.toString();
activity.runOnUiThread(() -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
webView.evaluateJavascript(jsCode, null);
} else {
webView.loadUrl("javascript:" + jsCode);
}
});
}
}
@@ -0,0 +1,141 @@
package com.woer.smartscale;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.os.Build;
import android.webkit.WebView;
/**
* HCT 智能秤后台服务广播桥接器。
*
* 后台服务 com.android.hct.android_hid_scale_service 会发送
* com.android.scale.weight 广播,weight 字段格式为:状态,净重,皮重,单位。
*/
public class ScaleBroadcastBridge {
private static final String SCALE_SERVICE_PACKAGE = "com.android.hct.android_hid_scale_service";
private static final String ACTION_WEIGHT = "com.android.scale.weight";
private static final String ACTION_SET = "com.android.scale.set";
private final Activity activity;
private final WebView webView;
private boolean receiverRegistered;
private final BroadcastReceiver weightReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String reading = intent.getStringExtra("weight");
if (reading == null || reading.trim().isEmpty()) {
dispatchEvent("error", "电子秤广播缺少重量数据", 0);
return;
}
String[] fields = reading.replace("\r", "").replace("\n", "").trim().split(",");
if (fields.length < 4) {
dispatchEvent("error", "电子秤重量数据格式无效", 0);
return;
}
try {
String stability = fields[0].trim();
double weightKg = toKilograms(Double.parseDouble(fields[1].trim()), fields[3].trim());
String message = "ST".equals(stability) ? "重量稳定" : "US".equals(stability) ? "重量波动中" : stability;
dispatchEvent("sample", message, weightKg);
} catch (NumberFormatException ignored) {
dispatchEvent("error", "电子秤重量值无效", 0);
}
}
};
public ScaleBroadcastBridge(Activity activity, WebView webView) {
this.activity = activity;
this.webView = webView;
}
public boolean start() {
if (!isScaleServiceInstalled()) {
dispatchEvent("error", "未安装电子秤后台服务 android_hid_scale_service", 0);
return false;
}
if (!receiverRegistered) {
IntentFilter filter = new IntentFilter(ACTION_WEIGHT);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.registerReceiver(weightReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
activity.registerReceiver(weightReceiver, filter);
}
receiverRegistered = true;
}
launchScaleService();
dispatchEvent("connecting", "正在连接电子秤后台服务", 0);
return true;
}
public boolean stop() {
if (receiverRegistered) {
try {
activity.unregisterReceiver(weightReceiver);
} catch (IllegalArgumentException ignored) {
// Activity 已销毁时接收器可能已被系统注销。
}
receiverRegistered = false;
}
return true;
}
public boolean sendCommand(String command) {
if (!"zero".equals(command) && !"tare".equals(command)) return false;
if (!isScaleServiceInstalled()) {
dispatchEvent("error", "未安装电子秤后台服务 android_hid_scale_service", 0);
return false;
}
Intent commandIntent = new Intent(ACTION_SET);
commandIntent.putExtra("set", command);
activity.sendBroadcast(commandIntent);
return true;
}
public void destroy() {
stop();
}
private boolean isScaleServiceInstalled() {
try {
activity.getPackageManager().getPackageInfo(SCALE_SERVICE_PACKAGE, 0);
return true;
} catch (PackageManager.NameNotFoundException ignored) {
return false;
}
}
private void launchScaleService() {
Intent launchIntent = activity.getPackageManager().getLaunchIntentForPackage(SCALE_SERVICE_PACKAGE);
if (launchIntent != null) {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
activity.startActivity(launchIntent);
}
}
private static double toKilograms(double value, String unit) {
String normalized = unit.trim().toLowerCase();
if ("g".equals(normalized) || "克".equals(unit.trim())) return value / 1000d;
if ("lb".equals(normalized) || "lbs".equals(normalized)) return value * 0.45359237d;
return value;
}
private void dispatchEvent(String status, String message, double weightKg) {
String safeMessage = message == null ? "" : message
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "");
StringBuilder javascript = new StringBuilder("(function(){window.dispatchEvent(new CustomEvent('woer-modbus',{detail:{status:'")
.append(status).append("',message:'").append(safeMessage).append("'");
if ("sample".equals(status)) javascript.append(",weightKg:").append(weightKg);
javascript.append("}}));})();");
activity.runOnUiThread(() -> webView.evaluateJavascript(javascript.toString(), null));
}
}
+119 -55
View File
@@ -69,6 +69,7 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.progress-item.active span { color: white; background: var(--green); border-color: var(--green); } .progress-item.active span { color: white; background: var(--green); border-color: var(--green); }
.content { flex: 1; display: grid; } .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: 12px; 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; }
.production-footer { justify-content: center; }
.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-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; } .home-copy { min-width: 0; }
@@ -84,26 +85,32 @@ h1 em { font-style: normal; color: var(--green); }
@keyframes pulse { 50% { opacity: .45; transform: scale(.92); } } @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: 13px; 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; } .admin-link:hover { color: var(--green); border-color: #9fc5b5; background: white; }
.admin-link-fab { position: absolute; bottom: 22px; right: 32px; display: flex; align-items: center; gap: 8px; border: 1px solid #cbd8d1; border-radius: 12px; background: rgba(255,255,255,.95); color: #53625a; padding: 13px 20px; font-size: 14px; font-weight: 800; box-shadow: 0 6px 18px rgba(30,55,43,.1); z-index: 3; }
.admin-link-fab:hover { color: var(--green); border-color: #9fc5b5; background: white; }
.admin-link-fab span { color: var(--green); font-size: 16px; }
.back-button-fab { position: absolute; bottom: 18px; right: 28px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--green); border-radius: 10px; background: var(--green); color: white; padding: 10px 18px; font-size: 14px; font-weight: 800; box-shadow: 0 6px 16px rgba(0,127,95,.22); z-index: 3; }
.back-button-fab:hover, .back-button-fab:active { color: white; border-color: #006e53; background: #006e53; }
.identity-material-block { margin-top: 14px; }
.home-notice { width: min(390px, 100%); } .home-notice { width: min(390px, 100%); }
.home-stats { margin-top: 36px; display: flex; gap: 32px; color: #849088; font-size: 11px; } .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 span { padding-left: 12px; border-left: 2px solid #e0e6e1; }
.home-stats b { display: block; color: var(--ink); font-size: 14px; margin-bottom: 4px; } .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 { 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); } .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: 28px; letter-spacing: -.035em; } .material-panel-heading h2 { margin: 7px 0 5px; font-size: 32px; letter-spacing: -.035em; }
.material-panel-heading p { margin: 0 0 18px; color: var(--muted); font-size: 13px; } .material-panel-heading p { margin: 0 0 18px; color: var(--muted); font-size: 15px; }
.home-material-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .home-material-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.home-material-grid .material-card { min-height: 100px; padding: 18px 17px; border-radius: 14px; } .home-material-grid .material-card { min-height: 120px; padding: 22px 20px; border-radius: 16px; }
.home-material-grid .material-card.selected { padding: 17px 16px; } .home-material-grid .material-card.selected { padding: 21px 19px; }
.home-material-grid .material-card b { font-size: 20px; } .home-material-grid .material-card b { font-size: 24px; }
.home-material-grid .material-card small { margin-top: 8px; font-size: 11px; } .home-material-grid .material-card small { margin-top: 10px; font-size: 13px; }
.home-material-grid .material-card i { font-size: 14px; } .home-material-grid .material-card i { font-size: 18px; }
.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 { 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 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 { background: var(--mint); color: var(--green); }
.material-selection-status.ready span { border-color: var(--green); background: var(--green); color: white; } .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; } .panel-view { width: min(760px, calc(100% - 48px)); margin: 0 auto; padding: 35px 0; position: relative; }
.back-button { .back-button {
min-height: 38px; min-height: 38px;
display: inline-flex; display: inline-flex;
@@ -121,64 +128,81 @@ h1 em { font-style: normal; color: var(--green); }
} }
.back-button:hover, .back-button:active { color: white; border-color: #006e53; background: #006e53; } .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 { 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: 30px; letter-spacing: -.035em; } .section-heading h2, .result-view h2 { margin: 7px 0 7px; font-size: 32px; letter-spacing: -.035em; }
.section-heading p, .result-view > p { margin: 0; color: var(--muted); font-size: 14px; } .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; } .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); } .identity-card { display: flex; align-items: center; gap: 16px; background: #fff; border: 1px solid var(--line); border-radius: 15px; padding: 18px; 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: 22px; font-weight: 800; } .avatar { width: 58px; height: 58px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 14px; background: var(--green); color: white; font-size: 24px; font-weight: 800; }
.identity-card small { color: #8c9791; font-size: 12px; } .identity-card small { color: #8c9791; font-size: 14px; }
.identity-card h3 { margin: 4px 0; font-size: 20px; } .identity-card h3 { margin: 4px 0; font-size: 22px; }
.identity-card p { margin: 0; color: #66736c; font-size: 13px; } .identity-card p { margin: 0; color: #66736c; font-size: 15px; }
.verified { margin-left: auto; background: var(--mint); color: var(--green); padding: 8px 11px; border-radius: 99px; font-size: 12px; font-weight: 700; } .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; } .verified.missing { color: #9b6b14; background: #fbf0d7; }
.identity-panel-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; color: var(--green); font-size: 14px; font-weight: 800; }
.identity-layout { display: flex; gap: 16px; }
.identity-person-panel, .identity-operation-panel { min-width: 0; padding: 16px 18px; border: 1px solid #b7d7c9; border-radius: 16px; background: linear-gradient(135deg, #ffffff, #f4fbf7); box-shadow: 0 10px 28px rgba(35,58,46,.08); }
.identity-person-panel { flex: .9 1 0; }
.identity-operation-panel { flex: 1.1 1 0; }
.identity-person-panel .identity-card { min-height: 88px; padding: 13px; box-shadow: none; }
.identity-person-panel .identity-card h3 { font-size: 24px; }
.identity-operation-panel .machine-select-row { min-height: 72px; }
.identity-operation-panel .machine-select-row b { font-size: 20px; }
.identity-operation-panel .full-primary { margin-top: 14px; padding: 13px 16px; font-size: 15px; }
.identity-back { position: absolute; top: 0; left: 0; z-index: 1; }
.identity-material-compact { min-height: 42px; justify-content: flex-start; gap: 8px; margin: 0 0 12px; padding: 8px 12px; }
.identity-material-compact small, .identity-material-compact b, .identity-material-compact em { display: inline; }
.identity-material-compact small { font-size: 11px; }
.identity-material-compact b { margin: 0; font-size: 15px; }
.identity-material-compact em { margin-left: 3px; font-size: 10px; }
.identity-material-compact i { margin-left: auto; }
.field-label { display: block; margin: 22px 0 8px; font-size: 13px; 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 { 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: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: 14px; } .machine-picker input { flex: 1; border: 0; outline: 0; background: transparent; font-size: 14px; }
.machine-picker b { color: var(--green); } .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 { min-height: 68px; display: flex; align-items: center; gap: 12px; border: 1px solid #cfd8d2; border-radius: 12px; background: white; padding: 12px 14px; }
.machine-select-row.selected { border-color: #a9cbbb; background: #fbfefc; } .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-icon { width: 40px; height: 40px; 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 > span:nth-child(2) { flex: 1; }
.machine-select-row small, .machine-select-row b { display: block; } .machine-select-row small, .machine-select-row b { display: block; }
.machine-select-row small { color: #89948e; font-size: 10px; margin-bottom: 4px; } .machine-select-row small { color: #89948e; font-size: 12px; margin-bottom: 4px; }
.machine-select-row b { font-size: 14px; } .machine-select-row b { font-size: 17px; }
.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 { border: 0; border-radius: 8px; background: var(--green); color: white; padding: 11px 14px; font-size: 13px; font-weight: 800; }
.machine-select-row > button.machine-refresh { border: 1px solid #cbd8d1; background: white; color: #65736b; } .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 { 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: 14px; } .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:hover { background: #eef7f2; color: var(--green); }
.suggestions button small { margin-left: auto; color: #95a099; } .suggestions button small { margin-left: auto; color: #95a099; }
.suggestions p { text-align: center; color: #7f8b84; font-size: 13px; } .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 { width: 100%; margin-top: 22px; border: 0; border-radius: 12px; background: var(--green); color: white; padding: 17px 18px; font-size: 16px; font-weight: 800; box-shadow: 0 10px 25px rgba(0,127,95,.18); }
.full-primary span { float: right; } .full-primary span { float: right; }
.notice { margin-top: 12px; padding: 10px 12px; border-radius: 8px; background: #f7edd8; color: #8b671e; font-size: 12px; } .notice { margin-top: 12px; padding: 10px 12px; border-radius: 8px; background: #f7edd8; color: #8b671e; font-size: 12px; }
.notice.compact { margin: 10px 0 -10px; } .notice.compact { margin: 10px 0 -10px; }
.weigh-view { padding: 14px 32px 28px; } .weigh-view { padding: 14px 32px 28px; position: relative; }
.weigh-back { margin-bottom: 7px; } .weigh-back { position: absolute; top: 14px; left: 32px; z-index: 1; margin: 0; }
.weigh-meta { display: flex; gap: 0; border: 1px solid var(--line); background: white; border-radius: 11px; padding: 10px 14px; margin-bottom: 14px; } .weigh-meta { display: flex; gap: 0; border: 1px solid var(--line); background: white; border-radius: 11px; padding: 12px 16px; margin: 0 0 14px 92px; }
.weigh-meta > span { min-width: 170px; padding-right: 18px; margin-right: 18px; border-right: 1px solid var(--line); } .weigh-meta > span { min-width: 170px; display: inline-flex; align-items: center; gap: 8px; padding-right: 18px; margin-right: 18px; border-right: 1px solid var(--line); white-space: nowrap; }
.weigh-meta > span.material-meta { min-width: 230px; } .weigh-meta > span.material-meta { min-width: 230px; }
.weigh-meta small, .weigh-meta b { display: block; } .weigh-meta small, .weigh-meta b { display: inline; }
.weigh-meta small { color: #8a958f; font-size: 11px; margin-bottom: 4px; } .weigh-meta small { color: #8a958f; font-size: 14px; margin: 0 10px 0 0; }
.weigh-meta b { font-size: 13px; } .weigh-meta b { font-size: 17px; }
.weigh-meta button { margin-left: auto; border: 0; background: none; color: var(--green); font-size: 12px; font-weight: 800; } .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); } .scale-display { min-height: 300px; display: flex; flex-direction: column; border-radius: 18px; background: #112b23; color: white; padding: 22px 28px 18px; 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; } .display-top { display: flex; justify-content: space-between; align-items: center; }
.stable-badge { color: #e2c680; font-size: 12px; font-weight: 700; } .stable-badge { color: #e2c680; font-size: 14px; 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 i { display: inline-block; width: 8px; height: 8px; 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 { color: #83dfb8; }
.stable-badge.ready i { background: #48c78e; box-shadow: 0 0 0 4px rgba(72,199,142,.13); } .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 { 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: 11px; padding: 5px 9px; } .unit-switch button { border: 0; border-radius: 6px; background: transparent; color: #849b91; font-size: 13px; padding: 6px 11px; }
.unit-switch button.active { background: #e8f0ea; color: #15352a; font-weight: 800; } .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 { flex: 1; display: flex; justify-content: center; align-items: center; gap: 15px; margin: 6px 0; }
.weight-number span { font-family: "Courier New", monospace; font-size: clamp(60px, 8vw, 84px); letter-spacing: -.08em; font-weight: 700; } .weight-number span { font-family: "Courier New", monospace; font-size: clamp(88px, 13vw, 138px); letter-spacing: -.08em; font-weight: 700; }
.weight-number b { color: #8da298; font-size: 15px; } .weight-number b { color: #8da298; font-size: 20px; }
.stability-track { height: 3px; background: rgba(255,255,255,.08); border-radius: 4px; overflow: hidden; } .stability-track { height: 4px; background: rgba(255,255,255,.08); border-radius: 4px; overflow: hidden; }
.stability-track span { display: block; height: 100%; background: #4bd19a; transition: width .25s; } .stability-track span { display: block; height: 100%; background: #4bd19a; transition: width .25s; }
.display-details { display: flex; justify-content: center; gap: 0; margin-top: 12px; color: #789187; font-size: 11px; } .display-details { display: flex; justify-content: center; gap: 0; margin-top: auto; padding-top: 14px; color: #789187; font-size: 14px; }
.display-details > span + span { margin-left: 42px; } .display-details > span + span { margin-left: 42px; }
.display-details b { color: #dbe8e1; margin-left: 5px; } .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 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 11px 0 16px; }
@@ -202,12 +226,12 @@ h1 em { font-style: normal; color: var(--green); }
.material-card b { font-size: 17px; letter-spacing: -.02em; } .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 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; } .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 { display: flex; align-items: center; gap: 12px; border: 1px solid #bedacd; border-radius: 12px; background: #f3faf7; padding: 14px 16px; }
.selected-material-summary > span:nth-child(2) { flex: 1; } .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, .selected-material-summary b, .selected-material-summary em { display: block; }
.selected-material-summary small { color: #7a8a82; font-size: 10px; } .selected-material-summary small { color: #7a8a82; font-size: 12px; }
.selected-material-summary b { margin: 3px 0; font-size: 19px; } .selected-material-summary b { margin: 3px 0; font-size: 21px; }
.selected-material-summary em { color: #8c9891; font-size: 9px; font-style: normal; } .selected-material-summary em { color: #8c9891; font-size: 11px; font-style: normal; }
.selected-material-summary > i { color: var(--green); font-size: 11px; font-style: normal; font-weight: 800; } .selected-material-summary > i { color: var(--green); font-size: 11px; font-style: normal; font-weight: 800; }
.full-primary.upload { margin-top: 18px; } .full-primary.upload { margin-top: 18px; }
@@ -491,11 +515,11 @@ h1 em { font-style: normal; color: var(--green); }
.home-material-panel { padding: clamp(19px, 2.5vw, 24px); } .home-material-panel { padding: clamp(19px, 2.5vw, 24px); }
.material-panel-heading h2 { margin-top: 5px; font-size: 25px; } .material-panel-heading h2 { margin-top: 5px; font-size: 25px; }
.material-panel-heading p { margin-bottom: 12px; font-size: 13px; } .material-panel-heading p { margin-bottom: 12px; font-size: 13px; }
.home-material-grid { gap: 11px; } .home-material-grid { gap: 12px; }
.home-material-grid .material-card { min-height: 78px; padding: 13px 15px; } .home-material-grid .material-card { min-height: 92px; padding: 16px 18px; }
.home-material-grid .material-card.selected { padding: 12px 14px; } .home-material-grid .material-card.selected { padding: 15px 17px; }
.home-material-grid .material-card b { font-size: 19px; } .home-material-grid .material-card b { font-size: 22px; }
.home-material-grid .material-card small { margin-top: 6px; font-size: 11px; } .home-material-grid .material-card small { margin-top: 8px; font-size: 13px; }
.material-selection-status { margin-top: 10px; padding: 8px 10px; } .material-selection-status { margin-top: 10px; padding: 8px 10px; }
.panel-view { width: min(900px, calc(100% - 40px)); padding: 12px 0; } .panel-view { width: min(900px, calc(100% - 40px)); padding: 12px 0; }
@@ -503,12 +527,12 @@ h1 em { font-style: normal; color: var(--green); }
.section-heading { margin-bottom: 12px; } .section-heading { margin-bottom: 12px; }
.section-heading h2, .result-view h2 { margin: 4px 0; font-size: 25px; } .section-heading h2, .result-view h2 { margin: 4px 0; font-size: 25px; }
.refresh-button { padding: 8px 12px; } .refresh-button { padding: 8px 12px; }
.identity-card { padding: 11px 14px; } .identity-card { padding: 13px 16px; }
.avatar { width: 44px; height: 44px; border-radius: 11px; font-size: 19px; } .avatar { width: 48px; height: 48px; border-radius: 11px; font-size: 21px; }
.identity-card h3 { margin: 2px 0; font-size: 18px; } .identity-card h3 { margin: 2px 0; font-size: 20px; }
.field-label { margin: 12px 0 6px; } .field-label { margin: 12px 0 6px; }
.machine-select-row { min-height: 52px; padding: 7px 10px; } .machine-select-row { min-height: 56px; padding: 8px 12px; }
.machine-select-icon { width: 32px; height: 32px; } .machine-select-icon { width: 36px; height: 36px; }
.full-primary { margin-top: 12px; padding: 12px 16px; } .full-primary { margin-top: 12px; padding: 12px 16px; }
.weigh-view { position: relative; width: min(1180px, calc(100% - 36px)); padding: 7px 0 10px; } .weigh-view { position: relative; width: min(1180px, calc(100% - 36px)); padding: 7px 0 10px; }
@@ -516,10 +540,10 @@ h1 em { font-style: normal; color: var(--green); }
.weigh-meta { margin: 0 0 7px 84px; padding: 7px 12px; } .weigh-meta { margin: 0 0 7px 84px; padding: 7px 12px; }
.weigh-meta > span { min-width: 150px; } .weigh-meta > span { min-width: 150px; }
.weigh-meta > span.material-meta { min-width: 200px; } .weigh-meta > span.material-meta { min-width: 200px; }
.scale-display { padding: 10px 20px 9px; border-radius: 14px; } .scale-display { padding: 14px 24px 12px; border-radius: 14px; }
.weight-number { margin: 0; } .weight-number { margin: 0; }
.weight-number span { font-size: clamp(48px, 7vw, 70px); } .weight-number span { font-size: clamp(56px, 9vw, 80px); }
.display-details { margin-top: 6px; } .display-details { margin-top: 8px; }
.scale-tools { margin: 7px 0 8px; } .scale-tools { margin: 7px 0 8px; }
.scale-tools button { min-height: 42px; padding: 7px 11px; } .scale-tools button { min-height: 42px; padding: 7px 11px; }
.selected-material-summary { padding: 8px 13px; } .selected-material-summary { padding: 8px 13px; }
@@ -713,7 +737,7 @@ h1 em { font-style: normal; color: var(--green); }
@media (orientation: landscape) and (max-height: 820px) { @media (orientation: landscape) and (max-height: 820px) {
.weight-number span { .weight-number span {
font-size: 44px; font-size: clamp(48px, 8vw, 68px);
font-weight: 900; font-weight: 900;
letter-spacing: -.05em; letter-spacing: -.05em;
} }
@@ -755,3 +779,43 @@ h1 em { font-style: normal; color: var(--green); }
margin: 0; margin: 0;
} }
} }
@media (orientation: landscape) and (max-width: 1100px) and (max-height: 700px) {
/* 1024×600 工业终端:首页仅保留废料选择,第二页按人员与操作两栏展示。 */
.home-view { display: flex; align-items: center; justify-content: center; padding: 18px 40px; }
.home-material-panel { width: min(720px, 100%); flex: 0 1 720px; padding: 22px 24px; }
.home-material-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.home-material-grid .material-card { width: auto; min-height: 88px; margin: 0; padding: 14px 17px; }
.home-material-grid .material-card:nth-child(odd), .home-material-grid .material-card:nth-child(n+3) { margin: 0; }
.material-panel-heading h2 { font-size: 27px; }
.home-material-grid .material-card.selected { padding: 13px 16px; }
.home-material-grid .material-card b { font-size: 21px; }
.identity-view { width: calc(100% - 48px); max-width: 960px; padding: 10px 0 14px; position: relative; }
.identity-view > .section-heading { min-height: 40px; margin-bottom: 10px; padding-left: 96px; }
.identity-view > .section-heading h2 { font-size: 26px; }
.identity-layout { display: grid; grid-template-columns: minmax(300px, .85fr) minmax(400px, 1.15fr); gap: 16px; }
.identity-person-panel, .identity-operation-panel { min-width: 0; min-height: 146px; padding: 16px 18px; border: 1px solid #b7d7c9; border-radius: 16px; background: linear-gradient(135deg, #ffffff, #f4fbf7); box-shadow: 0 10px 28px rgba(35,58,46,.08); }
.identity-panel-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 13px; color: var(--green); font-size: 17px; font-weight: 800; }
.identity-panel-heading .refresh-button { padding: 6px 9px; font-size: 12px; }
.identity-person-panel .identity-card { min-height: 88px; padding: 13px; box-shadow: none; }
.identity-person-panel .identity-card h3 { font-size: 24px; }
.identity-person-panel .identity-card p { font-size: 16px; }
.identity-operation-panel .machine-select-row { min-height: 72px; }
.identity-operation-panel .machine-select-row b { font-size: 20px; }
.identity-operation-panel .full-primary { margin-top: 14px; padding: 13px 16px; font-size: 15px; }
.identity-material-compact { min-height: 36px; margin: 0 0 10px; padding: 7px 11px; border-radius: 10px; }
.identity-material-compact b { font-size: 14px; }
.identity-back { top: 10px; }
.weigh-view { width: min(1240px, calc(100% - 24px)); }
.scale-display { min-height: 280px; padding: 19px 36px 16px; border-radius: 18px; }
.weight-number { margin: 8px 0; }
.weight-number span { font-size: clamp(84px, 12vw, 128px); }
.weight-number b { font-size: 24px; }
.display-details { margin-top: 12px; font-size: 15px; }
}
@media (orientation: landscape) {
.weight-number span { font-size: 128px !important; }
}
+109 -104
View File
@@ -5,7 +5,6 @@ import {
createFileTimestamp, createFileTimestamp,
evaluateStability, evaluateStability,
formatWeight, formatWeight,
parseScaleMessage,
type WeightUnit, type WeightUnit,
} from "./scale-utils"; } from "./scale-utils";
@@ -59,6 +58,9 @@ declare global {
exportLogs: (fileName: string, content: string) => boolean; exportLogs: (fileName: string, content: string) => boolean;
openSystemSettings: () => boolean; openSystemSettings: () => boolean;
openFileManager: () => boolean; openFileManager: () => boolean;
startScale: () => boolean;
stopScale: () => boolean;
scaleCommand: (command: "zero" | "tare") => boolean;
}; };
} }
} }
@@ -70,6 +72,20 @@ const DEFAULT_MATERIALS: Material[] = [
{ id: "6", name: "废锡", tone: "tin" }, { id: "6", name: "废锡", tone: "tin" },
]; ];
const TEST_EMPLOYEES = [
{ name: "张伟", id: "E-0826" },
{ name: "李敏", id: "E-1038" },
{ name: "陈浩", id: "E-1172" },
];
const TEST_MACHINES = ["A01 绕线机", "A03 挤出机", "B06 注塑机", "C02 裁切机"];
const TEST_WEIGHT_RECORDS: WeightRecord[] = [
{ employeeName: "张伟", employeeId: "E-0826", machineId: "A01 绕线机", materialType: "废铜", materialTypeId: "3", weightKg: 12.48, createdAt: "2026-08-04T08:35:00.000Z" },
{ employeeName: "李敏", employeeId: "E-1038", machineId: "A03 挤出机", materialType: "废电线", materialTypeId: "4", weightKg: 8.76, createdAt: "2026-08-04T09:12:00.000Z" },
{ employeeName: "陈浩", employeeId: "E-1172", machineId: "B06 注塑机", materialType: "废铝", materialTypeId: "5", weightKg: 6.32, createdAt: "2026-08-04T10:06:00.000Z" },
];
const DEFAULT_SETTINGS: AppSettings = { const DEFAULT_SETTINGS: AppSettings = {
employee: "", employee: "",
machines: "", machines: "",
@@ -102,7 +118,7 @@ const TRANSLATIONS: Record<Language, Record<string, string>> = {
language: "界面语言", languageDesc: "切换后立即应用到操作界面", serverApi: "本地服务器接口", serverApiDesc: "接口未配置时不会生成模拟数据", language: "界面语言", languageDesc: "切换后立即应用到操作界面", serverApi: "本地服务器接口", serverApiDesc: "接口未配置时不会生成模拟数据",
materialDict: "废料字典", materialDictDesc: "修改后将同步更新首页分类与称重数据", displayName: "显示名称", materialDict: "废料字典", materialDictDesc: "修改后将同步更新首页分类与称重数据", displayName: "显示名称",
saveAll: "保存全部设置", saveHint: "修改完成后请保存设置", passwordTitle: "管理员登录", passwordDesc: "请输入管理员密码进入应用设置", confirmLogin: "确认登录", saveAll: "保存全部设置", saveHint: "修改完成后请保存设置", passwordTitle: "管理员登录", passwordDesc: "请输入管理员密码进入应用设置", confirmLogin: "确认登录",
employeeApi: "人员信息接口", machineApi: "机台列表接口", scaleApi: "电子秤数据接口", uploadApi: "称重上传接口", apiAddressPlaceholder: "请输入完整接口地址", employeeApi: "人员信息接口", machineApi: "机台列表接口", scaleApi: "电子秤广播服务", uploadApi: "称重上传接口", apiAddressPlaceholder: "请输入完整接口地址", modbusConfigPlaceholder: "后台电子秤服务自动连接,无需配置", modbusConfigHint: "请安装 android_hid_scale_service 后台服务;重量、皮重、单位和稳定状态会自动同步。",
peripheralTitle: "完成称重后的外设动作", peripheralDesc: "功能默认关闭,需由 Android 原生层接入 USB 设备", reserved: "预留接口", cameraTitle: "USB 摄像头拍照", cameraDesc: "提交成功后自动拍摄现场照片", peripheralTitle: "完成称重后的外设动作", peripheralDesc: "功能默认关闭,需由 Android 原生层接入 USB 设备", reserved: "预留接口", cameraTitle: "USB 摄像头拍照", cameraDesc: "提交成功后自动拍摄现场照片",
photoPath: "照片保存路径", fileName: "文件名", printTitle: "称重凭证打印", printDesc: "提交成功后调用 USB / 蓝牙打印机", scrapWeight: "废料重量", weighingTime: "称重时间", photoPath: "照片保存路径", fileName: "文件名", printTitle: "称重凭证打印", printDesc: "提交成功后调用 USB / 蓝牙打印机", scrapWeight: "废料重量", weighingTime: "称重时间",
dataTitle: "数据补传与本地备份", dataDesc: "网络异常时确保称重记录不丢失", autoProtect: "自动保护", pending: "待补传数据", localBackup: "本地备份", records: "条记录", dataTitle: "数据补传与本地备份", dataDesc: "网络异常时确保称重记录不丢失", autoProtect: "自动保护", pending: "待补传数据", localBackup: "本地备份", records: "条记录",
@@ -134,7 +150,7 @@ const TRANSLATIONS: Record<Language, Record<string, string>> = {
language: "介面語言", languageDesc: "切換後立即套用到操作介面", serverApi: "本地伺服器介面", serverApiDesc: "介面未設定時不會產生模擬資料", language: "介面語言", languageDesc: "切換後立即套用到操作介面", serverApi: "本地伺服器介面", serverApiDesc: "介面未設定時不會產生模擬資料",
materialDict: "廢料字典", materialDictDesc: "修改後將同步更新首頁分類與稱重資料", displayName: "顯示名稱", materialDict: "廢料字典", materialDictDesc: "修改後將同步更新首頁分類與稱重資料", displayName: "顯示名稱",
saveAll: "儲存全部設定", saveHint: "修改完成後請儲存設定", passwordTitle: "管理員登入", passwordDesc: "請輸入管理員密碼進入應用設定", confirmLogin: "確認登入", saveAll: "儲存全部設定", saveHint: "修改完成後請儲存設定", passwordTitle: "管理員登入", passwordDesc: "請輸入管理員密碼進入應用設定", confirmLogin: "確認登入",
employeeApi: "人員資訊介面", machineApi: "機台列表介面", scaleApi: "電子秤資料介面", uploadApi: "稱重上傳介面", apiAddressPlaceholder: "請輸入完整介面地址", employeeApi: "人員資訊介面", machineApi: "機台列表介面", scaleApi: "電子秤廣播服務", uploadApi: "稱重上傳介面", apiAddressPlaceholder: "請輸入完整介面地址", modbusConfigPlaceholder: "後台電子秤服務自動連線,無需設定", modbusConfigHint: "請安裝 android_hid_scale_service 後台服務;重量、皮重、單位和穩定狀態會自動同步。",
peripheralTitle: "完成稱重後的外設動作", peripheralDesc: "功能預設關閉,需由 Android 原生層接入 USB 設備", reserved: "預留介面", cameraTitle: "USB 攝影機拍照", cameraDesc: "提交成功後自動拍攝現場照片", peripheralTitle: "完成稱重後的外設動作", peripheralDesc: "功能預設關閉,需由 Android 原生層接入 USB 設備", reserved: "預留介面", cameraTitle: "USB 攝影機拍照", cameraDesc: "提交成功後自動拍攝現場照片",
photoPath: "照片儲存路徑", fileName: "檔名", printTitle: "稱重憑證列印", printDesc: "提交成功後呼叫 USB / 藍牙印表機", scrapWeight: "廢料重量", weighingTime: "稱重時間", photoPath: "照片儲存路徑", fileName: "檔名", printTitle: "稱重憑證列印", printDesc: "提交成功後呼叫 USB / 藍牙印表機", scrapWeight: "廢料重量", weighingTime: "稱重時間",
dataTitle: "資料補傳與本地備份", dataDesc: "網路異常時確保稱重記錄不遺失", autoProtect: "自動保護", pending: "待補傳資料", localBackup: "本地備份", records: "條記錄", dataTitle: "資料補傳與本地備份", dataDesc: "網路異常時確保稱重記錄不遺失", autoProtect: "自動保護", pending: "待補傳資料", localBackup: "本地備份", records: "條記錄",
@@ -166,7 +182,7 @@ const TRANSLATIONS: Record<Language, Record<string, string>> = {
language: "Interface language", languageDesc: "Changes are applied to the interface immediately", serverApi: "Local server APIs", serverApiDesc: "No sample data is generated when an API is not configured", language: "Interface language", languageDesc: "Changes are applied to the interface immediately", serverApi: "Local server APIs", serverApiDesc: "No sample data is generated when an API is not configured",
materialDict: "Scrap dictionary", materialDictDesc: "Changes update home categories and weighing records", displayName: "Display name", materialDict: "Scrap dictionary", materialDictDesc: "Changes update home categories and weighing records", displayName: "Display name",
saveAll: "Save all settings", saveHint: "Save after making changes", passwordTitle: "Administrator login", passwordDesc: "Enter the administrator password to open settings", confirmLogin: "Log in", saveAll: "Save all settings", saveHint: "Save after making changes", passwordTitle: "Administrator login", passwordDesc: "Enter the administrator password to open settings", confirmLogin: "Log in",
employeeApi: "Operator API", machineApi: "Machine list API", scaleApi: "Scale data API", uploadApi: "Record upload API", apiAddressPlaceholder: "Enter the complete API address", employeeApi: "Operator API", machineApi: "Machine list API", scaleApi: "Scale broadcast service", uploadApi: "Record upload API", apiAddressPlaceholder: "Enter the complete API address", modbusConfigPlaceholder: "The scale service connects automatically", modbusConfigHint: "Install the android_hid_scale_service companion app. Weight, tare, unit, and stability are synchronized automatically.",
peripheralTitle: "Post-weighing peripheral actions", peripheralDesc: "Off by default; requires the Android native USB bridge", reserved: "Reserved", cameraTitle: "USB camera capture", cameraDesc: "Capture a site photo after successful submission", peripheralTitle: "Post-weighing peripheral actions", peripheralDesc: "Off by default; requires the Android native USB bridge", reserved: "Reserved", cameraTitle: "USB camera capture", cameraDesc: "Capture a site photo after successful submission",
photoPath: "Photo save path", fileName: "Filename", printTitle: "Print weighing receipt", printDesc: "Call a USB / Bluetooth printer after submission", scrapWeight: "Scrap weight", weighingTime: "Weighing time", photoPath: "Photo save path", fileName: "Filename", printTitle: "Print weighing receipt", printDesc: "Call a USB / Bluetooth printer after submission", scrapWeight: "Scrap weight", weighingTime: "Weighing time",
dataTitle: "Retry upload & local backup", dataDesc: "Protect weighing records during network failures", autoProtect: "Automatic", pending: "Pending uploads", localBackup: "Local backups", records: "records", dataTitle: "Retry upload & local backup", dataDesc: "Protect weighing records during network failures", autoProtect: "Automatic", pending: "Pending uploads", localBackup: "Local backups", records: "records",
@@ -186,7 +202,6 @@ const CONFIG_KEY = "smart-scale-config";
const BACKUP_KEY = "smart-scale-backup-records"; const BACKUP_KEY = "smart-scale-backup-records";
const PENDING_KEY = "smart-scale-pending-records"; const PENDING_KEY = "smart-scale-pending-records";
const LOG_KEY = "smart-scale-logs"; const LOG_KEY = "smart-scale-logs";
const PRODUCTION_DATA_KEY = "smart-scale-production-data-v1";
const IMAGE_PATH = "/sdcard/smart/image/"; const IMAGE_PATH = "/sdcard/smart/image/";
const CACHE_PATH = "/sdcard/smart/cache/"; const CACHE_PATH = "/sdcard/smart/cache/";
const LOG_PATH = "/sdcard/smart/logs/"; const LOG_PATH = "/sdcard/smart/logs/";
@@ -227,9 +242,10 @@ export default function Home() {
// 称重主流程状态:首页 → 人员/机台确认 → 称重 → 结果。 // 称重主流程状态:首页 → 人员/机台确认 → 称重 → 结果。
const [step, setStep] = useState<Step>("home"); const [step, setStep] = useState<Step>("home");
const [employee, setEmployee] = useState({ name: "", id: "" }); const [employee, setEmployee] = useState({ name: "", id: "" });
const [testEmployeeIndex, setTestEmployeeIndex] = useState(0);
const [machineQuery, setMachineQuery] = useState(""); const [machineQuery, setMachineQuery] = useState("");
const [machine, setMachine] = useState(""); const [machine, setMachine] = useState("");
const [machineOptions, setMachineOptions] = useState<string[]>([]); const [machineOptions, setMachineOptions] = useState<string[]>(TEST_MACHINES);
const [machineModalOpen, setMachineModalOpen] = useState(false); const [machineModalOpen, setMachineModalOpen] = useState(false);
const [machineKeypadOpen, setMachineKeypadOpen] = useState(false); const [machineKeypadOpen, setMachineKeypadOpen] = useState(false);
const [machineLoading, setMachineLoading] = useState(false); const [machineLoading, setMachineLoading] = useState(false);
@@ -259,7 +275,7 @@ export default function Home() {
const [numericEditor, setNumericEditor] = useState<NumericEditor | null>(null); const [numericEditor, setNumericEditor] = useState<NumericEditor | null>(null);
const [numericEditorValue, setNumericEditorValue] = useState(""); const [numericEditorValue, setNumericEditorValue] = useState("");
const [pendingRecords, setPendingRecords] = useState<WeightRecord[]>([]); const [pendingRecords, setPendingRecords] = useState<WeightRecord[]>([]);
const [backupRecords, setBackupRecords] = useState<WeightRecord[]>([]); const [backupRecords, setBackupRecords] = useState<WeightRecord[]>(TEST_WEIGHT_RECORDS);
const [lastRecord, setLastRecord] = useState<WeightRecord | null>(null); const [lastRecord, setLastRecord] = useState<WeightRecord | null>(null);
const [lastUploadPending, setLastUploadPending] = useState(false); const [lastUploadPending, setLastUploadPending] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([ const [logs, setLogs] = useState<LogEntry[]>([
@@ -309,18 +325,6 @@ export default function Home() {
// 延迟到首帧之后恢复配置,避免服务端渲染与本地配置产生水合差异。 // 延迟到首帧之后恢复配置,避免服务端渲染与本地配置产生水合差异。
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
try { try {
/*
* 早期测试版会自动生成模拟人员、机台和重量记录。
* 首次升级到正式数据模式时仅清理这些历史业务记录,
* 管理员密码、接口地址和废料字典等配置继续保留。
*/
if (window.localStorage.getItem(PRODUCTION_DATA_KEY) !== "ready") {
window.localStorage.removeItem(PENDING_KEY);
window.localStorage.removeItem(BACKUP_KEY);
window.localStorage.removeItem(LOG_KEY);
window.localStorage.setItem(PRODUCTION_DATA_KEY, "ready");
}
const savedConfig = window.localStorage.getItem(CONFIG_KEY); const savedConfig = window.localStorage.getItem(CONFIG_KEY);
if (savedConfig) { if (savedConfig) {
const parsed = JSON.parse(savedConfig); const parsed = JSON.parse(savedConfig);
@@ -359,50 +363,53 @@ export default function Home() {
}, []); }, []);
/* /*
* 称重数据只接受已配置的 WebSocket 接口,不再注入任何模拟重量。 * 称重数据通过 Android 原生 RS-485 Modbus RTU 桥接读取。
* 服务端可发送纯数字或带 weight / weightKg / value 字段的 JSON。 * 前端只接收 native 事件,不生成任何模拟重量。
*/ */
useEffect(() => { useEffect(() => {
if (step !== "weigh") return; if (step !== "weigh") return;
if (!appSettings.weight.trim()) return;
let socket: WebSocket; type ModbusEventDetail = { status?: string; message?: string; weightKg?: number };
let disposed = false; let started = false;
const handleModbusEvent = (event: Event) => {
try { const detail = (event as CustomEvent<ModbusEventDetail>).detail ?? {};
socket = new WebSocket(appSettings.weight.trim()); if (detail.status === "connecting") setScaleStatus("connecting");
} catch { if (detail.status === "connected" || detail.status === "sample") setScaleStatus("connected");
window.setTimeout(() => { if (detail.status === "error") setScaleStatus("error");
if (typeof detail.weightKg === "number" && Number.isFinite(detail.weightKg) && detail.weightKg >= 0) {
setRawWeight(detail.weightKg);
}
if (detail.message && detail.status !== "sample") {
addLog(detail.message, detail.status === "error" ? "error" : detail.status === "connected" ? "success" : "info");
}
};
window.addEventListener("woer-modbus", handleModbusEvent);
let errorTimer: number | undefined;
const reportModbusError = (message: string) => {
errorTimer = window.setTimeout(() => {
setScaleStatus("error"); setScaleStatus("error");
addLog("电子秤接口地址无效", "error"); addLog(message, "error");
}, 0); }, 0);
return; };
try {
if (!window.WoerNative?.startScale) {
reportModbusError("当前运行环境不支持电子秤广播服务");
} else {
started = window.WoerNative.startScale();
if (!started) {
reportModbusError("电子秤后台服务未安装或无法启动");
}
}
} catch {
reportModbusError("电子秤后台服务启动失败");
} }
socket.onopen = () => {
if (disposed) return;
setScaleStatus("connected");
addLog("电子秤数据接口连接成功", "success");
};
socket.onmessage = (event) => {
const nextWeight = parseScaleMessage(String(event.data ?? ""));
if (nextWeight !== null) setRawWeight(nextWeight);
};
socket.onerror = () => {
if (disposed) return;
setScaleStatus("error");
addLog("电子秤数据接口连接异常", "error");
};
socket.onclose = () => {
if (disposed) return;
setScaleStatus("error");
};
return () => { return () => {
disposed = true; window.removeEventListener("woer-modbus", handleModbusEvent);
socket.close(); if (errorTimer !== undefined) window.clearTimeout(errorTimer);
if (started) window.WoerNative?.stopScale();
}; };
}, [appSettings.weight, step]); }, [step]);
/* /*
* 按固定周期连续采样,而不是依赖 React 的重量变化事件。 * 按固定周期连续采样,而不是依赖 React 的重量变化事件。
@@ -451,7 +458,7 @@ export default function Home() {
setTare(0); setTare(0);
setStable(false); setStable(false);
setStableProgress(0); setStableProgress(0);
setScaleStatus(appSettings.weight.trim() ? "connecting" : "unconfigured"); setScaleStatus("connecting");
setLastAccumulation(null); setLastAccumulation(null);
setLastUploadPending(false); setLastUploadPending(false);
setNotice(""); setNotice("");
@@ -559,7 +566,14 @@ export default function Home() {
setLoading(true); setLoading(true);
setNotice(""); setNotice("");
try { try {
if (!appSettings.employee.trim()) throw new Error("人员接口未配置"); if (!appSettings.employee.trim()) {
const nextEmployee = TEST_EMPLOYEES[testEmployeeIndex % TEST_EMPLOYEES.length];
setEmployee(nextEmployee);
setTestEmployeeIndex((index) => (index + 1) % TEST_EMPLOYEES.length);
addLog(`已载入测试人员:${nextEmployee.name}`, "info");
setStep("identity");
return;
}
const response = await fetch(appSettings.employee.trim()); const response = await fetch(appSettings.employee.trim());
if (!response.ok) throw new Error("人员接口不可用"); if (!response.ok) throw new Error("人员接口不可用");
const data = await response.json(); const data = await response.json();
@@ -582,7 +596,11 @@ export default function Home() {
setMachineLoading(true); setMachineLoading(true);
setSettingsMessage(""); setSettingsMessage("");
try { try {
if (!appSettings.machines.trim()) throw new Error("机台接口未配置"); if (!appSettings.machines.trim()) {
setMachineOptions(TEST_MACHINES);
addLog("已载入测试机台列表", "info");
return;
}
const response = await fetch(appSettings.machines.trim()); const response = await fetch(appSettings.machines.trim());
if (!response.ok) throw new Error("机台接口不可用"); if (!response.ok) throw new Error("机台接口不可用");
const data = await response.json(); const data = await response.json();
@@ -619,7 +637,7 @@ export default function Home() {
setStable(false); setStable(false);
setStableProgress(0); setStableProgress(0);
stableSamples.current = []; stableSamples.current = [];
setScaleStatus(appSettings.weight.trim() ? "connecting" : "unconfigured"); setScaleStatus("connecting");
setNotice(""); setNotice("");
setStep("weigh"); setStep("weigh");
} }
@@ -755,6 +773,14 @@ export default function Home() {
setNotice(language === "en" ? "Last accumulation canceled" : language === "zh-TW" ? "已取消上一次累計" : "已取消上一次累计"); setNotice(language === "en" ? "Last accumulation canceled" : language === "zh-TW" ? "已取消上一次累計" : "已取消上一次累计");
} }
function sendScaleCommand(command: "zero" | "tare") {
try {
return window.WoerNative?.scaleCommand(command) ?? false;
} catch {
return false;
}
}
async function retryPendingUploads() { async function retryPendingUploads() {
if (!pendingRecords.length) { if (!pendingRecords.length) {
setSettingsMessage("当前没有需要补传的数据"); setSettingsMessage("当前没有需要补传的数据");
@@ -871,35 +897,9 @@ export default function Home() {
</div> </div>
</header> </header>
{step !== "home" && step !== "settings" && (
<div className="progress-strip" aria-label="流程进度">
{[t("progressEmployee"), t("progressWeight"), t("progressResult")].map((label, index) => {
const activeIndex = step === "identity" ? 0 : step === "weigh" ? 1 : 2;
return (
<div className={index <= activeIndex ? "progress-item active" : "progress-item"} key={label}>
<span>{index < activeIndex ? "✓" : index + 1}</span>
{label}
</div>
);
})}
</div>
)}
<div className="content"> <div className="content">
{step === "home" && ( {step === "home" && (
<div className="home-view"> <div className="home-view">
<div className="home-copy">
<div className="eyebrow"><span /> {t("heroEyebrow")}</div>
<h1>{t("heroTitle1")}<br /><em>{t("heroTitle2")}</em></h1>
<p>{t("heroDesc1")}<br />{t("heroDesc2")}</p>
<div className={loading ? "home-start-hint loading" : "home-start-hint"}>
<span>{loading ? "···" : "1"}</span>
<b>{loading ? t("loadingEmployee") : t("clickMaterial")}</b>
</div>
<button className="admin-link" onClick={() => { setPassword(""); setPasswordError(""); setPasswordOpen(true); }}>⚙ {t("adminLogin")} <span>→</span></button>
{notice && <div className="notice home-notice">ⓘ {notice}</div>}
</div>
<aside className="home-material-panel"> <aside className="home-material-panel">
<div className="material-panel-heading"> <div className="material-panel-heading">
<span className="step-kicker">{t("prep")}</span> <span className="step-kicker">{t("prep")}</span>
@@ -925,18 +925,27 @@ export default function Home() {
{loading ? t("loadingEmployee") : material ? `${t("chooseMaterial")} · ${materialName(material)}` : t("chooseAuto")} {loading ? t("loadingEmployee") : material ? `${t("chooseMaterial")} · ${materialName(material)}` : t("chooseAuto")}
</div> </div>
</aside> </aside>
<button className="admin-link-fab" onClick={() => setPasswordOpen(true)}>
{t("adminLogin")} <span>⌁</span>
</button>
</div> </div>
)} )}
{step === "identity" && ( {step === "identity" && (
<div className="panel-view identity-view"> <div className="panel-view identity-view">
<button className="back-button" onClick={resetToHome}>← {t("back")}</button> <button className="back-button identity-back" onClick={resetToHome}>← {t("back")}</button>
<div className="section-heading"> <div className="section-heading">
<div> <div>
<span className="step-kicker">01 · {t("employeeMachine")}</span>
<h2>{t("confirmInfo")}</h2> <h2>{t("confirmInfo")}</h2>
<p>{t("identityDesc")}</p>
</div> </div>
</div>
<div className={`selected-material-summary ${selectedMaterial.tone} identity-material-compact`}>
<small>{t("currentMaterial")}</small><b>{materialName(selectedMaterial)}</b><em>TYPE ID · {selectedMaterial.id}</em><i>✓</i>
</div>
<div className="identity-layout">
<section className="identity-person-panel">
<div className="identity-panel-heading">
<span>{t("currentEmployee")}</span>
<button className="refresh-button" onClick={() => void fetchIdentity()} disabled={loading}>↻ {t("refreshEmployee")}</button> <button className="refresh-button" onClick={() => void fetchIdentity()} disabled={loading}>↻ {t("refreshEmployee")}</button>
</div> </div>
<div className="identity-card"> <div className="identity-card">
@@ -944,20 +953,20 @@ export default function Home() {
<div><small>{t("currentEmployee")}</small><h3>{employee.name || "—"}</h3><p>{t("employeeId")} · {employee.id || "—"}</p></div> <div><small>{t("currentEmployee")}</small><h3>{employee.name || "—"}</h3><p>{t("employeeId")} · {employee.id || "—"}</p></div>
<span className={employee.name ? "verified" : "verified missing"}>{employee.name ? `✓ ${t("verified")}` : "!"}</span> <span className={employee.name ? "verified" : "verified missing"}>{employee.name ? `✓ ${t("verified")}` : "!"}</span>
</div> </div>
<label className="field-label">{t("machineNo")}</label> </section>
<section className="identity-operation-panel">
<div className="identity-panel-heading"><span>{t("machineNo")}</span></div>
<div className={machine ? "machine-select-row selected" : "machine-select-row"}> <div className={machine ? "machine-select-row selected" : "machine-select-row"}>
<span className="machine-select-icon">▣</span> <span className="machine-select-icon">▣</span>
<span><small>{machine ? t("selectedMachine") : t("machineNo")}</small><b>{machine || t("noMachine")}</b></span> <span><small>{machine ? t("selectedMachine") : t("machineNo")}</small><b>{machine || t("noMachine")}</b></span>
<button onClick={() => { setMachineQuery(""); setMachineKeypadOpen(false); setMachineModalOpen(true); }}>{t("selectMachine")}</button> <button onClick={() => { setMachineQuery(""); setMachineKeypadOpen(false); setMachineModalOpen(true); }}>{t("selectMachine")}</button>
<button className="machine-refresh" onClick={() => void refreshMachines()} disabled={machineLoading}>↻ {t("refreshMachine")}</button> <button className="machine-refresh" onClick={() => void refreshMachines()} disabled={machineLoading}>↻ {t("refreshMachine")}</button>
</div> </div>
<button <button className="full-primary" disabled={!employee.name || !machine} onClick={beginWeighing}>
className="full-primary"
disabled={!employee.name || !machine}
onClick={beginWeighing}
>
{t("confirmWeigh")} <span>→</span> {t("confirmWeigh")} <span>→</span>
</button> </button>
</section>
</div>
{notice && <div className="notice">ⓘ {notice}</div>} {notice && <div className="notice">ⓘ {notice}</div>}
</div> </div>
)} )}
@@ -995,20 +1004,12 @@ export default function Home() {
</div> </div>
<div className={lastAccumulation ? "scale-tools has-cancel" : "scale-tools"}> <div className={lastAccumulation ? "scale-tools has-cancel" : "scale-tools"}>
<button onClick={() => { setZeroOffset(rawWeight); setTare(0); setNotice(t("zeroed")); }}>⟲<span><b>{t("zero")}</b><small>{t("zeroDesc")}</small></span></button> <button onClick={() => { const sent = sendScaleCommand("zero"); setZeroOffset(rawWeight); setTare(0); setNotice(sent ? t("zeroed") : "清零指令发送失败,请检查电子秤后台服务"); }}>⟲<span><b>{t("zero")}</b><small>{t("zeroDesc")}</small></span></button>
<button onClick={() => { setTare(Math.max(0, rawWeight - zeroOffset)); setNotice(t("tared")); }}>▱<span><b>{t("tareAction")}</b><small>{t("tareDesc")}</small></span></button> <button onClick={() => { const sent = sendScaleCommand("tare"); setTare(Math.max(0, rawWeight - zeroOffset)); setNotice(sent ? t("tared") : "去皮指令发送失败,请检查电子秤后台服务"); }}>▱<span><b>{t("tareAction")}</b><small>{t("tareDesc")}</small></span></button>
<button onClick={addToTotal}>+<span><b>{t("accumulate")}</b><small>{t("accumulateDesc")}</small></span></button> <button onClick={addToTotal}>+<span><b>{t("accumulate")}</b><small>{t("accumulateDesc")}</small></span></button>
{lastAccumulation && <button className="cancel-total" onClick={cancelAccumulation}>×<span><b>{t("cancelAccum")}</b><small>{t("cancelAccumDesc")}</small></span></button>} {lastAccumulation && <button className="cancel-total" onClick={cancelAccumulation}>×<span><b>{t("cancelAccum")}</b><small>{t("cancelAccumDesc")}</small></span></button>}
</div> </div>
<div className={`selected-material-summary ${selectedMaterial.tone}`}>
<span>
<small>{t("currentMaterial")}</small>
<b>{materialName(selectedMaterial)}</b>
<em>TYPE ID · {selectedMaterial.id}</em>
</span>
<i>✓ {t("confirmedHome")}</i>
</div>
{notice && <div className="notice compact">ⓘ {notice}</div>} {notice && <div className="notice compact">ⓘ {notice}</div>}
<button className="full-primary upload" disabled={!stable || loading} onClick={submitWeight}> <button className="full-primary upload" disabled={!stable || loading} onClick={submitWeight}>
{loading ? t("uploading") : stable ? t("saveWeight") : t("waitStable")} <span>→</span> {loading ? t("uploading") : stable ? t("saveWeight") : t("waitStable")} <span>→</span>
@@ -1124,11 +1125,15 @@ export default function Home() {
<input <input
value={appSettings[key as keyof Pick<AppSettings, "employee" | "machines" | "weight" | "upload">] as string} value={appSettings[key as keyof Pick<AppSettings, "employee" | "machines" | "weight" | "upload">] as string}
onChange={(event) => setAppSettings({ ...appSettings, [key]: event.target.value })} onChange={(event) => setAppSettings({ ...appSettings, [key]: event.target.value })}
placeholder={t("apiAddressPlaceholder")} placeholder={key === "weight" ? t("modbusConfigPlaceholder") : t("apiAddressPlaceholder")}
/> />
</label> </label>
))} ))}
</div> </div>
<div className="path-box data-path">
<small>{t("scaleApi")}</small>
<code>{t("modbusConfigHint")}</code>
</div>
</> </>
)} )}
@@ -1263,8 +1268,8 @@ export default function Home() {
</div> </div>
)} )}
</div> </div>
<footer className="production-footer">沃尔核材 · 数字化生产</footer>
<footer><span>沃尔核材 · 数字化生产</span><span>{appSettings.deviceName} · {appSettings.deviceId} · v1.1</span></footer>
</section> </section>
{passwordOpen && ( {passwordOpen && (
+17
View File
@@ -6,6 +6,23 @@ const UNIT_FACTORS: Record<WeightUnit, number> = {
lb: 2.20462262, lb: 2.20462262,
}; };
/**
* RS-485 Modbus RTU 默认配置。
* 留空或传 "{}" 时使用此配置。
* 针对 Firefly AIO-3399C 工业终端的 /dev/ttysWK0 (RS485 端口)。
*/
export const DEFAULT_MODBUS_CONFIG = {
portPath: "/dev/ttysWK0",
baudRate: 9600,
slaveId: 1,
functionCode: 4,
registerAddress: 0,
registerCount: 2,
dataType: "float32",
byteOrder: "ABCD",
pollIntervalMs: 125,
};
/** /**
* 将内部统一保存的千克值转换为界面单位,并固定显示两位小数。 * 将内部统一保存的千克值转换为界面单位,并固定显示两位小数。
*/ */