diff --git a/android-app/android/app/src/main/AndroidManifest.xml b/android-app/android/app/src/main/AndroidManifest.xml index 2db7b37..db303be 100644 --- a/android-app/android/app/src/main/AndroidManifest.xml +++ b/android-app/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/android-app/android/app/src/main/java/com/woer/smartscale/MainActivity.java b/android-app/android/app/src/main/java/com/woer/smartscale/MainActivity.java index a8c5e5e..8932d07 100644 --- a/android-app/android/app/src/main/java/com/woer/smartscale/MainActivity.java +++ b/android-app/android/app/src/main/java/com/woer/smartscale/MainActivity.java @@ -9,7 +9,6 @@ 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; @@ -29,7 +28,10 @@ import java.nio.charset.StandardCharsets; public class MainActivity extends Activity { // Android 6 到 Android 9 写入公共目录时需要动态申请存储权限。 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 ScaleBroadcastBridge scaleBroadcastBridge; @Override protected void onCreate(Bundle savedInstanceState) { @@ -63,6 +65,7 @@ public class MainActivity extends Activity { settings.setUseWideViewPort(true); settings.setLoadWithOverviewMode(false); // 向前端暴露受控的文件保存和系统页面跳转能力。 + scaleBroadcastBridge = new ScaleBroadcastBridge(this, webView); webView.addJavascriptInterface(new AndroidBridge(), "WoerNative"); setContentView(webView); @@ -115,6 +118,21 @@ public class MainActivity extends Activity { | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); 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) { @@ -167,17 +185,12 @@ public class MainActivity extends Activity { } 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); + // 操作界面按 1024×600 横屏终端标定,保持控件在目标设备上的一致尺寸。 try { 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", ""); webView.loadDataWithBaseURL( "file:///android_asset/public/", @@ -205,6 +218,10 @@ public class MainActivity extends Activity { @Override protected void onDestroy() { + if (scaleBroadcastBridge != null) { + scaleBroadcastBridge.destroy(); + scaleBroadcastBridge = null; + } if (webView != null) { // 主动销毁 WebView,释放页面定时器和 JavaScript 上下文。 webView.destroy(); diff --git a/android-app/android/app/src/main/java/com/woer/smartscale/ModbusRtuBridge.java b/android-app/android/app/src/main/java/com/woer/smartscale/ModbusRtuBridge.java new file mode 100644 index 0000000..619d468 --- /dev/null +++ b/android-app/android/app/src/main/java/com/woer/smartscale/ModbusRtuBridge.java @@ -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 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); + } + }); + } +} diff --git a/android-app/android/app/src/main/java/com/woer/smartscale/ScaleBroadcastBridge.java b/android-app/android/app/src/main/java/com/woer/smartscale/ScaleBroadcastBridge.java new file mode 100644 index 0000000..25c362c --- /dev/null +++ b/android-app/android/app/src/main/java/com/woer/smartscale/ScaleBroadcastBridge.java @@ -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)); + } +} diff --git a/app/globals.css b/app/globals.css index eb5c24d..bcce9fd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -69,6 +69,7 @@ button:disabled { cursor: not-allowed; opacity: .55; } .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: 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-copy { min-width: 0; } @@ -84,26 +85,32 @@ h1 em { font-style: normal; color: var(--green); } @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: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-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: 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: 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-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: 15px; } +.home-material-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.home-material-grid .material-card { min-height: 120px; padding: 22px 20px; border-radius: 16px; } +.home-material-grid .material-card.selected { padding: 21px 19px; } +.home-material-grid .material-card b { font-size: 24px; } +.home-material-grid .material-card small { margin-top: 10px; font-size: 13px; } +.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 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; } +.panel-view { width: min(760px, calc(100% - 48px)); margin: 0 auto; padding: 35px 0; position: relative; } .back-button { min-height: 38px; 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; } .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; } .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: 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; } +.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: 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: 14px; } +.identity-card h3 { margin: 4px 0; font-size: 22px; } +.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.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; } .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: 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 { 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-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 small, .machine-select-row b { display: block; } -.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 small { color: #89948e; font-size: 12px; margin-bottom: 4px; } +.machine-select-row b { font-size: 17px; } +.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; } .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:hover { background: #eef7f2; color: var(--green); } .suggestions button small { margin-left: auto; color: #95a099; } .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; } .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; } -.weigh-back { margin-bottom: 7px; } -.weigh-meta { display: flex; gap: 0; border: 1px solid var(--line); background: white; border-radius: 11px; padding: 10px 14px; margin-bottom: 14px; } -.weigh-meta > span { min-width: 170px; padding-right: 18px; margin-right: 18px; border-right: 1px solid var(--line); } +.weigh-view { padding: 14px 32px 28px; position: relative; } +.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: 12px 16px; margin: 0 0 14px 92px; } +.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 small, .weigh-meta b { display: block; } -.weigh-meta small { color: #8a958f; font-size: 11px; margin-bottom: 4px; } -.weigh-meta b { font-size: 13px; } +.weigh-meta small, .weigh-meta b { display: inline; } +.weigh-meta small { color: #8a958f; font-size: 14px; margin: 0 10px 0 0; } +.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; } -.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; } -.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 { color: #e2c680; font-size: 14px; font-weight: 700; } +.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 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: 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; } -.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(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; } +.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(88px, 13vw, 138px); letter-spacing: -.08em; font-weight: 700; } +.weight-number b { color: #8da298; font-size: 20px; } +.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; } -.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 b { color: #dbe8e1; margin-left: 5px; } .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 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 { 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 small, .selected-material-summary b, .selected-material-summary em { display: block; } -.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 small { color: #7a8a82; font-size: 12px; } +.selected-material-summary b { margin: 3px 0; font-size: 21px; } +.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; } .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); } .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; } + .home-material-grid { gap: 12px; } + .home-material-grid .material-card { min-height: 92px; padding: 16px 18px; } + .home-material-grid .material-card.selected { padding: 15px 17px; } + .home-material-grid .material-card b { font-size: 22px; } + .home-material-grid .material-card small { margin-top: 8px; font-size: 13px; } .material-selection-status { margin-top: 10px; padding: 8px 10px; } .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 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; } + .identity-card { padding: 13px 16px; } + .avatar { width: 48px; height: 48px; border-radius: 11px; font-size: 21px; } + .identity-card h3 { margin: 2px 0; font-size: 20px; } .field-label { margin: 12px 0 6px; } - .machine-select-row { min-height: 52px; padding: 7px 10px; } - .machine-select-icon { width: 32px; height: 32px; } + .machine-select-row { min-height: 56px; padding: 8px 12px; } + .machine-select-icon { width: 36px; height: 36px; } .full-primary { margin-top: 12px; padding: 12px 16px; } .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 > span { min-width: 150px; } .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 span { font-size: clamp(48px, 7vw, 70px); } - .display-details { margin-top: 6px; } + .weight-number span { font-size: clamp(56px, 9vw, 80px); } + .display-details { margin-top: 8px; } .scale-tools { margin: 7px 0 8px; } .scale-tools button { min-height: 42px; padding: 7px 11px; } .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) { .weight-number span { - font-size: 44px; + font-size: clamp(48px, 8vw, 68px); font-weight: 900; letter-spacing: -.05em; } @@ -755,3 +779,43 @@ h1 em { font-style: normal; color: var(--green); } 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; } +} diff --git a/app/page.tsx b/app/page.tsx index 2f28cb2..66c1287 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,7 +5,6 @@ import { createFileTimestamp, evaluateStability, formatWeight, - parseScaleMessage, type WeightUnit, } from "./scale-utils"; @@ -59,6 +58,9 @@ declare global { exportLogs: (fileName: string, content: string) => boolean; openSystemSettings: () => 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" }, ]; +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 = { employee: "", machines: "", @@ -102,7 +118,7 @@ const TRANSLATIONS: Record> = { language: "界面语言", languageDesc: "切换后立即应用到操作界面", serverApi: "本地服务器接口", serverApiDesc: "接口未配置时不会生成模拟数据", materialDict: "废料字典", materialDictDesc: "修改后将同步更新首页分类与称重数据", displayName: "显示名称", 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: "提交成功后自动拍摄现场照片", photoPath: "照片保存路径", fileName: "文件名", printTitle: "称重凭证打印", printDesc: "提交成功后调用 USB / 蓝牙打印机", scrapWeight: "废料重量", weighingTime: "称重时间", dataTitle: "数据补传与本地备份", dataDesc: "网络异常时确保称重记录不丢失", autoProtect: "自动保护", pending: "待补传数据", localBackup: "本地备份", records: "条记录", @@ -134,7 +150,7 @@ const TRANSLATIONS: Record> = { language: "介面語言", languageDesc: "切換後立即套用到操作介面", serverApi: "本地伺服器介面", serverApiDesc: "介面未設定時不會產生模擬資料", materialDict: "廢料字典", materialDictDesc: "修改後將同步更新首頁分類與稱重資料", displayName: "顯示名稱", 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: "提交成功後自動拍攝現場照片", photoPath: "照片儲存路徑", fileName: "檔名", printTitle: "稱重憑證列印", printDesc: "提交成功後呼叫 USB / 藍牙印表機", scrapWeight: "廢料重量", weighingTime: "稱重時間", dataTitle: "資料補傳與本地備份", dataDesc: "網路異常時確保稱重記錄不遺失", autoProtect: "自動保護", pending: "待補傳資料", localBackup: "本地備份", records: "條記錄", @@ -166,7 +182,7 @@ const TRANSLATIONS: Record> = { language: "Interface language", languageDesc: "Changes are applied to the interface immediately", serverApi: "Local server APIs", serverApiDesc: "No sample data is generated when an API is not configured", materialDict: "Scrap dictionary", materialDictDesc: "Changes update home categories and weighing records", displayName: "Display name", saveAll: "Save all settings", saveHint: "Save after making changes", passwordTitle: "Administrator login", passwordDesc: "Enter the administrator password to open settings", 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", 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", @@ -186,7 +202,6 @@ const CONFIG_KEY = "smart-scale-config"; const BACKUP_KEY = "smart-scale-backup-records"; const PENDING_KEY = "smart-scale-pending-records"; const LOG_KEY = "smart-scale-logs"; -const PRODUCTION_DATA_KEY = "smart-scale-production-data-v1"; const IMAGE_PATH = "/sdcard/smart/image/"; const CACHE_PATH = "/sdcard/smart/cache/"; const LOG_PATH = "/sdcard/smart/logs/"; @@ -227,9 +242,10 @@ export default function Home() { // 称重主流程状态:首页 → 人员/机台确认 → 称重 → 结果。 const [step, setStep] = useState("home"); const [employee, setEmployee] = useState({ name: "", id: "" }); + const [testEmployeeIndex, setTestEmployeeIndex] = useState(0); const [machineQuery, setMachineQuery] = useState(""); const [machine, setMachine] = useState(""); - const [machineOptions, setMachineOptions] = useState([]); + const [machineOptions, setMachineOptions] = useState(TEST_MACHINES); const [machineModalOpen, setMachineModalOpen] = useState(false); const [machineKeypadOpen, setMachineKeypadOpen] = useState(false); const [machineLoading, setMachineLoading] = useState(false); @@ -259,7 +275,7 @@ export default function Home() { const [numericEditor, setNumericEditor] = useState(null); const [numericEditorValue, setNumericEditorValue] = useState(""); const [pendingRecords, setPendingRecords] = useState([]); - const [backupRecords, setBackupRecords] = useState([]); + const [backupRecords, setBackupRecords] = useState(TEST_WEIGHT_RECORDS); const [lastRecord, setLastRecord] = useState(null); const [lastUploadPending, setLastUploadPending] = useState(false); const [logs, setLogs] = useState([ @@ -309,18 +325,6 @@ export default function Home() { // 延迟到首帧之后恢复配置,避免服务端渲染与本地配置产生水合差异。 const timer = window.setTimeout(() => { try { - /* - * 早期测试版会自动生成模拟人员、机台和重量记录。 - * 首次升级到正式数据模式时仅清理这些历史业务记录, - * 管理员密码、接口地址和废料字典等配置继续保留。 - */ - if (window.localStorage.getItem(PRODUCTION_DATA_KEY) !== "ready") { - window.localStorage.removeItem(PENDING_KEY); - window.localStorage.removeItem(BACKUP_KEY); - window.localStorage.removeItem(LOG_KEY); - window.localStorage.setItem(PRODUCTION_DATA_KEY, "ready"); - } - const savedConfig = window.localStorage.getItem(CONFIG_KEY); if (savedConfig) { const parsed = JSON.parse(savedConfig); @@ -359,50 +363,53 @@ export default function Home() { }, []); /* - * 称重数据只接受已配置的 WebSocket 接口,不再注入任何模拟重量。 - * 服务端可发送纯数字或带 weight / weightKg / value 字段的 JSON。 + * 称重数据通过 Android 原生 RS-485 Modbus RTU 桥接读取。 + * 前端只接收 native 事件,不生成任何模拟重量。 */ useEffect(() => { if (step !== "weigh") return; - if (!appSettings.weight.trim()) return; - let socket: WebSocket; - let disposed = false; - - try { - socket = new WebSocket(appSettings.weight.trim()); - } catch { - window.setTimeout(() => { + type ModbusEventDetail = { status?: string; message?: string; weightKg?: number }; + let started = false; + const handleModbusEvent = (event: Event) => { + const detail = (event as CustomEvent).detail ?? {}; + if (detail.status === "connecting") setScaleStatus("connecting"); + if (detail.status === "connected" || detail.status === "sample") setScaleStatus("connected"); + 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"); - addLog("电子秤接口地址无效", "error"); + addLog(message, "error"); }, 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 () => { - disposed = true; - socket.close(); + window.removeEventListener("woer-modbus", handleModbusEvent); + if (errorTimer !== undefined) window.clearTimeout(errorTimer); + if (started) window.WoerNative?.stopScale(); }; - }, [appSettings.weight, step]); + }, [step]); /* * 按固定周期连续采样,而不是依赖 React 的重量变化事件。 @@ -451,7 +458,7 @@ export default function Home() { setTare(0); setStable(false); setStableProgress(0); - setScaleStatus(appSettings.weight.trim() ? "connecting" : "unconfigured"); + setScaleStatus("connecting"); setLastAccumulation(null); setLastUploadPending(false); setNotice(""); @@ -559,7 +566,14 @@ export default function Home() { setLoading(true); setNotice(""); 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()); if (!response.ok) throw new Error("人员接口不可用"); const data = await response.json(); @@ -582,7 +596,11 @@ export default function Home() { setMachineLoading(true); setSettingsMessage(""); 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()); if (!response.ok) throw new Error("机台接口不可用"); const data = await response.json(); @@ -619,7 +637,7 @@ export default function Home() { setStable(false); setStableProgress(0); stableSamples.current = []; - setScaleStatus(appSettings.weight.trim() ? "connecting" : "unconfigured"); + setScaleStatus("connecting"); setNotice(""); setStep("weigh"); } @@ -755,6 +773,14 @@ export default function Home() { 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() { if (!pendingRecords.length) { setSettingsMessage("当前没有需要补传的数据"); @@ -871,35 +897,9 @@ export default function Home() { - {step !== "home" && step !== "settings" && ( -
- {[t("progressEmployee"), t("progressWeight"), t("progressResult")].map((label, index) => { - const activeIndex = step === "identity" ? 0 : step === "weigh" ? 1 : 2; - return ( -
- {index < activeIndex ? "✓" : index + 1} - {label} -
- ); - })} -
- )} -
{step === "home" && (
-
-
{t("heroEyebrow")}
-

{t("heroTitle1")}
{t("heroTitle2")}

-

{t("heroDesc1")}
{t("heroDesc2")}

-
- {loading ? "···" : "1"} - {loading ? t("loadingEmployee") : t("clickMaterial")} -
- - {notice &&
ⓘ {notice}
} -
- +
)} {step === "identity" && (
- +
- 01 · {t("employeeMachine")}

{t("confirmInfo")}

-

{t("identityDesc")}

-
-
-
{employee.name ? employee.name.slice(-1) : "?"}
-
{t("currentEmployee")}

{employee.name || "—"}

{t("employeeId")} · {employee.id || "—"}

- {employee.name ? `✓ ${t("verified")}` : "!"} +
+ {t("currentMaterial")}{materialName(selectedMaterial)}TYPE ID · {selectedMaterial.id}✓
- -
- ▣ - {machine ? t("selectedMachine") : t("machineNo")}{machine || t("noMachine")} - - +
+
+
+ {t("currentEmployee")} + +
+
+
{employee.name ? employee.name.slice(-1) : "?"}
+
{t("currentEmployee")}

{employee.name || "—"}

{t("employeeId")} · {employee.id || "—"}

+ {employee.name ? `✓ ${t("verified")}` : "!"} +
+
+
+
{t("machineNo")}
+
+ ▣ + {machine ? t("selectedMachine") : t("machineNo")}{machine || t("noMachine")} + + +
+ +
- {notice &&
ⓘ {notice}
}
)} @@ -995,20 +1004,12 @@ export default function Home() {
- - + + {lastAccumulation && }
-
- - {t("currentMaterial")} - {materialName(selectedMaterial)} - TYPE ID · {selectedMaterial.id} - - ✓ {t("confirmedHome")} -
{notice &&
ⓘ {notice}
}
+
+ {t("scaleApi")} + {t("modbusConfigHint")} +
)} @@ -1263,8 +1268,8 @@ export default function Home() {
)} +
沃尔核材 · 数字化生产
-
沃尔核材 · 数字化生产{appSettings.deviceName} · {appSettings.deviceId} · v1.1
{passwordOpen && ( diff --git a/app/scale-utils.ts b/app/scale-utils.ts index 21c270a..09b0ab8 100644 --- a/app/scale-utils.ts +++ b/app/scale-utils.ts @@ -6,6 +6,23 @@ const UNIT_FACTORS: Record = { 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, +}; + /** * 将内部统一保存的千克值转换为界面单位,并固定显示两位小数。 */