feat: 初始化星元智灵人脸识别回收箱项目
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
._codex_tmp/
|
||||
.idea/
|
||||
.gradle/
|
||||
*.iml
|
||||
local.properties
|
||||
build/
|
||||
sdk-demo/
|
||||
*.idsig
|
||||
*.apk
|
||||
*.ap_
|
||||
keystore/
|
||||
app/src/main/assets/face_models/face_recognizer.csta
|
||||
@@ -0,0 +1,113 @@
|
||||
# 星元智灵
|
||||
|
||||
星元智灵是基于智投终端流程改造的竖屏 Android 投放终端应用。项目保留串口投口控制、称重回包、本地备份和服务器上报框架,并集成本地 SeetaFace6 人脸识别与人员录入流程。
|
||||
本项目为沃尔核材专用 APP,定位为安卓上位机应用。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 首页显示摄像头实时画面并默认进行本地人脸识别,也可通过刷卡或工号进入投放流程。
|
||||
- 设置页新增“人脸”Tab,可新增人员、录入或更新人脸、查看人员列表并删除人员。
|
||||
- 第二页填写本次投放信息:机台号、人员、生产批次。
|
||||
- 机台号支持手动输入,也支持小窗口表格选择;选择后自动联动人员和生产批次。
|
||||
- 第三页支持 1/2/3/4 投口模式,可选择单个投口或打开全部投口,页面展示本次机台号、人员、生产批次。
|
||||
- 投递页支持关门倒计时、手动关门、一次性延时关门。
|
||||
- 结果页仅显示本次投放垃圾类型和重量,不显示奖励。
|
||||
- 投口垃圾类型支持镀锡铜、铝合金、废铜、废电线、废铝、废锡和自定义。
|
||||
- 投放结果通过预留接口上报服务器,接口未定时使用本地队列和备份兜底。
|
||||
- 保留下位机原始称重小数;串口或下位机不可用时不会生成虚假称重结果。
|
||||
|
||||
## 工程结构
|
||||
|
||||
```text
|
||||
app/src/main/
|
||||
AndroidManifest.xml Android 应用配置
|
||||
assets/www/ WebView 前端界面
|
||||
assets/face_models/ SeetaFace6 人脸模型
|
||||
assets/brand/ App 图标源图
|
||||
java/ 原生桥、串口、TCP 上报逻辑
|
||||
jniLibs/armeabi-v7a/ 串口 native 库
|
||||
res/mipmap-*/ Android launcher 图标
|
||||
build.sh 无 Gradle 手动构建脚本
|
||||
```
|
||||
|
||||
## 构建要求
|
||||
|
||||
- macOS
|
||||
- Android SDK,包含:
|
||||
- `platforms/android-28/android.jar`
|
||||
- `build-tools/34.0.0`
|
||||
- JDK 8 或兼容 `javac --release 8` 的 JDK
|
||||
- Android SDK platform-tools,用于 `adb install`
|
||||
|
||||
仓库服务限制单文件最大 50MB,因此约 98MB 的
|
||||
`face_recognizer.csta` 被保存为 `model-parts/` 下的四个分片。
|
||||
`build.sh` 会在编译前自动校验 SHA-256 并恢复完整模型,无需手动合并。
|
||||
|
||||
## 构建 APK
|
||||
|
||||
```bash
|
||||
bash build.sh
|
||||
```
|
||||
|
||||
如果只需要恢复或校验人脸模型,可单独执行:
|
||||
|
||||
```bash
|
||||
bash scripts/restore-face-models.sh
|
||||
```
|
||||
|
||||
构建产物:
|
||||
|
||||
```text
|
||||
build/out/xingyuan-zhiling-signed.apk
|
||||
```
|
||||
|
||||
首次构建时,如果本地没有 debug keystore,脚本会自动生成:
|
||||
|
||||
```text
|
||||
keystore/zhitou-debug.keystore
|
||||
```
|
||||
|
||||
该 keystore 不提交到 Git。
|
||||
|
||||
## 安装到终端
|
||||
|
||||
```bash
|
||||
$HOME/Library/Android/sdk/platform-tools/adb install -r build/out/xingyuan-zhiling-signed.apk
|
||||
$HOME/Library/Android/sdk/platform-tools/adb shell monkey -p com.xingyuan.zhiling -c android.intent.category.LAUNCHER 1
|
||||
```
|
||||
|
||||
## 串口说明
|
||||
|
||||
默认串口配置:
|
||||
|
||||
```text
|
||||
path: /dev/ttyS4
|
||||
baudRate: 9600
|
||||
```
|
||||
|
||||
如果首页提示串口故障,请检查串口占用情况以及下位机连接。旧智投 APP 与星元智灵同时运行时,可能会竞争 `/dev/ttyS4`。
|
||||
|
||||
## 接口说明
|
||||
|
||||
当前机台号、人员、生产批次接口仍为测试数据实现,内置 `WAL-A01`、`WAL-A02`、`WAL-B01`、`WAL-B02` 四台机台供流程测试。后续接入正式接口时,需要替换:
|
||||
|
||||
- `app/src/main/java/com/zhitou/terminal/ZhitouBridge.java` 中的 `fetchWorkContext`
|
||||
- 前端 `app/src/main/assets/www/app.js` 中的接口解析和兜底逻辑
|
||||
|
||||
投放结果上报预留:
|
||||
|
||||
- `reportXingyuanDelivery`
|
||||
- `reportDeliveryResult`
|
||||
- 本地备份:`/sdcard/smart/cache/xingyuan_zhiling_delivery_backup.csv`
|
||||
- 本地 JSON 备份:`/sdcard/smart/cache/xingyuan_zhiling_delivery_backup.json`
|
||||
- 日志导出:`/sdcard/smart/logs/`
|
||||
|
||||
## 版本
|
||||
|
||||
当前应用版本:
|
||||
|
||||
```text
|
||||
versionName: 1.2.18-wal-stability-fix
|
||||
versionCode: 107
|
||||
package: com.xingyuan.zhiling
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.xingyuan.zhiling"
|
||||
android:versionCode="107"
|
||||
android:versionName="1.2.18-wal-stability-fix">
|
||||
|
||||
<uses-sdk
|
||||
android:minSdkVersion="21"
|
||||
android:targetSdkVersion="28" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".XingyuanApplication"
|
||||
android:allowBackup="false"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- 注册为桌面 Launcher,可设为默认桌面实现开机直接进入 app -->
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- 开机自启动接收器 -->
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||
<action android:name="com.android.intent.action.QUICKBOOT_POWERON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,11 @@
|
||||
Copyright (c) 2019, SeetaTech,
|
||||
Institute of Computing Technology, Chinese Academy of Sciences, Beijing, China
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1b3134" />
|
||||
<title>星元智灵</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="app-shell" aria-live="polite"></main>
|
||||
<div id="toast" class="toast" role="status"></div>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,315 @@
|
||||
#include <jni.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "seeta/FaceDetector.h"
|
||||
#include "seeta/FaceLandmarker.h"
|
||||
#include "seeta/FaceRecognizer.h"
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex g_mutex;
|
||||
std::unique_ptr<seeta::FaceDetector> g_detector;
|
||||
std::unique_ptr<seeta::FaceLandmarker> g_landmarker;
|
||||
std::unique_ptr<seeta::FaceRecognizer> g_recognizer;
|
||||
std::string g_last_error;
|
||||
|
||||
constexpr double kCenterLeftRatio = 0.25;
|
||||
constexpr double kCenterRightRatio = 0.75;
|
||||
constexpr double kCenterTopRatio = 0.18;
|
||||
constexpr double kCenterBottomRatio = 0.82;
|
||||
constexpr double kMaxEyeTiltRatio = 0.18;
|
||||
constexpr double kMaxMouthTiltRatio = 0.22;
|
||||
constexpr double kMaxNoseOffsetRatio = 0.20;
|
||||
constexpr double kMinSymmetryRatio = 0.62;
|
||||
constexpr double kMinSharpnessVariance = 45.0;
|
||||
|
||||
std::string to_string(JNIEnv *env, jstring value) {
|
||||
if (value == nullptr) return std::string();
|
||||
const char *characters = env->GetStringUTFChars(value, nullptr);
|
||||
if (characters == nullptr) return std::string();
|
||||
std::string result(characters);
|
||||
env->ReleaseStringUTFChars(value, characters);
|
||||
return result;
|
||||
}
|
||||
|
||||
unsigned char clamp_color(int value) {
|
||||
return static_cast<unsigned char>(std::max(0, std::min(255, value)));
|
||||
}
|
||||
|
||||
void nv21_to_bgr(const unsigned char *source,
|
||||
int width,
|
||||
int height,
|
||||
int rotation,
|
||||
bool mirror,
|
||||
std::vector<unsigned char> &output,
|
||||
int &output_width,
|
||||
int &output_height) {
|
||||
rotation = ((rotation % 360) + 360) % 360;
|
||||
if (rotation != 90 && rotation != 180 && rotation != 270) rotation = 0;
|
||||
output_width = (rotation == 90 || rotation == 270) ? height : width;
|
||||
output_height = (rotation == 90 || rotation == 270) ? width : height;
|
||||
output.assign(static_cast<size_t>(output_width) * output_height * 3, 0);
|
||||
const int frame_size = width * height;
|
||||
|
||||
for (int sy = 0; sy < height; ++sy) {
|
||||
const int uv_row = frame_size + (sy >> 1) * width;
|
||||
for (int sx = 0; sx < width; ++sx) {
|
||||
int y = static_cast<int>(source[sy * width + sx]) & 0xff;
|
||||
int uv_index = uv_row + (sx & ~1);
|
||||
int v = (static_cast<int>(source[uv_index]) & 0xff) - 128;
|
||||
int u = (static_cast<int>(source[uv_index + 1]) & 0xff) - 128;
|
||||
int c = std::max(0, y - 16);
|
||||
int red = (298 * c + 409 * v + 128) >> 8;
|
||||
int green = (298 * c - 100 * u - 208 * v + 128) >> 8;
|
||||
int blue = (298 * c + 516 * u + 128) >> 8;
|
||||
|
||||
int rx = sx;
|
||||
int ry = sy;
|
||||
if (rotation == 90) {
|
||||
rx = height - 1 - sy;
|
||||
ry = sx;
|
||||
} else if (rotation == 180) {
|
||||
rx = width - 1 - sx;
|
||||
ry = height - 1 - sy;
|
||||
} else if (rotation == 270) {
|
||||
rx = sy;
|
||||
ry = width - 1 - sx;
|
||||
}
|
||||
if (mirror) rx = output_width - 1 - rx;
|
||||
size_t index = (static_cast<size_t>(ry) * output_width + rx) * 3;
|
||||
output[index] = clamp_color(blue);
|
||||
output[index + 1] = clamp_color(green);
|
||||
output[index + 2] = clamp_color(red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool face_center_is_in_capture_area(const SeetaRect &face, int image_width, int image_height) {
|
||||
const double center_x = face.x + face.width * 0.5;
|
||||
const double center_y = face.y + face.height * 0.5;
|
||||
return center_x >= image_width * kCenterLeftRatio
|
||||
&& center_x <= image_width * kCenterRightRatio
|
||||
&& center_y >= image_height * kCenterTopRatio
|
||||
&& center_y <= image_height * kCenterBottomRatio;
|
||||
}
|
||||
|
||||
double point_distance(const SeetaPointF &first, const SeetaPointF &second) {
|
||||
const double dx = first.x - second.x;
|
||||
const double dy = first.y - second.y;
|
||||
return std::sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
double symmetry_ratio(double first, double second) {
|
||||
const double largest = std::max(first, second);
|
||||
return largest <= 0.0001 ? 0.0 : std::min(first, second) / largest;
|
||||
}
|
||||
|
||||
bool landmarks_are_frontal(const std::vector<SeetaPointF> &points) {
|
||||
if (points.size() < 5) return false;
|
||||
const SeetaPointF &left_eye = points[0];
|
||||
const SeetaPointF &right_eye = points[1];
|
||||
const SeetaPointF &nose = points[2];
|
||||
const SeetaPointF &left_mouth = points[3];
|
||||
const SeetaPointF &right_mouth = points[4];
|
||||
const double eye_width = std::fabs(right_eye.x - left_eye.x);
|
||||
if (eye_width < 1.0) return false;
|
||||
if (std::fabs(right_eye.y - left_eye.y) / eye_width > kMaxEyeTiltRatio) return false;
|
||||
const double mouth_width = std::fabs(right_mouth.x - left_mouth.x);
|
||||
if (mouth_width < 1.0
|
||||
|| std::fabs(right_mouth.y - left_mouth.y) / mouth_width > kMaxMouthTiltRatio) {
|
||||
return false;
|
||||
}
|
||||
const double eye_center_x = (left_eye.x + right_eye.x) * 0.5;
|
||||
const double eye_center_y = (left_eye.y + right_eye.y) * 0.5;
|
||||
const double mouth_center_x = (left_mouth.x + right_mouth.x) * 0.5;
|
||||
const double mouth_center_y = (left_mouth.y + right_mouth.y) * 0.5;
|
||||
if (std::fabs(nose.x - eye_center_x) / eye_width > kMaxNoseOffsetRatio) return false;
|
||||
if (std::fabs(mouth_center_x - nose.x) / eye_width > kMaxNoseOffsetRatio) return false;
|
||||
if (nose.y <= eye_center_y || mouth_center_y <= nose.y) return false;
|
||||
if (symmetry_ratio(point_distance(nose, left_eye), point_distance(nose, right_eye))
|
||||
< kMinSymmetryRatio) {
|
||||
return false;
|
||||
}
|
||||
return symmetry_ratio(point_distance(nose, left_mouth), point_distance(nose, right_mouth))
|
||||
>= kMinSymmetryRatio;
|
||||
}
|
||||
|
||||
int gray_at(const unsigned char *bgr, int width, int x, int y) {
|
||||
const size_t index = (static_cast<size_t>(y) * width + x) * 3;
|
||||
return (29 * bgr[index] + 150 * bgr[index + 1] + 77 * bgr[index + 2]) >> 8;
|
||||
}
|
||||
|
||||
double face_sharpness_variance(const unsigned char *bgr,
|
||||
int image_width,
|
||||
int image_height,
|
||||
const SeetaRect &face) {
|
||||
const int inset_x = std::max(2, face.width / 10);
|
||||
const int inset_y = std::max(2, face.height / 10);
|
||||
const int left = std::max(1, face.x + inset_x);
|
||||
const int top = std::max(1, face.y + inset_y);
|
||||
const int right = std::min(image_width - 2, face.x + face.width - inset_x);
|
||||
const int bottom = std::min(image_height - 2, face.y + face.height - inset_y);
|
||||
if (right <= left || bottom <= top) return 0.0;
|
||||
double sum = 0.0;
|
||||
double square_sum = 0.0;
|
||||
long count = 0;
|
||||
for (int y = top; y <= bottom; y += 2) {
|
||||
for (int x = left; x <= right; x += 2) {
|
||||
const int center = gray_at(bgr, image_width, x, y);
|
||||
const int laplacian = 4 * center
|
||||
- gray_at(bgr, image_width, x - 1, y)
|
||||
- gray_at(bgr, image_width, x + 1, y)
|
||||
- gray_at(bgr, image_width, x, y - 1)
|
||||
- gray_at(bgr, image_width, x, y + 1);
|
||||
sum += laplacian;
|
||||
square_sum += static_cast<double>(laplacian) * laplacian;
|
||||
++count;
|
||||
}
|
||||
}
|
||||
if (count <= 0) return 0.0;
|
||||
const double mean = sum / count;
|
||||
return square_sum / count - mean * mean;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_xingyuan_zhiling_SeetaFaceNative_initialize(
|
||||
JNIEnv *env, jclass, jstring detector_model, jstring landmarker_model, jstring recognizer_model) {
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
try {
|
||||
g_recognizer.reset();
|
||||
g_landmarker.reset();
|
||||
g_detector.reset();
|
||||
std::string detector_path = to_string(env, detector_model);
|
||||
std::string landmarker_path = to_string(env, landmarker_model);
|
||||
std::string recognizer_path = to_string(env, recognizer_model);
|
||||
if (detector_path.empty() || landmarker_path.empty() || recognizer_path.empty()) {
|
||||
g_last_error = "模型路径不能为空";
|
||||
return JNI_FALSE;
|
||||
}
|
||||
g_detector.reset(new seeta::FaceDetector(seeta::ModelSetting(detector_path, seeta::ModelSetting::CPU)));
|
||||
g_landmarker.reset(new seeta::FaceLandmarker(seeta::ModelSetting(landmarker_path, seeta::ModelSetting::CPU)));
|
||||
g_recognizer.reset(new seeta::FaceRecognizer(seeta::ModelSetting(recognizer_path, seeta::ModelSetting::CPU)));
|
||||
g_detector->set(seeta::FaceDetector::PROPERTY_MIN_FACE_SIZE, 80);
|
||||
g_detector->set(seeta::FaceDetector::PROPERTY_NUMBER_THREADS, 2);
|
||||
g_recognizer->set(seeta::FaceRecognizer::PROPERTY_NUMBER_THREADS, 2);
|
||||
g_last_error.clear();
|
||||
return JNI_TRUE;
|
||||
} catch (const std::exception &error) {
|
||||
g_last_error = error.what();
|
||||
} catch (...) {
|
||||
g_last_error = "SeetaFace6 初始化发生未知错误";
|
||||
}
|
||||
g_recognizer.reset();
|
||||
g_landmarker.reset();
|
||||
g_detector.reset();
|
||||
return JNI_FALSE;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||
Java_com_xingyuan_zhiling_SeetaFaceNative_extractFeature(
|
||||
JNIEnv *env, jclass, jbyteArray nv21, jint width, jint height, jint rotation, jboolean mirror) {
|
||||
if (nv21 == nullptr || width <= 0 || height <= 0) return nullptr;
|
||||
jsize length = env->GetArrayLength(nv21);
|
||||
if (length < width * height * 3 / 2) return nullptr;
|
||||
std::vector<unsigned char> source(static_cast<size_t>(length));
|
||||
env->GetByteArrayRegion(nv21, 0, length, reinterpret_cast<jbyte *>(source.data()));
|
||||
|
||||
std::vector<unsigned char> bgr;
|
||||
int image_width = 0;
|
||||
int image_height = 0;
|
||||
nv21_to_bgr(source.data(), width, height, rotation, mirror == JNI_TRUE, bgr, image_width, image_height);
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
if (!g_detector || !g_landmarker || !g_recognizer) {
|
||||
g_last_error = "识别引擎未就绪";
|
||||
return nullptr;
|
||||
}
|
||||
try {
|
||||
SeetaImageData image = {image_width, image_height, 3, bgr.data()};
|
||||
SeetaFaceInfoArray faces = g_detector->detect(image);
|
||||
if (faces.size <= 0 || faces.data == nullptr) {
|
||||
g_last_error = "画面中未检测到正面人脸";
|
||||
return nullptr;
|
||||
}
|
||||
int selected = -1;
|
||||
long largest_area = 0;
|
||||
for (int i = 0; i < faces.size; ++i) {
|
||||
if (!face_center_is_in_capture_area(faces.data[i].pos, image_width, image_height)) {
|
||||
continue;
|
||||
}
|
||||
long area = static_cast<long>(faces.data[i].pos.width) * faces.data[i].pos.height;
|
||||
if (area > largest_area) {
|
||||
largest_area = area;
|
||||
selected = i;
|
||||
}
|
||||
}
|
||||
if (selected < 0) {
|
||||
g_last_error = "请将人脸移至取景框中央";
|
||||
return nullptr;
|
||||
}
|
||||
SeetaRect face = faces.data[selected].pos;
|
||||
if (face.width < 80 || face.height < 80) {
|
||||
g_last_error = "人脸距离较远,请靠近摄像头";
|
||||
return nullptr;
|
||||
}
|
||||
int point_count = g_landmarker->number();
|
||||
if (point_count < 5) {
|
||||
g_last_error = "人脸关键点模型不可用";
|
||||
return nullptr;
|
||||
}
|
||||
std::vector<SeetaPointF> points(static_cast<size_t>(point_count));
|
||||
g_landmarker->mark(image, face, points.data());
|
||||
if (!landmarks_are_frontal(points)) {
|
||||
g_last_error = "请正对摄像头,侧脸不进行识别";
|
||||
return nullptr;
|
||||
}
|
||||
if (face_sharpness_variance(bgr.data(), image_width, image_height, face)
|
||||
< kMinSharpnessVariance) {
|
||||
g_last_error = "画面模糊,请站稳并保持静止";
|
||||
return nullptr;
|
||||
}
|
||||
int feature_size = g_recognizer->GetExtractFeatureSize();
|
||||
if (feature_size <= 0) {
|
||||
g_last_error = "人脸特征模型不可用";
|
||||
return nullptr;
|
||||
}
|
||||
std::vector<float> feature(static_cast<size_t>(feature_size));
|
||||
if (!g_recognizer->Extract(image, points.data(), feature.data())) {
|
||||
g_last_error = "人脸特征提取失败,请保持正脸和充足光线";
|
||||
return nullptr;
|
||||
}
|
||||
jfloatArray result = env->NewFloatArray(feature_size);
|
||||
if (result == nullptr) return nullptr;
|
||||
env->SetFloatArrayRegion(result, 0, feature_size, feature.data());
|
||||
g_last_error.clear();
|
||||
return result;
|
||||
} catch (const std::exception &error) {
|
||||
g_last_error = error.what();
|
||||
} catch (...) {
|
||||
g_last_error = "SeetaFace6 特征提取发生未知错误";
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jstring JNICALL
|
||||
Java_com_xingyuan_zhiling_SeetaFaceNative_lastError(JNIEnv *env, jclass) {
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
return env->NewStringUTF(g_last_error.c_str());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_xingyuan_zhiling_SeetaFaceNative_release(JNIEnv *, jclass) {
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
g_recognizer.reset();
|
||||
g_landmarker.reset();
|
||||
g_detector.reset();
|
||||
g_last_error.clear();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "Common/CStruct.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
struct SeetaFaceInfo
|
||||
{
|
||||
SeetaRect pos;
|
||||
float score;
|
||||
};
|
||||
|
||||
struct SeetaFaceInfoArray
|
||||
{
|
||||
struct SeetaFaceInfo *data;
|
||||
int size;
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,97 @@
|
||||
#ifndef INC_SEETA_C_STRUCT_H
|
||||
#define INC_SEETA_C_STRUCT_H
|
||||
|
||||
#ifdef _MSC_VER
|
||||
#ifdef SEETA_EXPORTS
|
||||
#define SEETA_API __declspec(dllexport)
|
||||
#else
|
||||
#define SEETA_API __declspec(dllimport)
|
||||
#endif
|
||||
#else
|
||||
#define SEETA_API __attribute__ ((visibility("default")))
|
||||
#endif
|
||||
|
||||
#define SEETA_C_API extern "C" SEETA_API
|
||||
|
||||
#define INCLUDED_SEETA_CSTRUCT
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
struct SeetaImageData
|
||||
{
|
||||
int width;
|
||||
int height;
|
||||
int channels;
|
||||
unsigned char *data;
|
||||
};
|
||||
|
||||
struct SeetaPoint
|
||||
{
|
||||
int x;
|
||||
int y;
|
||||
};
|
||||
|
||||
struct SeetaPointF
|
||||
{
|
||||
double x;
|
||||
double y;
|
||||
};
|
||||
|
||||
struct SeetaSize
|
||||
{
|
||||
int width;
|
||||
int height;
|
||||
};
|
||||
|
||||
struct SeetaRect
|
||||
{
|
||||
int x;
|
||||
int y;
|
||||
int width;
|
||||
int height;
|
||||
};
|
||||
|
||||
struct SeetaRegion
|
||||
{
|
||||
int top;
|
||||
int bottom;
|
||||
int left;
|
||||
int right;
|
||||
};
|
||||
|
||||
enum SeetaDevice
|
||||
{
|
||||
SEETA_DEVICE_AUTO = 0,
|
||||
SEETA_DEVICE_CPU = 1,
|
||||
SEETA_DEVICE_GPU = 2,
|
||||
};
|
||||
|
||||
struct SeetaModelSetting
|
||||
{
|
||||
enum SeetaDevice device;
|
||||
int id; // when device is GPU, id means GPU id
|
||||
const char **model; // model string terminate with nullptr
|
||||
};
|
||||
|
||||
struct SeetaBuffer
|
||||
{
|
||||
void *buffer;
|
||||
int64_t size;
|
||||
};
|
||||
|
||||
struct SeetaModelBuffer
|
||||
{
|
||||
enum SeetaDevice device;
|
||||
int id; // when device is GPU, id means GPU id
|
||||
const SeetaBuffer *buffer; // input buffers, terminate with empty buffer(buffer=nullptr, size=0)
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // INC_SEETA_C_STRUCT_H
|
||||
@@ -0,0 +1,703 @@
|
||||
#ifndef INC_SEETA_STRUCT_H
|
||||
#define INC_SEETA_STRUCT_H
|
||||
|
||||
#include "CStruct.h"
|
||||
|
||||
#include <memory>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
#include <cstring>
|
||||
#include <istream>
|
||||
#include <string>
|
||||
|
||||
#define INCLUDED_SEETA_STRUCT
|
||||
|
||||
namespace seeta
|
||||
{
|
||||
class ImageData : public SeetaImageData
|
||||
{
|
||||
public:
|
||||
using self = ImageData;
|
||||
using supper = SeetaImageData;
|
||||
|
||||
using byte = unsigned char;
|
||||
|
||||
~ImageData() = default;
|
||||
|
||||
ImageData(const supper &image)
|
||||
: ImageData(image.data, image.width, image.height, image.channels) {}
|
||||
|
||||
ImageData(int width, int height, int channels)
|
||||
: supper({ width, height, channels, nullptr })
|
||||
{
|
||||
this->m_data.reset(new byte[this->count()], std::default_delete<byte[]>());
|
||||
this->data = this->m_data.get();
|
||||
}
|
||||
|
||||
ImageData() : ImageData(0, 0, 0) {}
|
||||
|
||||
ImageData(const byte *data, int width, int height, int channels)
|
||||
: ImageData(width, height, channels)
|
||||
{
|
||||
this->copy_from(data);
|
||||
}
|
||||
|
||||
ImageData(const self&) = default;
|
||||
|
||||
ImageData &operator=(const self &) = default;
|
||||
|
||||
ImageData &operator=(const supper &other)
|
||||
{
|
||||
this->operator=(self(other));
|
||||
return *this;
|
||||
}
|
||||
|
||||
ImageData(self &&other)
|
||||
: supper({ other.width, other.height, other.channels, nullptr })
|
||||
{
|
||||
this->m_data = std::move(other.m_data);
|
||||
this->data = this->m_data.get();
|
||||
}
|
||||
|
||||
ImageData &operator=(self &&other)
|
||||
{
|
||||
this->width = other.width;
|
||||
this->height = other.height;
|
||||
this->channels = other.channels;
|
||||
this->m_data = std::move(other.m_data);
|
||||
this->data = this->m_data.get();
|
||||
return *this;
|
||||
}
|
||||
|
||||
void copy_from(const byte *data, int size = -1)
|
||||
{
|
||||
int copy_size = this->count();
|
||||
copy_size = size < 0 ? copy_size : std::min<int>(copy_size, size);
|
||||
copy(this->data, data, copy_size);
|
||||
}
|
||||
|
||||
void copy_to(byte *data, int size = -1) const
|
||||
{
|
||||
int copy_size = this->count();
|
||||
copy_size = size < 0 ? copy_size : std::min<int>(copy_size, size);
|
||||
copy(data, this->data, copy_size);
|
||||
}
|
||||
|
||||
static void copy(byte *dst, const byte *src, size_t size)
|
||||
{
|
||||
std::memcpy(dst, src, size);
|
||||
}
|
||||
|
||||
int count() const { return this->width * this->height * this->channels; }
|
||||
|
||||
ImageData clone() const
|
||||
{
|
||||
return ImageData(this->data, this->width, this->height, this->channels);
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<byte> m_data;
|
||||
};
|
||||
|
||||
class Point : public SeetaPoint
|
||||
{
|
||||
public:
|
||||
using self = Point;
|
||||
using supper = SeetaPoint;
|
||||
|
||||
Point(const supper &other) : supper(other) {}
|
||||
|
||||
Point(int x, int y) : supper({ x, y }) {}
|
||||
|
||||
Point() : Point(0, 0) {}
|
||||
};
|
||||
|
||||
class PointF : public SeetaPointF
|
||||
{
|
||||
public:
|
||||
using self = PointF;
|
||||
using supper = SeetaPointF;
|
||||
|
||||
PointF(const supper &other) : supper(other) {}
|
||||
|
||||
PointF(double x, double y) : supper({ x, y }) {}
|
||||
|
||||
PointF() : PointF(0, 0) {}
|
||||
};
|
||||
|
||||
class Size : public SeetaSize
|
||||
{
|
||||
public:
|
||||
using self = Size;
|
||||
using supper = SeetaSize;
|
||||
|
||||
Size(const supper &other) : supper(other) {}
|
||||
|
||||
Size(int width, int height) : supper({ width, height }) {}
|
||||
|
||||
Size() : Size(0, 0) {}
|
||||
};
|
||||
|
||||
class Rect : public SeetaRect
|
||||
{
|
||||
public:
|
||||
using self = Rect;
|
||||
using supper = SeetaRect;
|
||||
|
||||
Rect(const supper &other) : supper(other) {}
|
||||
|
||||
Rect(int x, int y, int width, int height) : supper({ x, y, width, height }) {}
|
||||
|
||||
Rect() : Rect(0, 0, 0, 0) {}
|
||||
|
||||
Rect(int x, int y, const Size &size) : supper({ x, y, size.width, size.height }) {}
|
||||
|
||||
Rect(const Point &top_left, int width, int height) : supper({ top_left.x, top_left.y, width, height }) {}
|
||||
|
||||
Rect(const Point &top_left, const Size &size) : supper({ top_left.x, top_left.y, size.width, size.height }) {}
|
||||
|
||||
Rect(const Point &top_left, const Point &bottom_right) : supper({ top_left.x, top_left.y, bottom_right.x - top_left.x, bottom_right.y - top_left.y }) {}
|
||||
|
||||
operator Point() const { return{ this->x, this->y }; }
|
||||
|
||||
operator Size() const { return{ this->width, this->height }; }
|
||||
};
|
||||
|
||||
class Region : public SeetaRegion
|
||||
{
|
||||
public:
|
||||
using self = Region;
|
||||
using supper = SeetaRegion;
|
||||
|
||||
Region(const supper &other) : supper(other) {}
|
||||
|
||||
Region(int top, int bottom, int left, int right) : supper({ top, bottom, left, right }) {}
|
||||
|
||||
Region() : Region(0, 0, 0, 0) {}
|
||||
|
||||
Region(const Rect &rect) : Region(rect.y, rect.y + rect.height, rect.x, rect.x + rect.width) {}
|
||||
|
||||
operator Rect() const { return{ left, top, right - left, bottom - top }; }
|
||||
};
|
||||
|
||||
class ModelSetting : public SeetaModelSetting
|
||||
{
|
||||
public:
|
||||
using self = ModelSetting;
|
||||
using supper = SeetaModelSetting;
|
||||
|
||||
enum Device
|
||||
{
|
||||
AUTO,
|
||||
CPU,
|
||||
GPU
|
||||
};
|
||||
|
||||
~ModelSetting() = default;
|
||||
|
||||
ModelSetting()
|
||||
: supper({ SEETA_DEVICE_AUTO, 0, nullptr })
|
||||
{
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelSetting(const supper &other)
|
||||
: supper({ other.device, other.id, nullptr })
|
||||
{
|
||||
if (other.model) {
|
||||
int i = 0;
|
||||
while (other.model[i])
|
||||
{
|
||||
m_model_string.emplace_back(other.model[i]);
|
||||
++i;
|
||||
}
|
||||
}
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelSetting(const self &other)
|
||||
: supper({ other.device, other.id, nullptr })
|
||||
{
|
||||
this->m_model_string = other.m_model_string;
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelSetting &operator=(const supper &other)
|
||||
{
|
||||
this->operator=(self(other));
|
||||
return *this;
|
||||
}
|
||||
|
||||
ModelSetting &operator=(const self &other)
|
||||
{
|
||||
this->device = other.device;
|
||||
this->id = other.id;
|
||||
this->m_model_string = other.m_model_string;
|
||||
this->update();
|
||||
return *this;
|
||||
}
|
||||
|
||||
ModelSetting(self &&other)
|
||||
: supper({ other.device, other.id, nullptr })
|
||||
{
|
||||
this->m_model_string = std::move(other.m_model_string);
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelSetting &operator=(self &&other)
|
||||
{
|
||||
this->device = other.device;
|
||||
this->id = other.id;
|
||||
this->m_model_string = std::move(other.m_model_string);
|
||||
this->update();
|
||||
return *this;
|
||||
}
|
||||
|
||||
ModelSetting(const std::string &model, SeetaDevice device, int id)
|
||||
: supper({ device, id, nullptr })
|
||||
{
|
||||
this->append(model);
|
||||
}
|
||||
|
||||
ModelSetting(const std::string &model, SeetaDevice device) : self(model, device, 0) {}
|
||||
|
||||
ModelSetting(const std::string &model, Device device, int id) : self(model, SeetaDevice(device), id) {}
|
||||
|
||||
ModelSetting(const std::string &model, Device device) : self(model, SeetaDevice(device)) {}
|
||||
|
||||
ModelSetting(const std::string &model) : self(model, SEETA_DEVICE_AUTO) {}
|
||||
|
||||
ModelSetting(const std::vector<std::string> &model, SeetaDevice device, int id)
|
||||
: supper({ device, id, nullptr })
|
||||
{
|
||||
this->append(model);
|
||||
}
|
||||
|
||||
ModelSetting(const std::vector<std::string> &model, SeetaDevice device) : self(model, device, 0) {}
|
||||
|
||||
ModelSetting(const std::vector<std::string> &model, Device device, int id) : self(model, SeetaDevice(device), id) {}
|
||||
|
||||
ModelSetting(const std::vector<std::string> &model, Device device) : self(model, SeetaDevice(device)) {}
|
||||
|
||||
ModelSetting(const std::vector<std::string> &model) : self(model, SEETA_DEVICE_AUTO) {}
|
||||
|
||||
ModelSetting(SeetaDevice device, int id)
|
||||
: supper({ device, id, nullptr })
|
||||
{
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelSetting(SeetaDevice device) : self(device, 0) {}
|
||||
|
||||
ModelSetting(Device device, int id) : self(SeetaDevice(device), id) {}
|
||||
|
||||
ModelSetting(Device device) : self(SeetaDevice(device)) {}
|
||||
|
||||
Device get_device() const { return Device(this->device); }
|
||||
int get_id() const { return this->id; }
|
||||
|
||||
Device set_device(Device device)
|
||||
{
|
||||
return set_device(SeetaDevice(device));
|
||||
}
|
||||
|
||||
Device set_device(SeetaDevice device)
|
||||
{
|
||||
auto old = this->device;
|
||||
this->device = device;
|
||||
return Device(old);
|
||||
}
|
||||
|
||||
int set_id(int id)
|
||||
{
|
||||
const auto old = this->id;
|
||||
this->id = id;
|
||||
return old;
|
||||
}
|
||||
|
||||
void clear()
|
||||
{
|
||||
this->m_model_string.clear();
|
||||
this->update();
|
||||
}
|
||||
|
||||
void append(const std::string &model)
|
||||
{
|
||||
this->m_model_string.push_back(model);
|
||||
this->update();
|
||||
}
|
||||
|
||||
void append(const std::vector<std::string> &model)
|
||||
{
|
||||
this->m_model_string.insert(this->m_model_string.end(), model.begin(), model.end());
|
||||
this->update();
|
||||
}
|
||||
|
||||
const std::vector<std::string> &get_model() const
|
||||
{
|
||||
return this->m_model_string;
|
||||
}
|
||||
|
||||
const std::string &get_model(size_t i) const
|
||||
{
|
||||
return this->m_model_string[i];
|
||||
}
|
||||
|
||||
size_t count() const
|
||||
{
|
||||
return this->m_model_string.size();
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<const char *> m_model;
|
||||
std::vector<std::string> m_model_string;
|
||||
|
||||
/**
|
||||
* \brief build buffer::model
|
||||
*/
|
||||
void update()
|
||||
{
|
||||
m_model.clear();
|
||||
m_model.reserve(m_model_string.size() + 1);
|
||||
for (auto &model_string : m_model_string)
|
||||
{
|
||||
m_model.push_back(model_string.c_str());
|
||||
}
|
||||
m_model.push_back(nullptr);
|
||||
this->model = m_model.data();
|
||||
}
|
||||
};
|
||||
|
||||
class Buffer : public SeetaBuffer
|
||||
{
|
||||
public:
|
||||
using self = Buffer;
|
||||
using supper = SeetaBuffer;
|
||||
|
||||
using byte = unsigned char;
|
||||
|
||||
~Buffer() = default;
|
||||
|
||||
Buffer(const supper &other) : Buffer(other.buffer, other.size) {}
|
||||
|
||||
Buffer(const self &) = default;
|
||||
|
||||
Buffer &operator=(const self &) = default;
|
||||
|
||||
Buffer &operator=(const supper &other)
|
||||
{
|
||||
this->operator=(self(other));
|
||||
return *this;
|
||||
}
|
||||
|
||||
Buffer(self &&other)
|
||||
: supper({ other.buffer, other.size }), m_buffer(std::move(other.m_buffer)), m_size(other.m_size) {}
|
||||
|
||||
Buffer &operator=(self &&other)
|
||||
{
|
||||
this->buffer = other.buffer;
|
||||
this->size = other.size;
|
||||
this->m_buffer = std::move(other.m_buffer);
|
||||
this->m_size = other.m_size;
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* \brief contruct cpp-style-buffer from c-style-buffer, optional borrow or copy
|
||||
* \param other c-style-buffer
|
||||
* \param borrow if borrow or copy buffer
|
||||
*/
|
||||
Buffer(const supper &other, bool borrow)
|
||||
: supper({ nullptr, 0 })
|
||||
{
|
||||
if (borrow) this->borrow(other.buffer, other.size);
|
||||
else this->rebind(other.buffer, other.size);
|
||||
}
|
||||
|
||||
Buffer(const void *buffer, int64_t size)
|
||||
: supper({ nullptr, 0 })
|
||||
{
|
||||
this->m_size = size;
|
||||
if (this->m_size)
|
||||
{
|
||||
this->m_buffer.reset(new byte[size_t(this->m_size)], std::default_delete<byte[]>());
|
||||
}
|
||||
this->buffer = this->m_buffer.get();
|
||||
this->size = this->m_size;
|
||||
if (buffer != nullptr)
|
||||
{
|
||||
this->copy_from(buffer, size);
|
||||
}
|
||||
}
|
||||
|
||||
explicit Buffer(int64_t size) : Buffer(nullptr, size) {}
|
||||
|
||||
Buffer() : Buffer(nullptr, 0) {}
|
||||
|
||||
Buffer(std::istream &in, int64_t size = -1)
|
||||
: supper({ nullptr, 0 })
|
||||
{
|
||||
if (size < 0)
|
||||
{
|
||||
const auto cur = in.tellg();
|
||||
in.seekg(0, std::ios::end);
|
||||
const auto end = in.tellg();
|
||||
size = int64_t(end - cur);
|
||||
in.seekg(-size, std::ios::end);
|
||||
}
|
||||
|
||||
this->m_size = size;
|
||||
this->m_buffer.reset(new byte[size_t(this->m_size)], std::default_delete<byte[]>());
|
||||
this->buffer = this->m_buffer.get();
|
||||
this->size = this->m_size;
|
||||
|
||||
in.read(reinterpret_cast<char*>(this->buffer), this->size);
|
||||
}
|
||||
|
||||
void copy_from(const void *data, int64_t size = -1)
|
||||
{
|
||||
auto copy_size = this->m_size;
|
||||
copy_size = size < 0 ? copy_size : std::min<int64_t>(copy_size, size);
|
||||
copy(this->buffer, data, size_t(copy_size));
|
||||
}
|
||||
|
||||
void copy_to(byte *data, int64_t size = -1) const
|
||||
{
|
||||
auto copy_size = this->m_size;
|
||||
copy_size = size < 0 ? copy_size : std::min<int64_t>(copy_size, size);
|
||||
copy(data, this->buffer, size_t(copy_size));
|
||||
}
|
||||
|
||||
static void copy(void *dst, const void *src, size_t size)
|
||||
{
|
||||
if (dst == nullptr || src == nullptr) return;
|
||||
std::memcpy(dst, src, size);
|
||||
}
|
||||
|
||||
Buffer clone() const
|
||||
{
|
||||
return self(this->buffer, this->size);
|
||||
}
|
||||
|
||||
void rebind(const void *buffer, int64_t size)
|
||||
{
|
||||
if (size < 0) size = 0;
|
||||
if (size > this->m_size)
|
||||
{
|
||||
this->m_buffer.reset(new byte[size_t(size)], std::default_delete<byte[]>());
|
||||
}
|
||||
this->m_size = size;
|
||||
this->buffer = this->m_buffer.get();
|
||||
this->size = this->m_size;
|
||||
this->copy_from(buffer, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* \brief borrow c-style buffer
|
||||
* \param buffer pointert to buffer
|
||||
* \param size size of buffer
|
||||
*/
|
||||
void borrow(void *buffer, int64_t size)
|
||||
{
|
||||
this->m_size = 0;
|
||||
this->m_buffer.reset();
|
||||
this->buffer = buffer;
|
||||
this->size = size;
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<byte> m_buffer;
|
||||
int64_t m_size = 0;
|
||||
};
|
||||
|
||||
class ModelBuffer : SeetaModelBuffer
|
||||
{
|
||||
public:
|
||||
using self = ModelBuffer;
|
||||
using supper = SeetaModelBuffer;
|
||||
|
||||
enum Device
|
||||
{
|
||||
AUTO = SEETA_DEVICE_AUTO,
|
||||
CPU = SEETA_DEVICE_CPU,
|
||||
GPU = SEETA_DEVICE_GPU,
|
||||
};
|
||||
|
||||
~ModelBuffer() = default;
|
||||
|
||||
ModelBuffer()
|
||||
: supper({ SEETA_DEVICE_AUTO, 0, nullptr })
|
||||
{
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelBuffer(const supper &other)
|
||||
: supper({ other.device, other.id, nullptr })
|
||||
{
|
||||
if (other.buffer) {
|
||||
int i = 0;
|
||||
while (other.buffer[i].buffer && other.buffer[i].size)
|
||||
{
|
||||
m_model_buffer.emplace_back(other.buffer[i]);
|
||||
++i;
|
||||
}
|
||||
}
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelBuffer(const self &other)
|
||||
: supper({ other.device, other.id, nullptr })
|
||||
{
|
||||
this->m_model_buffer = other.m_model_buffer;
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelBuffer &operator=(const supper &other)
|
||||
{
|
||||
this->operator=(self(other));
|
||||
return *this;
|
||||
}
|
||||
|
||||
ModelBuffer &operator=(const self &other)
|
||||
{
|
||||
this->device = other.device;
|
||||
this->id = other.id;
|
||||
this->m_model_buffer = other.m_model_buffer;
|
||||
this->update();
|
||||
return *this;
|
||||
}
|
||||
|
||||
ModelBuffer(self &&other)
|
||||
: supper({ other.device, other.id, nullptr })
|
||||
{
|
||||
this->m_model_buffer = std::move(other.m_model_buffer);
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelBuffer &operator=(self &&other)
|
||||
{
|
||||
this->device = other.device;
|
||||
this->id = other.id;
|
||||
this->m_model_buffer = std::move(other.m_model_buffer);
|
||||
this->update();
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
ModelBuffer(const seeta::Buffer &buffer, SeetaDevice device, int id)
|
||||
: supper({ device, id, nullptr })
|
||||
{
|
||||
this->append(buffer);
|
||||
}
|
||||
|
||||
ModelBuffer(const seeta::Buffer &buffer, SeetaDevice device) : self(buffer, device, 0) {}
|
||||
|
||||
ModelBuffer(const seeta::Buffer &buffer, Device device, int id) : self(buffer, SeetaDevice(device), id) {}
|
||||
|
||||
ModelBuffer(const seeta::Buffer &buffer, Device device) : self(buffer, SeetaDevice(device)) {}
|
||||
|
||||
ModelBuffer(const seeta::Buffer &buffer) : self(buffer, SEETA_DEVICE_AUTO) {}
|
||||
|
||||
ModelBuffer(const std::vector<seeta::Buffer> &buffer, SeetaDevice device, int id)
|
||||
: supper({ device, id, nullptr })
|
||||
{
|
||||
this->append(buffer);
|
||||
}
|
||||
|
||||
ModelBuffer(const std::vector<seeta::Buffer> &buffer, SeetaDevice device) : self(buffer, device, 0) {}
|
||||
|
||||
ModelBuffer(const std::vector<seeta::Buffer> &buffer, Device device, int id) : self(buffer, SeetaDevice(device), id) {}
|
||||
|
||||
ModelBuffer(const std::vector<seeta::Buffer> &buffer, Device device) : self(buffer, SeetaDevice(device)) {}
|
||||
|
||||
ModelBuffer(const std::vector<seeta::Buffer> &buffer) : self(buffer, SEETA_DEVICE_AUTO) {}
|
||||
|
||||
ModelBuffer(SeetaDevice device, int id)
|
||||
: supper({ device, id, nullptr })
|
||||
{
|
||||
this->update();
|
||||
}
|
||||
|
||||
ModelBuffer(SeetaDevice device) : self(device, 0) {}
|
||||
|
||||
ModelBuffer(Device device, int id) : self(SeetaDevice(device), id) {}
|
||||
|
||||
ModelBuffer(Device device) : self(SeetaDevice(device)) {}
|
||||
|
||||
Device get_device() const { return Device(this->device); }
|
||||
int get_id() const { return this->id; }
|
||||
|
||||
Device set_device(Device device)
|
||||
{
|
||||
return set_device(SeetaDevice(device));
|
||||
}
|
||||
|
||||
Device set_device(SeetaDevice device)
|
||||
{
|
||||
auto old = this->device;
|
||||
this->device = device;
|
||||
return Device(old);
|
||||
}
|
||||
|
||||
int set_id(int id)
|
||||
{
|
||||
const auto old = this->id;
|
||||
this->id = id;
|
||||
return old;
|
||||
}
|
||||
|
||||
void clear()
|
||||
{
|
||||
this->m_model_buffer.clear();
|
||||
this->update();
|
||||
}
|
||||
|
||||
void append(const seeta::Buffer &buffer)
|
||||
{
|
||||
this->m_model_buffer.push_back(buffer);
|
||||
this->update();
|
||||
}
|
||||
|
||||
void append(const std::vector<seeta::Buffer> &model)
|
||||
{
|
||||
this->m_model_buffer.insert(this->m_model_buffer.end(), model.begin(), model.end());
|
||||
this->update();
|
||||
}
|
||||
|
||||
const std::vector<seeta::Buffer> &get_buffer() const
|
||||
{
|
||||
return this->m_model_buffer;
|
||||
}
|
||||
|
||||
const seeta::Buffer &get_buffer(size_t i) const
|
||||
{
|
||||
return this->m_model_buffer[i];
|
||||
}
|
||||
|
||||
size_t count() const
|
||||
{
|
||||
return this->m_model_buffer.size();
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<SeetaBuffer> m_buffer;
|
||||
std::vector<seeta::Buffer> m_model_buffer;
|
||||
|
||||
/**
|
||||
* \brief build supper::buffer
|
||||
*/
|
||||
void update()
|
||||
{
|
||||
this->m_buffer.clear();
|
||||
this->m_buffer.reserve(m_model_buffer.size() + 1);
|
||||
for (auto &model_buffer : m_model_buffer)
|
||||
{
|
||||
this->m_buffer.push_back(model_buffer);
|
||||
}
|
||||
this->m_buffer.push_back({ nullptr, 0 }); // terminate with empty buffer
|
||||
this->buffer = this->m_buffer.data();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Created by kier on 19-4-24.
|
||||
//
|
||||
|
||||
#ifndef INC_SEETA_FACEDETECTOR_H
|
||||
#define INC_SEETA_FACEDETECTOR_H
|
||||
|
||||
#include "seeta/Common/Struct.h"
|
||||
#include "seeta/CFaceInfo.h"
|
||||
#include "seeta/SeetaFaceDetectorConfig.h"
|
||||
|
||||
namespace seeta {
|
||||
namespace SEETA_FACE_DETECTOR_NAMESPACE_VERSION {
|
||||
class FaceDetector {
|
||||
public:
|
||||
using self = FaceDetector;
|
||||
|
||||
enum Property {
|
||||
PROPERTY_MIN_FACE_SIZE,
|
||||
PROPERTY_THRESHOLD,
|
||||
PROPERTY_MAX_IMAGE_WIDTH,
|
||||
PROPERTY_MAX_IMAGE_HEIGHT,
|
||||
PROPERTY_NUMBER_THREADS,
|
||||
|
||||
PROPERTY_ARM_CPU_MODE = 0x101,
|
||||
};
|
||||
|
||||
SEETA_API explicit FaceDetector(const SeetaModelSetting &setting);
|
||||
|
||||
SEETA_API ~FaceDetector();
|
||||
|
||||
SEETA_API explicit FaceDetector(const self *other);
|
||||
|
||||
SEETA_API SeetaFaceInfoArray detect(const SeetaImageData &image) const;
|
||||
|
||||
SEETA_API void set(Property property, double value);
|
||||
|
||||
SEETA_API double get(Property property) const;
|
||||
|
||||
private:
|
||||
FaceDetector(const FaceDetector &) = delete;
|
||||
|
||||
const FaceDetector &operator=(const FaceDetector &) = delete;
|
||||
|
||||
private:
|
||||
class Implement;
|
||||
|
||||
Implement *m_impl;
|
||||
};
|
||||
}
|
||||
using namespace SEETA_FACE_DETECTOR_NAMESPACE_VERSION;
|
||||
}
|
||||
|
||||
#endif //INC_SEETA_FACEDETECTOR_H
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// Created by kier on 19-7-16.
|
||||
//
|
||||
|
||||
#ifndef SEETA_FACELANDMARKER_FACELANDMARKER_H
|
||||
#define SEETA_FACELANDMARKER_FACELANDMARKER_H
|
||||
|
||||
#include "Common/Struct.h"
|
||||
#include "seeta/SeetaFaceLandmarkerConfig.h"
|
||||
|
||||
namespace seeta {
|
||||
namespace SEETA_FACE_LANDMARKER_NAMESPACE_VERSION {
|
||||
class FaceLandmarker {
|
||||
public:
|
||||
using self = FaceLandmarker;
|
||||
|
||||
SEETA_API explicit FaceLandmarker(const SeetaModelSetting &setting);
|
||||
SEETA_API ~FaceLandmarker();
|
||||
|
||||
SEETA_API FaceLandmarker(const self *other);
|
||||
|
||||
SEETA_API int number() const;
|
||||
|
||||
SEETA_API void mark(const SeetaImageData &image, const SeetaRect &face, SeetaPointF *points) const;
|
||||
|
||||
SEETA_API void mark(const SeetaImageData &image, const SeetaRect &face, SeetaPointF *points, int32_t *mask) const;
|
||||
|
||||
std::vector<SeetaPointF> mark(const SeetaImageData &image, const SeetaRect &face) const {
|
||||
std::vector<SeetaPointF> points(this->number());
|
||||
mark(image, face, points.data());
|
||||
return points;
|
||||
}
|
||||
|
||||
class PointWithMask {
|
||||
public:
|
||||
SeetaPointF point;
|
||||
bool mask;
|
||||
};
|
||||
|
||||
std::vector<PointWithMask> mark_v2(const SeetaImageData &image, const SeetaRect &face) const {
|
||||
std::vector<SeetaPointF> points(this->number());
|
||||
std::vector<int32_t> masks(this->number());
|
||||
mark(image, face, points.data(), masks.data());
|
||||
std::vector<PointWithMask> point_with_masks(this->number());
|
||||
for (int i = 0; i < this->number(); ++i) {
|
||||
point_with_masks[i].point = points[i];
|
||||
point_with_masks[i].mask = masks[i];
|
||||
}
|
||||
return point_with_masks;
|
||||
}
|
||||
|
||||
private:
|
||||
FaceLandmarker(const FaceLandmarker &) = delete;
|
||||
const FaceLandmarker &operator=(const FaceLandmarker&) = delete;
|
||||
|
||||
private:
|
||||
class Implement;
|
||||
Implement *m_impl;
|
||||
};
|
||||
}
|
||||
using namespace SEETA_FACE_LANDMARKER_NAMESPACE_VERSION;
|
||||
}
|
||||
|
||||
#endif //SEETA_FACELANDMARKER_FACELANDMARKER_H
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// Created by kier on 19-4-24.
|
||||
//
|
||||
|
||||
#ifndef SEETA_FACERECOGNIZER_FACERECOGNIZER_H
|
||||
#define SEETA_FACERECOGNIZER_FACERECOGNIZER_H
|
||||
|
||||
#include "Common/Struct.h"
|
||||
#include "seeta/SeetaFaceRecognizerConfig.h"
|
||||
|
||||
// #define SEETA_FACE_RECOGNIZER_MAJOR_VERSION 6
|
||||
// #define SEETA_FACE_RECOGNIZER_MINOR_VERSION 0
|
||||
// #define SEETA_FACE_RECOGNIZER_SINOR_VERSION 0
|
||||
|
||||
namespace seeta {
|
||||
namespace SEETA_FACE_RECOGNIZE_NAMESPACE_VERSION {
|
||||
class FaceRecognizer {
|
||||
public:
|
||||
using self = FaceRecognizer;
|
||||
enum Property {
|
||||
PROPERTY_NUMBER_THREADS = 4,
|
||||
PROPERTY_ARM_CPU_MODE = 5
|
||||
};
|
||||
|
||||
SEETA_API explicit FaceRecognizer(const SeetaModelSetting &setting);
|
||||
SEETA_API ~FaceRecognizer();
|
||||
|
||||
SEETA_API FaceRecognizer(const self *other);
|
||||
|
||||
SEETA_API static int GetCropFaceWidth();
|
||||
SEETA_API static int GetCropFaceHeight();
|
||||
SEETA_API static int GetCropFaceChannels();
|
||||
|
||||
SEETA_API static bool CropFace(const SeetaImageData &image, const SeetaPointF *points, SeetaImageData &face);
|
||||
|
||||
SEETA_API int GetExtractFeatureSize() const;
|
||||
|
||||
SEETA_API bool ExtractCroppedFace(const SeetaImageData &image, float *features) const;
|
||||
|
||||
SEETA_API bool Extract(const SeetaImageData &image, const SeetaPointF *points, float *features) const;
|
||||
|
||||
SEETA_API float CalculateSimilarity(const float *features1, const float *features2) const;
|
||||
|
||||
static seeta::ImageData CropFace(const SeetaImageData &image, const SeetaPointF *points) {
|
||||
seeta::ImageData face(GetCropFaceWidth(), GetCropFaceHeight(), GetCropFaceChannels());
|
||||
CropFace(image, points, face);
|
||||
return face;
|
||||
}
|
||||
|
||||
SEETA_API int GetCropFaceWidthV2() const;
|
||||
|
||||
SEETA_API int GetCropFaceHeightV2() const;
|
||||
|
||||
SEETA_API int GetCropFaceChannelsV2() const;
|
||||
|
||||
SEETA_API bool CropFaceV2(const SeetaImageData &image, const SeetaPointF *points, SeetaImageData &face);
|
||||
|
||||
seeta::ImageData CropFaceV2(const SeetaImageData &image, const SeetaPointF *points) {
|
||||
seeta::ImageData face(GetCropFaceWidthV2(), GetCropFaceHeightV2(), GetCropFaceChannelsV2());
|
||||
CropFaceV2(image, points, face);
|
||||
return face;
|
||||
}
|
||||
|
||||
SEETA_API void set(Property property, double value);
|
||||
|
||||
SEETA_API double get(Property property) const;
|
||||
|
||||
|
||||
private:
|
||||
FaceRecognizer(const FaceRecognizer &) = delete;
|
||||
const FaceRecognizer &operator=(const FaceRecognizer&) = delete;
|
||||
|
||||
private:
|
||||
class Implement;
|
||||
Implement *m_impl;
|
||||
};
|
||||
}
|
||||
using namespace SEETA_FACE_RECOGNIZE_NAMESPACE_VERSION;
|
||||
}
|
||||
|
||||
#endif //SEETA_FACERECOGNIZER_FACERECOGNIZER_H
|
||||
@@ -0,0 +1,12 @@
|
||||
#ifndef _INC_SEETA_FACE_DETECTOR_V6_
|
||||
#define _INC_SEETA_FACE_DETECTOR_V6_
|
||||
|
||||
#define SEETA_FACE_DETECTOR_MAJOR_VERSION 6
|
||||
#define SEETA_FACE_DETECTOR_MINOR_VERSION 0
|
||||
#define SEETA_FACE_DETECTOR_SINOR_VERSION 0
|
||||
|
||||
#define SEETA_FACE_DETECTOR_LIBRARY_NAME "SeetaFaceDetector"
|
||||
|
||||
#define SEETA_FACE_DETECTOR_NAMESPACE_VERSION v6
|
||||
|
||||
#endif // _INC_SEETA_FACE_DETECTOR_V6_
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#define SEETA_FACE_LANDMARKER_MAJOR_VERSION 6
|
||||
#define SEETA_FACE_LANDMARKER_MINOR_VERSION 0
|
||||
#define SEETA_FACE_LANDMARKER_SINOR_VERSION 0
|
||||
|
||||
#define SEETA_FACE_LANDMARKER_LIBRARY_NAME "SeetaFaceLandmarker"
|
||||
|
||||
#define SEETA_FACE_LANDMARKER_NAMESPACE_VERSION v6
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#define SEETA_FACE_RECOGNIZER_MAJOR_VERSION 6
|
||||
#define SEETA_FACE_RECOGNIZER_MINOR_VERSION 1
|
||||
#define SEETA_FACE_RECOGNIZER_SINOR_VERSION 0
|
||||
|
||||
#define SEETA_FACE_RECOGNIZE_LIBRARY_NAME "SeetaFaceRecognizer"
|
||||
|
||||
#define SEETA_FACE_RECOGNIZE_NAMESPACE_VERSION v6
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2009 Cedric Priscal
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
*/
|
||||
|
||||
package android_serialport_api;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
public class SerialPort {
|
||||
private static final String TAG = "SerialPort";
|
||||
|
||||
/*
|
||||
* Do not remove or rename this field: it is used by native close().
|
||||
*/
|
||||
private FileDescriptor mFd;
|
||||
private FileInputStream mFileInputStream;
|
||||
private FileOutputStream mFileOutputStream;
|
||||
|
||||
public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException {
|
||||
if (!device.canRead() || !device.canWrite()) {
|
||||
try {
|
||||
Process su = Runtime.getRuntime().exec("/system/bin/su");
|
||||
String cmd = "chmod 666 " + device.getAbsolutePath() + "\nexit\n";
|
||||
su.getOutputStream().write(cmd.getBytes());
|
||||
if ((su.waitFor() != 0) || !device.canRead() || !device.canWrite()) {
|
||||
throw new SecurityException();
|
||||
}
|
||||
} catch (Exception error) {
|
||||
throw new SecurityException(error);
|
||||
}
|
||||
}
|
||||
|
||||
mFd = open(device.getAbsolutePath(), baudrate, flags);
|
||||
if (mFd == null) {
|
||||
Log.e(TAG, "native open returns null");
|
||||
throw new IOException("native open returns null");
|
||||
}
|
||||
mFileInputStream = new FileInputStream(mFd);
|
||||
mFileOutputStream = new FileOutputStream(mFd);
|
||||
}
|
||||
|
||||
public InputStream getInputStream() {
|
||||
return mFileInputStream;
|
||||
}
|
||||
|
||||
public OutputStream getOutputStream() {
|
||||
return mFileOutputStream;
|
||||
}
|
||||
|
||||
private native static FileDescriptor open(String path, int baudrate, int flags);
|
||||
|
||||
public native void close();
|
||||
|
||||
static {
|
||||
System.loadLibrary("serial_port");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
/**
|
||||
* 开机自启动接收器。
|
||||
*
|
||||
* 监听 android.intent.action.BOOT_COMPLETED 广播,
|
||||
* 设备开机完成后自动拉起 MainActivity。
|
||||
*
|
||||
* 注意:该广播只有在以下条件同时满足时才会被接收:
|
||||
* 1. 应用已安装(非刚安装后首次,需经历过一次重启或手动启动过 app);
|
||||
* 2. 拥有 RECEIVE_BOOT_COMPLETED 权限。
|
||||
*
|
||||
* 对于嵌入式设备,建议同时将 MainActivity 设为默认桌面(HOME),
|
||||
* 这样即使 BootReceiver 因系统策略延迟,也能在用户按 Home 键时回到 app。
|
||||
*/
|
||||
public class BootReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String action = intent.getAction();
|
||||
if (action == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 开机完成
|
||||
if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
|
||||
launchMainActivity(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// 部分定制系统在替换系统包或更新后会发送这些广播
|
||||
if ("android.intent.action.QUICKBOOT_POWERON".equals(action)
|
||||
|| "com.android.intent.action.QUICKBOOT_POWERON".equals(action)) {
|
||||
launchMainActivity(context);
|
||||
}
|
||||
}
|
||||
|
||||
private void launchMainActivity(Context context) {
|
||||
Intent launchIntent = new Intent(context, MainActivity.class);
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(launchIntent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
final class CrashRecovery {
|
||||
private static final String PREFS = "xingyuan_crash_recovery";
|
||||
private static final String KEY_LAST_CRASH_AT = "last_crash_at";
|
||||
private static final String KEY_CRASH_COUNT = "crash_count";
|
||||
private static final String KEY_NOTICE = "notice";
|
||||
private static final String KEY_STARTUP_CRASH = "startup_crash";
|
||||
private static final String KEY_RETRY_GRANTED = "retry_granted";
|
||||
private static final long CRASH_WINDOW_MS = 60_000L;
|
||||
private static final int MAX_RESTARTS_IN_WINDOW = 3;
|
||||
private static final long RESTART_DELAY_MS = 1_500L;
|
||||
|
||||
private CrashRecovery() {
|
||||
}
|
||||
|
||||
static void recordAndSchedule(Context context, Throwable error) {
|
||||
Context app = context.getApplicationContext();
|
||||
SharedPreferences preferences = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
long now = System.currentTimeMillis();
|
||||
long lastCrashAt = preferences.getLong(KEY_LAST_CRASH_AT, 0L);
|
||||
int count = now - lastCrashAt <= CRASH_WINDOW_MS
|
||||
? preferences.getInt(KEY_CRASH_COUNT, 0) + 1
|
||||
: 1;
|
||||
String summary = summarize(error);
|
||||
preferences.edit()
|
||||
.putLong(KEY_LAST_CRASH_AT, now)
|
||||
.putInt(KEY_CRASH_COUNT, count)
|
||||
.putString(KEY_NOTICE, summary)
|
||||
.putBoolean(KEY_STARTUP_CRASH, isStartupCrash(error))
|
||||
.putBoolean(KEY_RETRY_GRANTED, false)
|
||||
.commit();
|
||||
writeCrashLog(app, now, error);
|
||||
if (count <= MAX_RESTARTS_IN_WINDOW) {
|
||||
scheduleRestart(app);
|
||||
} else {
|
||||
preferences.edit().putString(KEY_NOTICE,
|
||||
summary + ";一分钟内已连续异常 " + count + " 次,已停止自动重启以避免循环崩溃").commit();
|
||||
}
|
||||
}
|
||||
|
||||
static void recordRendererCrashAndRestart(Context context, boolean didCrash) {
|
||||
RuntimeException error = new RuntimeException(
|
||||
didCrash ? "WebView 渲染进程崩溃" : "WebView 渲染进程被系统回收");
|
||||
recordAndSchedule(context, error);
|
||||
}
|
||||
|
||||
static String consumeRecoveryNotice(Context context) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
String notice = preferences.getString(KEY_NOTICE, "");
|
||||
if (notice == null || notice.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
preferences.edit().remove(KEY_NOTICE).apply();
|
||||
return "应用已从异常退出中自动恢复:" + notice;
|
||||
}
|
||||
|
||||
static boolean shouldEnterStartupRecovery(Context context) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
if (preferences.getBoolean(KEY_RETRY_GRANTED, false)) {
|
||||
preferences.edit().putBoolean(KEY_RETRY_GRANTED, false).commit();
|
||||
return false;
|
||||
}
|
||||
boolean startupCrash = preferences.getBoolean(KEY_STARTUP_CRASH, false);
|
||||
long lastCrashAt = preferences.getLong(KEY_LAST_CRASH_AT, 0L);
|
||||
int count = preferences.getInt(KEY_CRASH_COUNT, 0);
|
||||
boolean repeatedRecentCrash = count >= 2
|
||||
&& System.currentTimeMillis() - lastCrashAt <= CRASH_WINDOW_MS;
|
||||
return startupCrash || repeatedRecentCrash;
|
||||
}
|
||||
|
||||
static boolean canAutoRetryStartup(Context context) {
|
||||
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getInt(KEY_CRASH_COUNT, 0) <= MAX_RESTARTS_IN_WINDOW;
|
||||
}
|
||||
|
||||
static String peekRecoveryNotice(Context context) {
|
||||
String notice = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_NOTICE, "");
|
||||
return notice == null || notice.length() == 0 ? "未知异常" : notice;
|
||||
}
|
||||
|
||||
static void clearStartupRecoveryForRetry(Context context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_STARTUP_CRASH, false)
|
||||
.putBoolean(KEY_RETRY_GRANTED, true)
|
||||
.commit();
|
||||
}
|
||||
|
||||
static void markLaunchSuccessful(Context context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_LAST_CRASH_AT, 0L)
|
||||
.putInt(KEY_CRASH_COUNT, 0)
|
||||
.putBoolean(KEY_STARTUP_CRASH, false)
|
||||
.putBoolean(KEY_RETRY_GRANTED, false)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private static void scheduleRestart(Context context) {
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
intent.setAction("com.xingyuan.zhiling.action.CRASH_RECOVERY");
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
| Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
int flags = PendingIntent.FLAG_CANCEL_CURRENT;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
flags |= PendingIntent.FLAG_IMMUTABLE;
|
||||
}
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 7311, intent, flags);
|
||||
AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
|
||||
if (alarm == null) {
|
||||
return;
|
||||
}
|
||||
long triggerAt = System.currentTimeMillis() + RESTART_DELAY_MS;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
alarm.setExact(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent);
|
||||
} else {
|
||||
alarm.set(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent);
|
||||
}
|
||||
}
|
||||
|
||||
private static String summarize(Throwable error) {
|
||||
if (error == null) {
|
||||
return "未知异常";
|
||||
}
|
||||
String type = error.getClass().getSimpleName();
|
||||
String message = error.getMessage();
|
||||
if (message == null || message.trim().length() == 0) {
|
||||
return type;
|
||||
}
|
||||
String normalized = message.replace('\n', ' ').replace('\r', ' ').trim();
|
||||
if (normalized.length() > 180) {
|
||||
normalized = normalized.substring(0, 180);
|
||||
}
|
||||
return type + " - " + normalized;
|
||||
}
|
||||
|
||||
private static boolean isStartupCrash(Throwable error) {
|
||||
Throwable current = error;
|
||||
while (current != null) {
|
||||
StackTraceElement[] stack = current.getStackTrace();
|
||||
if (stack != null) {
|
||||
for (StackTraceElement element : stack) {
|
||||
if (MainActivity.class.getName().equals(element.getClassName())
|
||||
&& "onCreate".equals(element.getMethodName())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void writeCrashLog(Context context, long timestamp, Throwable error) {
|
||||
File directory = new File(context.getFilesDir(), "crash_logs");
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
return;
|
||||
}
|
||||
File target = new File(directory, "last-crash.log");
|
||||
FileWriter writer = null;
|
||||
try {
|
||||
writer = new FileWriter(target, false);
|
||||
writer.write(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.CHINA)
|
||||
.format(new Date(timestamp)));
|
||||
writer.write('\n');
|
||||
StringWriter stack = new StringWriter();
|
||||
PrintWriter printer = new PrintWriter(stack);
|
||||
if (error != null) {
|
||||
error.printStackTrace(printer);
|
||||
}
|
||||
printer.flush();
|
||||
writer.write(stack.toString());
|
||||
writer.flush();
|
||||
} catch (Throwable ignored) {
|
||||
} finally {
|
||||
if (writer != null) {
|
||||
try {
|
||||
writer.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AtomicFile;
|
||||
import android.util.Base64;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class FaceFeatureStore {
|
||||
static final class Match {
|
||||
final String faceId;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
final float similarity;
|
||||
|
||||
Match(String faceId, String employeeNo, String name, float similarity) {
|
||||
this.faceId = faceId;
|
||||
this.employeeNo = employeeNo;
|
||||
this.name = name;
|
||||
this.similarity = similarity;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Record {
|
||||
final String faceId;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
final float[] feature;
|
||||
|
||||
Record(String faceId, String employeeNo, String name, float[] feature) {
|
||||
this.faceId = faceId;
|
||||
this.employeeNo = employeeNo;
|
||||
this.name = name;
|
||||
this.feature = feature;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Charset UTF8 = Charset.forName("UTF-8");
|
||||
private final AtomicFile atomicStore;
|
||||
private final List<Record> records = new ArrayList<Record>();
|
||||
|
||||
FaceFeatureStore(Context context) {
|
||||
atomicStore = new AtomicFile(new File(context.getFilesDir(), "xingyuan_face_features.json"));
|
||||
load();
|
||||
}
|
||||
|
||||
synchronized int size() {
|
||||
return records.size();
|
||||
}
|
||||
|
||||
synchronized JSONArray listPeople() {
|
||||
JSONArray people = new JSONArray();
|
||||
for (Record record : records) {
|
||||
try {
|
||||
JSONObject person = new JSONObject();
|
||||
person.put("faceId", record.faceId);
|
||||
person.put("employeeNo", record.employeeNo);
|
||||
person.put("name", record.name);
|
||||
person.put("registered", true);
|
||||
people.put(person);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return people;
|
||||
}
|
||||
|
||||
synchronized boolean remove(String faceId) throws Exception {
|
||||
String normalizedId = safe(faceId);
|
||||
List<Record> previous = new ArrayList<Record>(records);
|
||||
boolean removed = false;
|
||||
for (int i = records.size() - 1; i >= 0; i -= 1) {
|
||||
if (normalizedId.equals(records.get(i).faceId)) {
|
||||
records.remove(i);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
try {
|
||||
save();
|
||||
} catch (Exception error) {
|
||||
// 磁盘写入失败时恢复内存快照,避免运行中的名单与磁盘人脸库不一致。
|
||||
records.clear();
|
||||
records.addAll(previous);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
synchronized void put(String faceId, String employeeNo, String name, float[] feature) throws Exception {
|
||||
if (faceId == null || faceId.trim().length() == 0 || feature == null || feature.length == 0) {
|
||||
throw new IllegalArgumentException("人脸编号和特征不能为空");
|
||||
}
|
||||
String normalizedId = faceId.trim();
|
||||
List<Record> previous = new ArrayList<Record>(records);
|
||||
for (int i = records.size() - 1; i >= 0; i -= 1) {
|
||||
if (normalizedId.equals(records.get(i).faceId)) {
|
||||
records.remove(i);
|
||||
}
|
||||
}
|
||||
records.add(new Record(normalizedId, safe(employeeNo), safe(name), feature.clone()));
|
||||
try {
|
||||
save();
|
||||
} catch (Exception error) {
|
||||
records.clear();
|
||||
records.addAll(previous);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
synchronized Match findBest(float[] probe, float threshold) {
|
||||
if (probe == null || probe.length == 0) {
|
||||
return null;
|
||||
}
|
||||
Record best = null;
|
||||
float bestScore = -1f;
|
||||
for (Record record : records) {
|
||||
if (record.feature.length != probe.length) {
|
||||
continue;
|
||||
}
|
||||
float score = cosine(record.feature, probe);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = record;
|
||||
}
|
||||
}
|
||||
if (best == null || bestScore < threshold) {
|
||||
return null;
|
||||
}
|
||||
return new Match(best.faceId, best.employeeNo, best.name, bestScore);
|
||||
}
|
||||
|
||||
private void load() {
|
||||
synchronized (this) {
|
||||
records.clear();
|
||||
if (!atomicStore.getBaseFile().exists()) {
|
||||
return;
|
||||
}
|
||||
StringBuilder text = new StringBuilder();
|
||||
try {
|
||||
// AtomicFile 会在上次写入中断时自动回退到 .bak,避免人员库变空。
|
||||
BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(atomicStore.openRead(), UTF8));
|
||||
try {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
text.append(line);
|
||||
}
|
||||
} finally {
|
||||
reader.close();
|
||||
}
|
||||
JSONArray array = new JSONArray(text.toString());
|
||||
for (int i = 0; i < array.length(); i += 1) {
|
||||
JSONObject item = array.optJSONObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
float[] feature = decode(item.optString("feature", ""));
|
||||
if (feature.length == 0) {
|
||||
continue;
|
||||
}
|
||||
records.add(new Record(
|
||||
item.optString("faceId", ""),
|
||||
item.optString("employeeNo", ""),
|
||||
item.optString("name", ""),
|
||||
feature));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
records.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void save() throws Exception {
|
||||
JSONArray array = new JSONArray();
|
||||
for (Record record : records) {
|
||||
JSONObject item = new JSONObject();
|
||||
item.put("faceId", record.faceId);
|
||||
item.put("employeeNo", record.employeeNo);
|
||||
item.put("name", record.name);
|
||||
item.put("feature", encode(record.feature));
|
||||
array.put(item);
|
||||
}
|
||||
FileOutputStream output = null;
|
||||
try {
|
||||
output = atomicStore.startWrite();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, UTF8));
|
||||
writer.write(array.toString());
|
||||
writer.flush();
|
||||
atomicStore.finishWrite(output);
|
||||
} catch (Exception error) {
|
||||
if (output != null) {
|
||||
atomicStore.failWrite(output);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static String encode(float[] feature) {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(feature.length * 4).order(ByteOrder.LITTLE_ENDIAN);
|
||||
for (float value : feature) {
|
||||
buffer.putFloat(value);
|
||||
}
|
||||
return Base64.encodeToString(buffer.array(), Base64.NO_WRAP);
|
||||
}
|
||||
|
||||
private static float[] decode(String value) {
|
||||
try {
|
||||
byte[] bytes = Base64.decode(value, Base64.DEFAULT);
|
||||
if (bytes.length == 0 || bytes.length % 4 != 0) {
|
||||
return new float[0];
|
||||
}
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
float[] feature = new float[bytes.length / 4];
|
||||
for (int i = 0; i < feature.length; i += 1) {
|
||||
feature[i] = buffer.getFloat();
|
||||
}
|
||||
return feature;
|
||||
} catch (Exception ignored) {
|
||||
return new float[0];
|
||||
}
|
||||
}
|
||||
|
||||
private static float cosine(float[] first, float[] second) {
|
||||
double dot = 0;
|
||||
double firstNorm = 0;
|
||||
double secondNorm = 0;
|
||||
for (int i = 0; i < first.length; i += 1) {
|
||||
dot += first[i] * second[i];
|
||||
firstNorm += first[i] * first[i];
|
||||
secondNorm += second[i] * second[i];
|
||||
}
|
||||
if (firstNorm <= 0 || secondNorm <= 0) {
|
||||
return -1f;
|
||||
}
|
||||
return (float) (dot / Math.sqrt(firstNorm * secondNorm));
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.AssetManager;
|
||||
import android.hardware.Camera;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
import android.view.SurfaceHolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
final class FaceRecognitionController implements Camera.PreviewCallback {
|
||||
private static final String TAG = "XingyuanFace";
|
||||
interface Listener {
|
||||
void onFaceStatus(String status, String message, int enrolledCount);
|
||||
|
||||
void onFaceRecognized(FaceFeatureStore.Match match);
|
||||
}
|
||||
|
||||
private static final String MODEL_DIRECTORY = "face_models";
|
||||
private static final String DETECTOR_MODEL = "face_detector.csta";
|
||||
private static final String LANDMARKER_MODEL = "face_landmarker_pts5.csta";
|
||||
private static final String RECOGNIZER_MODEL = "face_recognizer.csta";
|
||||
private static final float DEFAULT_MATCH_THRESHOLD = 0.75f;
|
||||
private static final long FRAME_INTERVAL_MS = 700;
|
||||
private static final long RECOGNITION_COOLDOWN_MS = 5000;
|
||||
private static final long ENROLLMENT_FEATURE_MAX_AGE_MS = 8000;
|
||||
private static final long FEEDBACK_INTERVAL_MS = 1200;
|
||||
private static final long CAMERA_RETRY_INTERVAL_MS = 5000;
|
||||
|
||||
private final Context context;
|
||||
private final Listener listener;
|
||||
private final FaceFeatureStore featureStore;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
private final AtomicBoolean processing = new AtomicBoolean(false);
|
||||
private final AtomicBoolean cameraTaskQueued = new AtomicBoolean(false);
|
||||
private final AtomicBoolean destroyed = new AtomicBoolean(false);
|
||||
private volatile Camera camera;
|
||||
private volatile SurfaceHolder previewHolder;
|
||||
private volatile boolean cameraStartRequestedWhileQueued;
|
||||
private volatile boolean restartCameraWhenSurfaceReady;
|
||||
private volatile boolean requestedActive;
|
||||
private volatile long recognitionSession;
|
||||
private volatile long lastCameraStartAttemptAt;
|
||||
private volatile boolean engineReady;
|
||||
private volatile int previewWidth;
|
||||
private volatile int previewHeight;
|
||||
private long lastFrameAt;
|
||||
private long lastRecognizedAt;
|
||||
private long lastSimilarityLogAt;
|
||||
private long lastExtractionLogAt;
|
||||
private long lastFeedbackAt;
|
||||
private String lastFeedbackMessage = "";
|
||||
private volatile float[] latestFeature;
|
||||
private volatile long latestFeatureAt;
|
||||
private volatile float matchThreshold = DEFAULT_MATCH_THRESHOLD;
|
||||
private boolean faceAvailableNotified;
|
||||
|
||||
FaceRecognitionController(Context context, Listener listener) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.listener = listener;
|
||||
this.featureStore = new FaceFeatureStore(context);
|
||||
// 模型约 100MB,复制和 SeetaFace6 初始化必须离开 UI 线程,否则首次启动会 ANR。
|
||||
worker.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
initializeEngine();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int enrolledCount() {
|
||||
return featureStore.size();
|
||||
}
|
||||
|
||||
String peopleJson() {
|
||||
return featureStore.listPeople().toString();
|
||||
}
|
||||
|
||||
synchronized float setMatchThreshold(float value) {
|
||||
if (Float.isNaN(value) || Float.isInfinite(value)) {
|
||||
value = DEFAULT_MATCH_THRESHOLD;
|
||||
}
|
||||
matchThreshold = Math.max(0.50f, Math.min(0.95f, value));
|
||||
lastFeedbackAt = 0;
|
||||
lastFeedbackMessage = "";
|
||||
Log.i(TAG, "match threshold updated=" + matchThreshold);
|
||||
return matchThreshold;
|
||||
}
|
||||
|
||||
float getMatchThreshold() {
|
||||
return matchThreshold;
|
||||
}
|
||||
|
||||
synchronized boolean removePerson(String faceId) throws Exception {
|
||||
boolean removed = featureStore.remove(faceId);
|
||||
if (removed) {
|
||||
notifyStatus("ready", "人员已删除", featureStore.size());
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
void setActive(boolean active) {
|
||||
if (destroyed.get()) {
|
||||
return;
|
||||
}
|
||||
if (requestedActive == active) {
|
||||
if (active && camera == null) {
|
||||
requestCameraStart(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
requestedActive = active;
|
||||
recognitionSession++;
|
||||
latestFeature = null;
|
||||
latestFeatureAt = 0;
|
||||
faceAvailableNotified = false;
|
||||
if (active) {
|
||||
lastCameraStartAttemptAt = 0;
|
||||
lastFrameAt = 0;
|
||||
lastRecognizedAt = 0;
|
||||
lastSimilarityLogAt = 0;
|
||||
lastExtractionLogAt = 0;
|
||||
lastFeedbackAt = 0;
|
||||
lastFeedbackMessage = "";
|
||||
Log.i(TAG, "resume recognition session=" + recognitionSession
|
||||
+ " enrolled=" + featureStore.size() + " cameraOpen=" + (camera != null));
|
||||
requestCameraStart(false);
|
||||
} else {
|
||||
// RK3399 的旧 Camera HAL 在页面切换时调用 stopPreview/release 可能阻塞数十秒。
|
||||
// 普通页面切换只暂停帧处理,保留摄像头预览;Activity 销毁时才真正释放。
|
||||
Log.i(TAG, "pause recognition session=" + recognitionSession);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isRequestedActive() {
|
||||
return requestedActive;
|
||||
}
|
||||
|
||||
void retryAfterPermission() {
|
||||
if (destroyed.get()) {
|
||||
return;
|
||||
}
|
||||
if (requestedActive) {
|
||||
lastCameraStartAttemptAt = 0;
|
||||
requestCameraStart(false);
|
||||
}
|
||||
}
|
||||
|
||||
void setPreviewSurface(SurfaceHolder holder) {
|
||||
if (holder == null || destroyed.get()) {
|
||||
return;
|
||||
}
|
||||
boolean changed = previewHolder != holder;
|
||||
previewHolder = holder;
|
||||
if (requestedActive && (camera == null || (changed && restartCameraWhenSurfaceReady))) {
|
||||
requestCameraStart(changed && restartCameraWhenSurfaceReady);
|
||||
}
|
||||
}
|
||||
|
||||
void clearPreviewSurface(SurfaceHolder holder) {
|
||||
if (previewHolder != holder) {
|
||||
return;
|
||||
}
|
||||
previewHolder = null;
|
||||
if (camera != null) {
|
||||
// 页面切换不会再销毁 Surface;若系统确实回收了 Surface,等新 Surface
|
||||
// 创建后在后台线程重启摄像头,绝不在 UI 回调中调用 stopPreview。
|
||||
restartCameraWhenSurfaceReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
if (!destroyed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
requestedActive = false;
|
||||
recognitionSession++;
|
||||
restartCameraWhenSurfaceReady = false;
|
||||
previewHolder = null;
|
||||
latestFeature = null;
|
||||
latestFeatureAt = 0;
|
||||
try {
|
||||
worker.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
stopCamera();
|
||||
if (engineReady) {
|
||||
try {
|
||||
SeetaFaceNative.release();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
engineReady = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
worker.shutdown();
|
||||
}
|
||||
|
||||
synchronized void enrollCurrent(String faceId, String employeeNo, String name) throws Exception {
|
||||
float[] snapshot = latestFeature;
|
||||
long featureAge = SystemClock.elapsedRealtime() - latestFeatureAt;
|
||||
if (snapshot == null || snapshot.length == 0 || featureAge > ENROLLMENT_FEATURE_MAX_AGE_MS) {
|
||||
String reason = safeNativeError();
|
||||
if (reason.length() == 0 || "识别引擎初始化失败".equals(reason)) {
|
||||
reason = "请正对摄像头,避免逆光并保持面部清晰";
|
||||
}
|
||||
throw new IllegalStateException("最近 8 秒内未检测到可录入人脸:" + reason);
|
||||
}
|
||||
featureStore.put(faceId, employeeNo, name, snapshot);
|
||||
notifyStatus("ready", "人脸采集成功", featureStore.size());
|
||||
}
|
||||
|
||||
synchronized void prepareEnrollment() {
|
||||
latestFeature = null;
|
||||
latestFeatureAt = 0;
|
||||
faceAvailableNotified = false;
|
||||
notifyStatus("searching", "请正对摄像头,正在采集新的 SeetaFace6 人脸特征", featureStore.size());
|
||||
}
|
||||
|
||||
private void initializeEngine() {
|
||||
try {
|
||||
File modelDirectory = new File(context.getFilesDir(), MODEL_DIRECTORY);
|
||||
copyAsset(DETECTOR_MODEL, modelDirectory);
|
||||
copyAsset(LANDMARKER_MODEL, modelDirectory);
|
||||
copyAsset(RECOGNIZER_MODEL, modelDirectory);
|
||||
engineReady = SeetaFaceNative.initialize(
|
||||
new File(modelDirectory, DETECTOR_MODEL).getAbsolutePath(),
|
||||
new File(modelDirectory, LANDMARKER_MODEL).getAbsolutePath(),
|
||||
new File(modelDirectory, RECOGNIZER_MODEL).getAbsolutePath());
|
||||
notifyStatus(engineReady ? "ready" : "error",
|
||||
engineReady ? "识别引擎已就绪" : safeNativeError(), featureStore.size());
|
||||
} catch (Throwable error) {
|
||||
engineReady = false;
|
||||
notifyStatus("error", "识别引擎加载失败:" + message(error), featureStore.size());
|
||||
}
|
||||
}
|
||||
|
||||
private void requestCameraStart(final boolean restart) {
|
||||
if (destroyed.get()) {
|
||||
return;
|
||||
}
|
||||
if (restart) {
|
||||
restartCameraWhenSurfaceReady = true;
|
||||
}
|
||||
SurfaceHolder holder = previewHolder;
|
||||
if (!requestedActive || holder == null || holder.getSurface() == null
|
||||
|| !holder.getSurface().isValid()) {
|
||||
return;
|
||||
}
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
if (!restart && camera == null && lastCameraStartAttemptAt > 0
|
||||
&& now - lastCameraStartAttemptAt < CAMERA_RETRY_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
if (!cameraTaskQueued.compareAndSet(false, true)) {
|
||||
cameraStartRequestedWhileQueued = true;
|
||||
return;
|
||||
}
|
||||
lastCameraStartAttemptAt = now;
|
||||
cameraStartRequestedWhileQueued = false;
|
||||
try {
|
||||
worker.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
boolean shouldRestart = restartCameraWhenSurfaceReady;
|
||||
restartCameraWhenSurfaceReady = false;
|
||||
if (shouldRestart) {
|
||||
stopCamera();
|
||||
}
|
||||
startCamera();
|
||||
} finally {
|
||||
boolean retryRequested = cameraStartRequestedWhileQueued;
|
||||
cameraStartRequestedWhileQueued = false;
|
||||
cameraTaskQueued.set(false);
|
||||
if (requestedActive && previewHolder != null
|
||||
&& (retryRequested || restartCameraWhenSurfaceReady)) {
|
||||
requestCameraStart(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Throwable ignored) {
|
||||
cameraTaskQueued.set(false);
|
||||
lastCameraStartAttemptAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void startCamera() {
|
||||
if (destroyed.get() || !requestedActive || camera != null) {
|
||||
return;
|
||||
}
|
||||
SurfaceHolder holder = previewHolder;
|
||||
if (holder == null || holder.getSurface() == null || !holder.getSurface().isValid()) {
|
||||
Log.i(TAG, "wait for persistent preview surface session=" + recognitionSession);
|
||||
return;
|
||||
}
|
||||
Log.i(TAG, "open camera session=" + recognitionSession);
|
||||
try {
|
||||
int count = Camera.getNumberOfCameras();
|
||||
if (count <= 0) {
|
||||
notifyStatus("camera_error", "未发现系统摄像头", featureStore.size());
|
||||
return;
|
||||
}
|
||||
camera = Camera.open(0);
|
||||
Camera.Parameters parameters = camera.getParameters();
|
||||
Camera.Size selected = choosePreviewSize(parameters.getSupportedPreviewSizes());
|
||||
if (selected != null) {
|
||||
parameters.setPreviewSize(selected.width, selected.height);
|
||||
}
|
||||
parameters.setPreviewFormat(android.graphics.ImageFormat.NV21);
|
||||
camera.setParameters(parameters);
|
||||
Camera.Size actual = camera.getParameters().getPreviewSize();
|
||||
previewWidth = actual.width;
|
||||
previewHeight = actual.height;
|
||||
camera.setPreviewDisplay(holder);
|
||||
camera.setPreviewCallback(this);
|
||||
camera.startPreview();
|
||||
notifyStatus(engineReady ? "recognizing" : "camera_ready",
|
||||
engineReady
|
||||
? "摄像头已连接 " + previewWidth + "×" + previewHeight + ",识别中"
|
||||
: "摄像头已连接,但识别引擎未就绪",
|
||||
featureStore.size());
|
||||
} catch (SecurityException error) {
|
||||
stopCamera();
|
||||
notifyStatus("permission", "等待摄像头权限", featureStore.size());
|
||||
} catch (Throwable error) {
|
||||
stopCamera();
|
||||
notifyStatus("camera_error", "摄像头启动失败:" + message(error), featureStore.size());
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void stopCamera() {
|
||||
recognitionSession++;
|
||||
Log.i(TAG, "stop recognition session=" + recognitionSession);
|
||||
Camera current = camera;
|
||||
camera = null;
|
||||
if (current != null) {
|
||||
try {
|
||||
current.setPreviewCallback(null);
|
||||
current.stopPreview();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
current.release();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
latestFeature = null;
|
||||
latestFeatureAt = 0;
|
||||
faceAvailableNotified = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPreviewFrame(final byte[] data, Camera source) {
|
||||
if (destroyed.get() || !requestedActive || !engineReady
|
||||
|| data == null || previewWidth <= 0 || previewHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
if (now - lastFrameAt < FRAME_INTERVAL_MS || !processing.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
lastFrameAt = now;
|
||||
final byte[] frame = data.clone();
|
||||
final int width = previewWidth;
|
||||
final int height = previewHeight;
|
||||
final long session = recognitionSession;
|
||||
try {
|
||||
worker.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
long extractionStartedAt = SystemClock.elapsedRealtime();
|
||||
boolean shouldLogExtraction = extractionStartedAt - lastExtractionLogAt >= 3000;
|
||||
if (shouldLogExtraction) {
|
||||
lastExtractionLogAt = extractionStartedAt;
|
||||
Log.i(TAG, "extract begin session=" + session + " size=" + width + "x" + height
|
||||
+ " enrolled=" + featureStore.size());
|
||||
}
|
||||
float[] feature = SeetaFaceNative.extractFeature(frame, width, height, 0, false);
|
||||
if (shouldLogExtraction) {
|
||||
Log.i(TAG, "extract end session=" + session
|
||||
+ " elapsedMs=" + (SystemClock.elapsedRealtime() - extractionStartedAt)
|
||||
+ " feature=" + (feature == null ? 0 : feature.length)
|
||||
+ " nativeError=" + safeNativeError());
|
||||
}
|
||||
if (!isRecognitionSessionCurrent(session)) {
|
||||
Log.i(TAG, "discard stale frame session=" + session
|
||||
+ " current=" + recognitionSession);
|
||||
return;
|
||||
}
|
||||
if (feature == null || feature.length == 0) {
|
||||
notifyFaceFeedback("face_error", safeNativeError());
|
||||
if (latestFeatureAt > 0
|
||||
&& SystemClock.elapsedRealtime() - latestFeatureAt > ENROLLMENT_FEATURE_MAX_AGE_MS) {
|
||||
latestFeature = null;
|
||||
latestFeatureAt = 0;
|
||||
if (faceAvailableNotified) {
|
||||
faceAvailableNotified = false;
|
||||
notifyStatus("searching", "正在等待清晰正脸", featureStore.size());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
latestFeature = feature;
|
||||
latestFeatureAt = SystemClock.elapsedRealtime();
|
||||
if (!faceAvailableNotified) {
|
||||
faceAvailableNotified = true;
|
||||
notifyFaceFeedback("face_ready", "已检测到清晰正脸");
|
||||
}
|
||||
long recognizedNow = SystemClock.elapsedRealtime();
|
||||
FaceFeatureStore.Match best = featureStore.findBest(feature, -1f);
|
||||
float currentThreshold = matchThreshold;
|
||||
if (best != null && recognizedNow - lastSimilarityLogAt >= 3000) {
|
||||
lastSimilarityLogAt = recognizedNow;
|
||||
Log.i(TAG, "best face=" + best.faceId + " similarity=" + best.similarity
|
||||
+ " threshold=" + currentThreshold);
|
||||
}
|
||||
FaceFeatureStore.Match match = best != null && best.similarity >= currentThreshold ? best : null;
|
||||
if (match == null) {
|
||||
if (best == null) {
|
||||
notifyFaceFeedback("no_match", "本地没有可匹配的人脸信息");
|
||||
} else {
|
||||
int similarityPercent = Math.round(best.similarity * 100f);
|
||||
int thresholdPercent = Math.round(currentThreshold * 100f);
|
||||
notifyFaceFeedback("no_match", "人脸匹配失败:相似度 "
|
||||
+ similarityPercent + "% ,需要达到 " + thresholdPercent + "%");
|
||||
}
|
||||
}
|
||||
if (match != null && recognizedNow - lastRecognizedAt >= RECOGNITION_COOLDOWN_MS) {
|
||||
lastRecognizedAt = recognizedNow;
|
||||
Log.i(TAG, "recognized face=" + match.faceId + " similarity=" + match.similarity);
|
||||
if (isRecognitionSessionCurrent(session)) {
|
||||
listener.onFaceRecognized(match);
|
||||
}
|
||||
}
|
||||
} catch (Throwable error) {
|
||||
notifyStatus("error", "人脸识别失败:" + message(error), featureStore.size());
|
||||
} finally {
|
||||
processing.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Throwable error) {
|
||||
// Activity 销毁和 Camera 回调可能同时发生,线程池关闭后不能让拒绝异常逃出 Camera 线程。
|
||||
processing.set(false);
|
||||
if (!destroyed.get()) {
|
||||
Log.w(TAG, "discard preview frame because worker is unavailable", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRecognitionSessionCurrent(long session) {
|
||||
return requestedActive && recognitionSession == session;
|
||||
}
|
||||
|
||||
private void notifyFaceFeedback(String status, String message) {
|
||||
String normalized = message == null || message.length() == 0 ? "人脸识别失败" : message;
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
boolean sameMessage = normalized.equals(lastFeedbackMessage);
|
||||
if (now - lastFeedbackAt < FEEDBACK_INTERVAL_MS
|
||||
|| (sameMessage && now - lastFeedbackAt < FEEDBACK_INTERVAL_MS * 3)) {
|
||||
return;
|
||||
}
|
||||
lastFeedbackAt = now;
|
||||
lastFeedbackMessage = normalized;
|
||||
notifyStatus(status, normalized, featureStore.size());
|
||||
}
|
||||
|
||||
private void copyAsset(String name, File directory) throws Exception {
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
throw new IllegalStateException("无法创建模型目录");
|
||||
}
|
||||
File target = new File(directory, name);
|
||||
AssetManager assets = context.getAssets();
|
||||
InputStream input = assets.open(MODEL_DIRECTORY + "/" + name);
|
||||
int assetSize = input.available();
|
||||
if (target.isFile() && assetSize > 0 && target.length() == assetSize) {
|
||||
input.close();
|
||||
return;
|
||||
}
|
||||
FileOutputStream output = new FileOutputStream(target, false);
|
||||
try {
|
||||
byte[] buffer = new byte[16384];
|
||||
int count;
|
||||
while ((count = input.read(buffer)) >= 0) {
|
||||
output.write(buffer, 0, count);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
input.close();
|
||||
} finally {
|
||||
output.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Camera.Size choosePreviewSize(List<Camera.Size> sizes) {
|
||||
if (sizes == null || sizes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Camera.Size best = sizes.get(0);
|
||||
long bestDistance = Long.MAX_VALUE;
|
||||
for (Camera.Size size : sizes) {
|
||||
long distance = Math.abs(size.width - 640L) + Math.abs(size.height - 480L);
|
||||
if (distance < bestDistance) {
|
||||
best = size;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private void notifyStatus(String status, String message, int count) {
|
||||
if (!destroyed.get()) {
|
||||
listener.onFaceStatus(status, message, count);
|
||||
}
|
||||
}
|
||||
|
||||
private String safeNativeError() {
|
||||
try {
|
||||
String value = SeetaFaceNative.lastError();
|
||||
return value == null || value.length() == 0 ? "识别引擎初始化失败" : value;
|
||||
} catch (Throwable ignored) {
|
||||
return "识别引擎初始化失败";
|
||||
}
|
||||
}
|
||||
|
||||
private static String message(Throwable error) {
|
||||
String value = error == null ? "未知错误" : error.getMessage();
|
||||
return value == null || value.length() == 0 ? error.getClass().getSimpleName() : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.TargetApi;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.provider.Settings;
|
||||
import android.view.View;
|
||||
import android.view.Gravity;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
import android.view.SurfaceHolder;
|
||||
import android.view.SurfaceView;
|
||||
import android.webkit.ValueCallback;
|
||||
import android.webkit.RenderProcessGoneDetail;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class MainActivity extends Activity implements
|
||||
ZhitouSerialPortManager.Listener,
|
||||
ZhitouTcpReporter.Listener,
|
||||
FaceRecognitionController.Listener {
|
||||
static final int FILE_CHOOSER_REQUEST = 4101;
|
||||
private static final int PERMISSION_REQUEST = 4102;
|
||||
private FrameLayout rootLayout;
|
||||
private WebView webView;
|
||||
private SurfaceView facePreviewView;
|
||||
private int facePreviewLeft = Integer.MIN_VALUE;
|
||||
private int facePreviewTop = Integer.MIN_VALUE;
|
||||
private int facePreviewWidth = Integer.MIN_VALUE;
|
||||
private int facePreviewHeight = Integer.MIN_VALUE;
|
||||
private volatile boolean requestedFacePreviewVisible;
|
||||
private volatile int requestedFacePreviewLeft;
|
||||
private volatile int requestedFacePreviewTop;
|
||||
private volatile int requestedFacePreviewWidth;
|
||||
private volatile int requestedFacePreviewHeight;
|
||||
private final AtomicBoolean facePreviewLayoutQueued = new AtomicBoolean(false);
|
||||
private final AtomicLong facePreviewLayoutVersion = new AtomicLong(0L);
|
||||
private String pendingRecoveryNotice;
|
||||
private boolean recoveryRetryStarted;
|
||||
private final Runnable markStableLaunchTask = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
CrashRecovery.markLaunchSuccessful(MainActivity.this);
|
||||
}
|
||||
};
|
||||
private ValueCallback<Uri[]> filePathCallback;
|
||||
private ZhitouTcpReporter tcpReporter;
|
||||
private ZhitouSerialPortManager serialPortManager;
|
||||
private ZhitouBridge bridge;
|
||||
private FaceRecognitionController faceRecognitionController;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (CrashRecovery.shouldEnterStartupRecovery(this)) {
|
||||
showStartupRecoveryScreen();
|
||||
return;
|
||||
}
|
||||
pendingRecoveryNotice = CrashRecovery.consumeRecoveryNotice(this);
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
rootLayout = new FrameLayout(this);
|
||||
webView = new WebView(this);
|
||||
tcpReporter = new ZhitouTcpReporter(this, this);
|
||||
serialPortManager = new ZhitouSerialPortManager(this);
|
||||
webView.setBackgroundColor(Color.rgb(247, 250, 248));
|
||||
rootLayout.addView(webView, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
facePreviewView = new SurfaceView(this);
|
||||
facePreviewView.setZOrderOnTop(true);
|
||||
facePreviewView.getHolder().setFormat(PixelFormat.OPAQUE);
|
||||
// Surface 始终保留。RK3399 的旧 Camera HAL 在页面切换时重新绑定
|
||||
// Surface 会阻塞主线程并触发 ANR;隐藏时只把画面缩到 1×1。
|
||||
facePreviewView.setVisibility(View.VISIBLE);
|
||||
rootLayout.addView(facePreviewView, new FrameLayout.LayoutParams(1, 1));
|
||||
setContentView(rootLayout);
|
||||
|
||||
configureWebView();
|
||||
requestRuntimePermissions();
|
||||
prepareSmartStorage();
|
||||
webView.loadUrl("file:///android_asset/www/index.html");
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private void configureWebView() {
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setDatabaseEnabled(true);
|
||||
settings.setAllowFileAccess(true);
|
||||
settings.setAllowContentAccess(true);
|
||||
settings.setLoadWithOverviewMode(true);
|
||||
settings.setUseWideViewPort(true);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
|
||||
// 页面只通过受控的原生桥通讯,不允许 file:// 页面任意读取文件或跨域访问。
|
||||
settings.setAllowFileAccessFromFileURLs(false);
|
||||
settings.setAllowUniversalAccessFromFileURLs(false);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
}
|
||||
|
||||
webView.setWebViewClient(new RecoveryWebViewClient());
|
||||
webView.setWebChromeClient(new ZhitouWebChromeClient(this));
|
||||
faceRecognitionController = new FaceRecognitionController(this, this);
|
||||
facePreviewView.getHolder().addCallback(new SurfaceHolder.Callback() {
|
||||
@Override
|
||||
public void surfaceCreated(SurfaceHolder holder) {
|
||||
if (faceRecognitionController != null) {
|
||||
faceRecognitionController.setPreviewSurface(holder);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||
if (faceRecognitionController != null) {
|
||||
faceRecognitionController.clearPreviewSurface(holder);
|
||||
}
|
||||
}
|
||||
});
|
||||
bridge = new ZhitouBridge(this, tcpReporter, serialPortManager, faceRecognitionController);
|
||||
webView.addJavascriptInterface(bridge, "XingyuanNative");
|
||||
webView.addJavascriptInterface(bridge, "ZhitouNative");
|
||||
}
|
||||
|
||||
private void requestRuntimePermissions() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
return;
|
||||
}
|
||||
String[] permissions = new String[] {
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
};
|
||||
requestPermissions(permissions, PERMISSION_REQUEST);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == PERMISSION_REQUEST) {
|
||||
prepareSmartStorage();
|
||||
if (faceRecognitionController != null) {
|
||||
faceRecognitionController.retryAfterPermission();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareSmartStorage() {
|
||||
File smartDir = new File(Environment.getExternalStorageDirectory(), "smart");
|
||||
File imageDir = new File(smartDir, "Image");
|
||||
File logsDir = new File(smartDir, "logs");
|
||||
// 首次启动时创建业务固定目录,避免用户手动新建 smart/Image、smart/logs。
|
||||
ensureDirectory(imageDir);
|
||||
ensureDirectory(logsDir);
|
||||
}
|
||||
|
||||
private void ensureDirectory(File dir) {
|
||||
if (dir == null || dir.exists()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dir.mkdirs();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode != FILE_CHOOSER_REQUEST || filePathCallback == null) {
|
||||
return;
|
||||
}
|
||||
Uri[] results = null;
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
if (data.getClipData() != null) {
|
||||
int count = data.getClipData().getItemCount();
|
||||
results = new Uri[count];
|
||||
for (int i = 0; i < count; i += 1) {
|
||||
results[i] = data.getClipData().getItemAt(i).getUri();
|
||||
}
|
||||
} else if (data.getData() != null) {
|
||||
results = new Uri[] { data.getData() };
|
||||
}
|
||||
}
|
||||
filePathCallback.onReceiveValue(results);
|
||||
filePathCallback = null;
|
||||
}
|
||||
|
||||
void setFilePathCallback(ValueCallback<Uri[]> callback) {
|
||||
if (filePathCallback != null) {
|
||||
filePathCallback.onReceiveValue(null);
|
||||
}
|
||||
filePathCallback = callback;
|
||||
}
|
||||
|
||||
void clearFilePathCallback() {
|
||||
filePathCallback = null;
|
||||
}
|
||||
|
||||
void launchFileChooser(Intent intent) throws ActivityNotFoundException {
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
startActivityForResult(intent, FILE_CHOOSER_REQUEST);
|
||||
}
|
||||
|
||||
void openSystemSettings() {
|
||||
Intent intent = new Intent(Settings.ACTION_SETTINGS);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
void openFileManager() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
try {
|
||||
startActivity(intent);
|
||||
} catch (ActivityNotFoundException error) {
|
||||
openFileManagerFallback();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onImei(String imei) {
|
||||
dispatchSerialImei(imei);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVersion(String version) {
|
||||
dispatchSerialVersion(version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIdentity(int type, String identity) {
|
||||
dispatchSerialIdentity(type, identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onThrowWeight(ZhitouSerialPortManager.ThrowWeightItem item) {
|
||||
dispatchSerialThrowWeight(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDeviceStatus(ZhitouSerialPortManager.DeviceStatusItem item) {
|
||||
dispatchSerialDeviceStatus(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReporterLog(String kind, String message) {
|
||||
dispatchNativeLog(kind, message);
|
||||
}
|
||||
|
||||
private void dispatchSerialIdentity(int type, String identity) {
|
||||
if (webView == null) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new ZhitouSerialIdentityTask(webView, type, identity));
|
||||
}
|
||||
|
||||
private void dispatchSerialImei(String imei) {
|
||||
if (webView == null) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new ZhitouSerialImeiTask(webView, imei));
|
||||
}
|
||||
|
||||
private void dispatchSerialVersion(String version) {
|
||||
if (webView == null) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new ZhitouSerialVersionTask(webView, version));
|
||||
}
|
||||
|
||||
private void dispatchSerialThrowWeight(ZhitouSerialPortManager.ThrowWeightItem item) {
|
||||
if (webView == null || item == null) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new ZhitouSerialThrowWeightTask(webView, item));
|
||||
}
|
||||
|
||||
private void dispatchSerialDeviceStatus(ZhitouSerialPortManager.DeviceStatusItem item) {
|
||||
if (webView == null || item == null) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new ZhitouSerialDeviceStatusTask(webView, item));
|
||||
}
|
||||
|
||||
private void dispatchNativeLog(String kind, String message) {
|
||||
if (webView == null) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new ZhitouNativeLogTask(webView, kind, message));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFaceStatus(String status, String message, int enrolledCount) {
|
||||
if (webView == null) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new ZhitouFaceStatusTask(webView, status, message, enrolledCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFaceRecognized(FaceFeatureStore.Match match) {
|
||||
if (webView == null || match == null) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new ZhitouFaceRecognizedTask(webView, match));
|
||||
}
|
||||
|
||||
void setFaceRecognitionActive(boolean active) {
|
||||
if (faceRecognitionController != null) {
|
||||
faceRecognitionController.setActive(active);
|
||||
}
|
||||
}
|
||||
|
||||
void setFacePreviewLayout(final boolean visible, final int left, final int top, final int width, final int height) {
|
||||
requestedFacePreviewVisible = visible;
|
||||
requestedFacePreviewLeft = left;
|
||||
requestedFacePreviewTop = top;
|
||||
requestedFacePreviewWidth = width;
|
||||
requestedFacePreviewHeight = height;
|
||||
facePreviewLayoutVersion.incrementAndGet();
|
||||
queueFacePreviewLayoutApply();
|
||||
}
|
||||
|
||||
private void queueFacePreviewLayoutApply() {
|
||||
if (!facePreviewLayoutQueued.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
long appliedVersion = facePreviewLayoutVersion.get();
|
||||
applyFacePreviewLayout(
|
||||
requestedFacePreviewVisible,
|
||||
requestedFacePreviewLeft,
|
||||
requestedFacePreviewTop,
|
||||
requestedFacePreviewWidth,
|
||||
requestedFacePreviewHeight);
|
||||
facePreviewLayoutQueued.set(false);
|
||||
if (facePreviewLayoutVersion.get() != appliedVersion) {
|
||||
queueFacePreviewLayoutApply();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyFacePreviewLayout(boolean visible, int left, int top, int width, int height) {
|
||||
if (facePreviewView == null || rootLayout == null) {
|
||||
return;
|
||||
}
|
||||
int availableWidth = rootLayout.getWidth();
|
||||
int availableHeight = rootLayout.getHeight();
|
||||
if (availableWidth <= 0 || availableHeight <= 0) {
|
||||
availableWidth = getResources().getDisplayMetrics().widthPixels;
|
||||
availableHeight = getResources().getDisplayMetrics().heightPixels;
|
||||
}
|
||||
availableWidth = Math.max(1, availableWidth);
|
||||
availableHeight = Math.max(1, availableHeight);
|
||||
boolean showPreview = visible && width > 0 && height > 0;
|
||||
int targetLeft = showPreview ? clamp(left, 0, availableWidth - 1) : 0;
|
||||
int targetTop = showPreview ? clamp(top, 0, availableHeight - 1) : 0;
|
||||
int targetWidth = showPreview ? clamp(width, 1, availableWidth - targetLeft) : 1;
|
||||
int targetHeight = showPreview ? clamp(height, 1, availableHeight - targetTop) : 1;
|
||||
boolean layoutChanged = facePreviewLeft != targetLeft
|
||||
|| facePreviewTop != targetTop
|
||||
|| facePreviewWidth != targetWidth
|
||||
|| facePreviewHeight != targetHeight;
|
||||
if (layoutChanged) {
|
||||
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(targetWidth, targetHeight);
|
||||
params.leftMargin = targetLeft;
|
||||
params.topMargin = targetTop;
|
||||
facePreviewView.setLayoutParams(params);
|
||||
facePreviewLeft = targetLeft;
|
||||
facePreviewTop = targetTop;
|
||||
facePreviewWidth = targetWidth;
|
||||
facePreviewHeight = targetHeight;
|
||||
if (showPreview) {
|
||||
facePreviewView.bringToFront();
|
||||
}
|
||||
}
|
||||
if (facePreviewView.getVisibility() != View.VISIBLE) {
|
||||
facePreviewView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private static int clamp(int value, int minimum, int maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
int getEnrolledFaceCount() {
|
||||
return faceRecognitionController == null ? 0 : faceRecognitionController.enrolledCount();
|
||||
}
|
||||
|
||||
float setFaceMatchThreshold(float value) {
|
||||
return faceRecognitionController == null ? value : faceRecognitionController.setMatchThreshold(value);
|
||||
}
|
||||
|
||||
float getFaceMatchThreshold() {
|
||||
return faceRecognitionController == null ? 0.75f : faceRecognitionController.getMatchThreshold();
|
||||
}
|
||||
|
||||
String getFacePeopleJson() {
|
||||
return faceRecognitionController == null ? "[]" : faceRecognitionController.peopleJson();
|
||||
}
|
||||
|
||||
boolean removeFacePerson(String faceId) throws Exception {
|
||||
if (faceRecognitionController == null) {
|
||||
throw new IllegalStateException("人脸识别控制器不可用");
|
||||
}
|
||||
return faceRecognitionController.removePerson(faceId);
|
||||
}
|
||||
|
||||
void enrollCurrentFace(String faceId, String employeeNo, String name) throws Exception {
|
||||
if (faceRecognitionController == null) {
|
||||
throw new IllegalStateException("人脸识别控制器不可用");
|
||||
}
|
||||
faceRecognitionController.enrollCurrent(faceId, employeeNo, name);
|
||||
}
|
||||
|
||||
void prepareFaceEnrollment() {
|
||||
if (faceRecognitionController != null) {
|
||||
faceRecognitionController.prepareEnrollment();
|
||||
}
|
||||
}
|
||||
|
||||
void dispatchConnectionCheck(JSONObject payload) {
|
||||
if (webView == null) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(new ZhitouConnectionCheckTask(webView, payload));
|
||||
}
|
||||
|
||||
private void openFileManagerFallback() {
|
||||
Intent fallback = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
fallback.setType("*/*");
|
||||
fallback.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
try {
|
||||
startActivity(fallback);
|
||||
} catch (ActivityNotFoundException error) {
|
||||
Toast.makeText(this, "无法打开文件管理", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (webView != null && webView.canGoBack()) {
|
||||
webView.goBack();
|
||||
return;
|
||||
}
|
||||
super.onBackPressed();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
enterImmersiveMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTrimMemory(int level) {
|
||||
super.onTrimMemory(level);
|
||||
if (level >= TRIM_MEMORY_MODERATE && webView != null) {
|
||||
webView.freeMemory();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLowMemory() {
|
||||
super.onLowMemory();
|
||||
if (webView != null) {
|
||||
webView.freeMemory();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (faceRecognitionController != null) {
|
||||
faceRecognitionController.destroy();
|
||||
faceRecognitionController = null;
|
||||
}
|
||||
if (facePreviewView != null) {
|
||||
facePreviewView.setVisibility(View.GONE);
|
||||
}
|
||||
if (bridge != null) {
|
||||
bridge.destroy();
|
||||
bridge = null;
|
||||
}
|
||||
if (webView != null) {
|
||||
webView.removeCallbacks(markStableLaunchTask);
|
||||
webView.removeJavascriptInterface("XingyuanNative");
|
||||
webView.removeJavascriptInterface("ZhitouNative");
|
||||
webView.stopLoading();
|
||||
webView.loadUrl("about:blank");
|
||||
webView.setWebChromeClient(null);
|
||||
webView.setWebViewClient(null);
|
||||
if (rootLayout != null) {
|
||||
rootLayout.removeView(webView);
|
||||
}
|
||||
webView.destroy();
|
||||
webView = null;
|
||||
}
|
||||
if (tcpReporter != null) {
|
||||
tcpReporter.stop();
|
||||
}
|
||||
if (serialPortManager != null) {
|
||||
serialPortManager.destroy();
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) {
|
||||
enterImmersiveMode();
|
||||
}
|
||||
}
|
||||
|
||||
private void enterImmersiveMode() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void showStartupRecoveryScreen() {
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
LinearLayout panel = new LinearLayout(this);
|
||||
panel.setOrientation(LinearLayout.VERTICAL);
|
||||
panel.setGravity(Gravity.CENTER);
|
||||
panel.setPadding(64, 64, 64, 64);
|
||||
panel.setBackgroundColor(Color.rgb(247, 250, 248));
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText("应用安全恢复");
|
||||
title.setTextSize(28f);
|
||||
title.setTextColor(Color.rgb(29, 68, 51));
|
||||
title.setGravity(Gravity.CENTER);
|
||||
panel.addView(title, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
TextView detail = new TextView(this);
|
||||
detail.setText("上次启动期间发生异常,已阻止循环崩溃。\n\n"
|
||||
+ CrashRecovery.peekRecoveryNotice(this));
|
||||
detail.setTextSize(18f);
|
||||
detail.setTextColor(Color.DKGRAY);
|
||||
detail.setGravity(Gravity.CENTER);
|
||||
LinearLayout.LayoutParams detailParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT);
|
||||
detailParams.setMargins(0, 40, 0, 40);
|
||||
panel.addView(detail, detailParams);
|
||||
|
||||
Button retry = new Button(this);
|
||||
retry.setText("重新启动应用");
|
||||
retry.setTextSize(20f);
|
||||
retry.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
retryAfterStartupCrash();
|
||||
}
|
||||
});
|
||||
panel.addView(retry, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
setContentView(panel);
|
||||
enterImmersiveMode();
|
||||
|
||||
if (CrashRecovery.canAutoRetryStartup(this)) {
|
||||
panel.postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
retryAfterStartupCrash();
|
||||
}
|
||||
}, 4_000L);
|
||||
}
|
||||
}
|
||||
|
||||
private void retryAfterStartupCrash() {
|
||||
if (recoveryRetryStarted) {
|
||||
return;
|
||||
}
|
||||
recoveryRetryStarted = true;
|
||||
CrashRecovery.clearStartupRecoveryForRetry(this);
|
||||
Intent retryIntent = new Intent(this, MainActivity.class);
|
||||
retryIntent.setAction("com.xingyuan.zhiling.action.CRASH_RECOVERY");
|
||||
retryIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
finish();
|
||||
startActivity(retryIntent);
|
||||
}
|
||||
|
||||
private final class RecoveryWebViewClient extends WebViewClient {
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
view.removeCallbacks(markStableLaunchTask);
|
||||
view.postDelayed(markStableLaunchTask, 30_000L);
|
||||
if (pendingRecoveryNotice == null || pendingRecoveryNotice.length() == 0) {
|
||||
return;
|
||||
}
|
||||
String notice = pendingRecoveryNotice;
|
||||
pendingRecoveryNotice = "";
|
||||
new ZhitouNativeLogTask(view, "恢复", notice).run();
|
||||
Toast.makeText(MainActivity.this, notice, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
@Override
|
||||
public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) {
|
||||
boolean didCrash = detail != null && detail.didCrash();
|
||||
CrashRecovery.recordRendererCrashAndRestart(MainActivity.this, didCrash);
|
||||
android.os.Process.killProcess(android.os.Process.myPid());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private void openApplicationSettings() {
|
||||
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
|
||||
intent.setData(Uri.parse("package:" + getPackageName()));
|
||||
startActivity(intent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
final class SeetaFaceNative {
|
||||
static {
|
||||
System.loadLibrary("TenniS");
|
||||
System.loadLibrary("SeetaAuthorize");
|
||||
System.loadLibrary("SeetaFaceDetector600");
|
||||
System.loadLibrary("SeetaFaceLandmarker600");
|
||||
System.loadLibrary("SeetaFaceRecognizer600");
|
||||
System.loadLibrary("xingyuan_face");
|
||||
}
|
||||
|
||||
private SeetaFaceNative() {
|
||||
}
|
||||
|
||||
static native boolean initialize(String detectorModel, String landmarkerModel, String recognizerModel);
|
||||
|
||||
static native float[] extractFeature(byte[] nv21, int width, int height, int rotationDegrees, boolean mirror);
|
||||
|
||||
static native String lastError();
|
||||
|
||||
static native void release();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.app.Application;
|
||||
import android.os.Process;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class XingyuanApplication extends Application {
|
||||
private static final String TAG = "XingyuanCrash";
|
||||
private final AtomicBoolean handlingCrash = new AtomicBoolean(false);
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
final Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler();
|
||||
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
|
||||
@Override
|
||||
public void uncaughtException(Thread thread, Throwable error) {
|
||||
if (!handlingCrash.compareAndSet(false, true)) {
|
||||
terminate(previous, thread, error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Log.e(TAG, "Uncaught exception in " + thread.getName(), error);
|
||||
CrashRecovery.recordAndSchedule(XingyuanApplication.this, error);
|
||||
} catch (Throwable recoveryError) {
|
||||
Log.e(TAG, "Unable to record or schedule crash recovery", recoveryError);
|
||||
}
|
||||
Process.killProcess(Process.myPid());
|
||||
System.exit(10);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void terminate(
|
||||
Thread.UncaughtExceptionHandler previous,
|
||||
Thread thread,
|
||||
Throwable error) {
|
||||
if (previous != null) {
|
||||
previous.uncaughtException(thread, error);
|
||||
return;
|
||||
}
|
||||
Process.killProcess(Process.myPid());
|
||||
System.exit(10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouConnectionCheckTask implements Runnable {
|
||||
private final WebView webView;
|
||||
private final JSONObject payload;
|
||||
|
||||
ZhitouConnectionCheckTask(WebView webView, JSONObject payload) {
|
||||
this.webView = webView;
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
String js = "window.ZhitouSerial&&window.ZhitouSerial.onConnectionCheck("
|
||||
+ (payload == null ? "{}" : payload.toString()) + ");";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
final class ZhitouDeviceStatusTask implements Runnable {
|
||||
private final ZhitouTcpReporter reporter;
|
||||
|
||||
ZhitouDeviceStatusTask(ZhitouTcpReporter reporter) {
|
||||
this.reporter = reporter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
reporter.sendDeviceStatusNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouFaceRecognizedTask implements Runnable {
|
||||
private final WebView webView;
|
||||
private final FaceFeatureStore.Match match;
|
||||
|
||||
ZhitouFaceRecognizedTask(WebView webView, FaceFeatureStore.Match match) {
|
||||
this.webView = webView;
|
||||
this.match = match;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (webView == null || match == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("faceId", match.faceId);
|
||||
payload.put("employeeNo", match.employeeNo);
|
||||
payload.put("name", match.name);
|
||||
payload.put("similarity", match.similarity);
|
||||
webView.evaluateJavascript(
|
||||
"window.XingyuanSerial&&window.XingyuanSerial.onFaceRecognized(" + payload.toString() + ");",
|
||||
null);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouFaceStatusTask implements Runnable {
|
||||
private final WebView webView;
|
||||
private final String status;
|
||||
private final String message;
|
||||
private final int enrolledCount;
|
||||
|
||||
ZhitouFaceStatusTask(WebView webView, String status, String message, int enrolledCount) {
|
||||
this.webView = webView;
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
this.enrolledCount = enrolledCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (webView == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("status", status);
|
||||
payload.put("message", message);
|
||||
payload.put("enrolledCount", enrolledCount);
|
||||
webView.evaluateJavascript(
|
||||
"window.XingyuanSerial&&window.XingyuanSerial.onFaceStatus(" + payload.toString() + ");",
|
||||
null);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
final class ZhitouHeartbeatTask implements Runnable {
|
||||
private final ZhitouTcpReporter reporter;
|
||||
|
||||
ZhitouHeartbeatTask(ZhitouTcpReporter reporter) {
|
||||
this.reporter = reporter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
reporter.sendHeartbeatNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouMemberInfo {
|
||||
int verifyStatus;
|
||||
String memberId = "";
|
||||
String memberCode = "";
|
||||
String name = "";
|
||||
String balance = "";
|
||||
int resiCount;
|
||||
String error = "";
|
||||
|
||||
boolean isVerified() {
|
||||
return verifyStatus == 1;
|
||||
}
|
||||
|
||||
String toJson() {
|
||||
JSONObject data = new JSONObject();
|
||||
try {
|
||||
data.put("ok", isVerified());
|
||||
data.put("verifyStatus", verifyStatus);
|
||||
data.put("memberId", memberId == null ? "" : memberId);
|
||||
data.put("memberCode", memberCode == null ? "" : memberCode);
|
||||
data.put("name", name == null ? "" : name);
|
||||
data.put("balance", balance == null ? "" : balance);
|
||||
data.put("resiCount", resiCount);
|
||||
data.put("error", error == null ? "" : error);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return data.toString();
|
||||
}
|
||||
|
||||
static ZhitouMemberInfo failed(String error) {
|
||||
ZhitouMemberInfo info = new ZhitouMemberInfo();
|
||||
info.verifyStatus = 0;
|
||||
info.error = error == null ? "" : error;
|
||||
return info;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouNativeLogTask implements Runnable {
|
||||
private final WebView webView;
|
||||
private final String kind;
|
||||
private final String message;
|
||||
|
||||
ZhitouNativeLogTask(WebView webView, String kind, String message) {
|
||||
this.webView = webView;
|
||||
this.kind = kind == null ? "传输" : kind;
|
||||
this.message = message == null ? "" : message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
String js = "window.ZhitouSerial&&window.ZhitouSerial.onNativeLog("
|
||||
+ JSONObject.quote(kind) + "," + JSONObject.quote(message) + ");";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
final class ZhitouOpenFileManagerTask implements Runnable {
|
||||
private final MainActivity activity;
|
||||
|
||||
ZhitouOpenFileManagerTask(MainActivity activity) {
|
||||
this.activity = activity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
activity.openFileManager();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
final class ZhitouOpenSystemSettingsTask implements Runnable {
|
||||
private final MainActivity activity;
|
||||
|
||||
ZhitouOpenSystemSettingsTask(MainActivity activity) {
|
||||
this.activity = activity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
activity.openSystemSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
final class ZhitouSendFrameTask implements Runnable {
|
||||
private final ZhitouTcpReporter reporter;
|
||||
private final int orderCode;
|
||||
private final byte[] body;
|
||||
|
||||
ZhitouSendFrameTask(ZhitouTcpReporter reporter, int orderCode, byte[] body) {
|
||||
this.reporter = reporter;
|
||||
this.orderCode = orderCode;
|
||||
this.body = body.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
reporter.sendFrameNow(orderCode, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouSerialDeviceStatusTask implements Runnable {
|
||||
private final WebView webView;
|
||||
private final ZhitouSerialPortManager.DeviceStatusItem item;
|
||||
|
||||
ZhitouSerialDeviceStatusTask(WebView webView, ZhitouSerialPortManager.DeviceStatusItem item) {
|
||||
this.webView = webView;
|
||||
this.item = item;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
JSONObject payload = new JSONObject();
|
||||
try {
|
||||
payload.put("port", item.port);
|
||||
if (!Double.isNaN(item.temperature)) {
|
||||
payload.put("temperature", item.temperature);
|
||||
}
|
||||
if (item.range >= 0) {
|
||||
payload.put("range", item.range);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
String js = "window.ZhitouSerial&&window.ZhitouSerial.onDeviceStatus(" + payload.toString() + ");";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouSerialIdentityTask implements Runnable {
|
||||
private final WebView webView;
|
||||
private final int type;
|
||||
private final String identity;
|
||||
|
||||
ZhitouSerialIdentityTask(WebView webView, int type, String identity) {
|
||||
this.webView = webView;
|
||||
this.type = type;
|
||||
this.identity = identity == null ? "" : identity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
String js = "window.ZhitouSerial&&window.ZhitouSerial.onIdentity("
|
||||
+ type + "," + JSONObject.quote(identity) + ");";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouSerialImeiRequestTask implements Runnable {
|
||||
private final ZhitouSerialPortManager serialPortManager;
|
||||
private final String json;
|
||||
|
||||
ZhitouSerialImeiRequestTask(ZhitouSerialPortManager serialPortManager, String json) {
|
||||
this.serialPortManager = serialPortManager;
|
||||
this.json = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
JSONObject data = json == null || json.length() == 0 ? new JSONObject() : new JSONObject(json);
|
||||
serialPortManager.requestImei(
|
||||
data.optString("path", "/dev/ttyS4"),
|
||||
data.optInt("baudRate", 9600));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouSerialImeiTask implements Runnable {
|
||||
private final WebView webView;
|
||||
private final String imei;
|
||||
|
||||
ZhitouSerialImeiTask(WebView webView, String imei) {
|
||||
this.webView = webView;
|
||||
this.imei = imei == null ? "" : imei;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
String js = "window.ZhitouSerial&&window.ZhitouSerial.onImei("
|
||||
+ JSONObject.quote(imei) + ");";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
import android_serialport_api.SerialPort;
|
||||
|
||||
final class ZhitouSerialPortManager {
|
||||
private static final class SerialWriteTask implements Runnable {
|
||||
private final ZhitouSerialPortManager manager;
|
||||
private final byte[] frame;
|
||||
private final long generation;
|
||||
|
||||
SerialWriteTask(ZhitouSerialPortManager manager, byte[] frame, long generation) {
|
||||
this.manager = manager;
|
||||
this.frame = frame;
|
||||
this.generation = generation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
manager.writeFrame(frame, generation);
|
||||
}
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
void onImei(String imei);
|
||||
|
||||
void onVersion(String version);
|
||||
|
||||
void onIdentity(int type, String identity);
|
||||
|
||||
void onThrowWeight(ThrowWeightItem item);
|
||||
|
||||
void onDeviceStatus(DeviceStatusItem item);
|
||||
}
|
||||
|
||||
static final class ThrowWeightItem {
|
||||
final int port;
|
||||
final double weight;
|
||||
final double beforeWeight;
|
||||
final double afterWeight;
|
||||
final int range;
|
||||
final boolean queryResponse;
|
||||
|
||||
ThrowWeightItem(int port, double weight, double beforeWeight, double afterWeight, int range,
|
||||
boolean queryResponse) {
|
||||
this.port = port;
|
||||
this.weight = weight;
|
||||
this.beforeWeight = beforeWeight;
|
||||
this.afterWeight = afterWeight;
|
||||
this.range = range;
|
||||
this.queryResponse = queryResponse;
|
||||
}
|
||||
}
|
||||
|
||||
static final class DeviceStatusItem {
|
||||
final int port;
|
||||
final double temperature;
|
||||
final int range;
|
||||
|
||||
DeviceStatusItem(int port, double temperature, int range) {
|
||||
this.port = port;
|
||||
this.temperature = temperature;
|
||||
this.range = range;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String TAG = "ZhitouSerial";
|
||||
private static final String DEFAULT_PATH = "/dev/ttyS4";
|
||||
private static final int DEFAULT_BAUD_RATE = 9600;
|
||||
private static final long SERIAL_CHECK_WAIT_MS = 1800;
|
||||
private static final long WRITE_GAP_MS = 40;
|
||||
private static final Charset GBK = Charset.forName("GBK");
|
||||
private static final byte ORDER_MEMBER_VERIFY = 0x01;
|
||||
private static final byte ORDER_OPEN_THROW_DOOR = 0x02;
|
||||
private static final byte ORDER_CLOSE_THROW_DOOR = 0x03;
|
||||
private static final byte ORDER_OPEN_RECEIVE_DOOR = 0x04;
|
||||
private static final byte ORDER_QUERY_WEIGHT = 0x20;
|
||||
private static final byte ORDER_QUERY_FILL = 0x21;
|
||||
private static final byte ORDER_QUERY_TEMPERATURE = 0x22;
|
||||
private static final byte ORDER_DEVICE_VERSION = 0x30;
|
||||
private static final byte ORDER_DEVICE_IMEI = 0x31;
|
||||
private static final byte ORDER_CLOSE_TIME = 0x60;
|
||||
private static final byte ORDER_THROW_WEIGHT = 0x61;
|
||||
private static final byte ORDER_DEVICE_STATUS = 0x62;
|
||||
private static final byte ORDER_GPS = 0x67;
|
||||
private static final byte ORDER_POWER_ON = (byte) 0x90;
|
||||
|
||||
private final Listener listener;
|
||||
private final Object ioLock = new Object();
|
||||
private final Object imeiLock = new Object();
|
||||
private final Object versionLock = new Object();
|
||||
private final byte[] receiveBuffer = new byte[4096];
|
||||
private final ExecutorService writeExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
private SerialPort serialPort;
|
||||
private InputStream inputStream;
|
||||
private OutputStream outputStream;
|
||||
private Thread readThread;
|
||||
private String currentPath = DEFAULT_PATH;
|
||||
private int currentBaudRate = DEFAULT_BAUD_RATE;
|
||||
private int receiveLength;
|
||||
private volatile boolean running;
|
||||
private volatile String lastImei = "";
|
||||
private volatile String lastVersion = "";
|
||||
private volatile long lastValidFrameAt;
|
||||
private volatile long connectionGeneration;
|
||||
private volatile boolean destroyed;
|
||||
|
||||
ZhitouSerialPortManager(Listener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
boolean checkSerial(String path, int baudRate) {
|
||||
long baseline;
|
||||
if (!ensureOpen(path, baudRate)) {
|
||||
return false;
|
||||
}
|
||||
if (hasRecentValidFrame()) {
|
||||
return true;
|
||||
}
|
||||
baseline = lastValidFrameAt;
|
||||
// 启动自检必须等到下位机真实回包,不能只用“串口能打开”判断在线。
|
||||
if (!sendFrame(ORDER_DEVICE_IMEI, new byte[0])) {
|
||||
return false;
|
||||
}
|
||||
return waitForValidFrameAfter(baseline, SERIAL_CHECK_WAIT_MS);
|
||||
}
|
||||
|
||||
String requestImei(String path, int baudRate) {
|
||||
if (!ensureOpen(path, baudRate)) {
|
||||
return "";
|
||||
}
|
||||
synchronized (imeiLock) {
|
||||
lastImei = "";
|
||||
}
|
||||
sendFrame(ORDER_DEVICE_IMEI, new byte[0]);
|
||||
return waitForValue(imeiLock, true);
|
||||
}
|
||||
|
||||
String requestVersion(String path, int baudRate) {
|
||||
if (!ensureOpen(path, baudRate)) {
|
||||
return "";
|
||||
}
|
||||
synchronized (versionLock) {
|
||||
lastVersion = "";
|
||||
}
|
||||
sendFrame(ORDER_DEVICE_VERSION, new byte[0]);
|
||||
return waitForValue(versionLock, false);
|
||||
}
|
||||
|
||||
boolean openThrowDoor(String path, int baudRate, int port) {
|
||||
if (!ensureOpen(path, baudRate)) {
|
||||
return false;
|
||||
}
|
||||
return sendFrame(ORDER_OPEN_THROW_DOOR, new byte[] { 0x01, (byte) clamp(port, 0, 255) });
|
||||
}
|
||||
|
||||
boolean closeThrowDoor(String path, int baudRate) {
|
||||
if (!ensureOpen(path, baudRate)) {
|
||||
return false;
|
||||
}
|
||||
return sendFrame(ORDER_CLOSE_THROW_DOOR, new byte[] { 0x01 });
|
||||
}
|
||||
|
||||
boolean openReceiveDoor(String path, int baudRate, int doorIndex) {
|
||||
if (!ensureOpen(path, baudRate)) {
|
||||
return false;
|
||||
}
|
||||
return sendFrame(ORDER_OPEN_RECEIVE_DOOR, new byte[] { 0x01, (byte) clamp(doorIndex, 0, 255) });
|
||||
}
|
||||
|
||||
boolean queryWeight(String path, int baudRate, int port) {
|
||||
if (!ensureOpen(path, baudRate)) {
|
||||
return false;
|
||||
}
|
||||
return sendFrame(ORDER_QUERY_WEIGHT, new byte[] { 0x01, (byte) clamp(port, 0, 255) });
|
||||
}
|
||||
|
||||
boolean queryFill(String path, int baudRate, int port) {
|
||||
if (!ensureOpen(path, baudRate)) {
|
||||
return false;
|
||||
}
|
||||
return sendFrame(ORDER_QUERY_FILL, new byte[] { 0x01, (byte) clamp(port, 0, 255) });
|
||||
}
|
||||
|
||||
boolean queryTemperature(String path, int baudRate, int port) {
|
||||
if (!ensureOpen(path, baudRate)) {
|
||||
return false;
|
||||
}
|
||||
return sendFrame(ORDER_QUERY_TEMPERATURE, new byte[] { 0x01, (byte) clamp(port, 0, 255) });
|
||||
}
|
||||
|
||||
void close() {
|
||||
synchronized (ioLock) {
|
||||
connectionGeneration += 1;
|
||||
running = false;
|
||||
if (inputStream != null) {
|
||||
try {
|
||||
inputStream.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
inputStream = null;
|
||||
if (outputStream != null) {
|
||||
try {
|
||||
outputStream.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
outputStream = null;
|
||||
if (serialPort != null) {
|
||||
try {
|
||||
serialPort.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
serialPort = null;
|
||||
readThread = null;
|
||||
receiveLength = 0;
|
||||
lastValidFrameAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
destroyed = true;
|
||||
close();
|
||||
writeExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private boolean ensureOpen(String path, int baudRate) {
|
||||
if (destroyed) {
|
||||
return false;
|
||||
}
|
||||
String nextPath = normalizePath(path);
|
||||
int nextBaudRate = baudRate > 0 ? baudRate : DEFAULT_BAUD_RATE;
|
||||
synchronized (ioLock) {
|
||||
if (serialPort != null && outputStream != null && inputStream != null
|
||||
&& nextPath.equals(currentPath) && nextBaudRate == currentBaudRate) {
|
||||
return true;
|
||||
}
|
||||
close();
|
||||
currentPath = nextPath;
|
||||
currentBaudRate = nextBaudRate;
|
||||
try {
|
||||
serialPort = new SerialPort(new File(currentPath), currentBaudRate, 0);
|
||||
inputStream = serialPort.getInputStream();
|
||||
outputStream = serialPort.getOutputStream();
|
||||
running = true;
|
||||
long readGeneration = connectionGeneration;
|
||||
readThread = new Thread(
|
||||
new ZhitouSerialReadLoop(this, readGeneration),
|
||||
"zhitou-serial-read");
|
||||
readThread.setDaemon(true);
|
||||
readThread.start();
|
||||
Log.i(TAG, "opened " + currentPath + " baud=" + currentBaudRate);
|
||||
return true;
|
||||
} catch (Throwable error) {
|
||||
Log.e(TAG, "open failed path=" + currentPath + " baud=" + currentBaudRate, error);
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean sendFrame(byte orderCode, byte[] body) {
|
||||
final byte[] frame = buildFrame(orderCode, body == null ? new byte[0] : body);
|
||||
final long generation;
|
||||
synchronized (ioLock) {
|
||||
if (destroyed || outputStream == null) {
|
||||
return false;
|
||||
}
|
||||
generation = connectionGeneration;
|
||||
}
|
||||
try {
|
||||
writeExecutor.execute(new SerialWriteTask(this, frame, generation));
|
||||
return true;
|
||||
} catch (RejectedExecutionException error) {
|
||||
Log.w(TAG, "send rejected because serial manager is shutting down");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void writeFrame(byte[] frame, long generation) {
|
||||
synchronized (ioLock) {
|
||||
if (destroyed || generation != connectionGeneration || outputStream == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
outputStream.write(frame);
|
||||
outputStream.flush();
|
||||
Log.i(TAG, "send " + toHex(frame));
|
||||
} catch (Exception error) {
|
||||
Log.e(TAG, "send failed " + toHex(frame), error);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
Thread.sleep(WRITE_GAP_MS);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildFrame(byte orderCode, byte[] body) {
|
||||
byte[] result = new byte[body.length + 10];
|
||||
int index = 0;
|
||||
result[index++] = (byte) 0xfe;
|
||||
result[index++] = (byte) 0xfe;
|
||||
result[index++] = 0x68;
|
||||
result[index++] = 0x00;
|
||||
result[index++] = 0x68;
|
||||
result[index++] = orderCode;
|
||||
result[index++] = (byte) ((body.length >> 8) & 0xff);
|
||||
result[index++] = (byte) (body.length & 0xff);
|
||||
System.arraycopy(body, 0, result, index, body.length);
|
||||
index += body.length;
|
||||
result[index++] = checksum(orderCode, body);
|
||||
result[index] = 0x16;
|
||||
return result;
|
||||
}
|
||||
|
||||
private byte checksum(byte orderCode, byte[] body) {
|
||||
int sum = 0x68 + 0x00 + 0x68 + unsigned(orderCode);
|
||||
sum += (body.length >> 8) & 0xff;
|
||||
sum += body.length & 0xff;
|
||||
for (byte b : body) {
|
||||
sum += unsigned(b);
|
||||
}
|
||||
return (byte) sum;
|
||||
}
|
||||
|
||||
private String waitForValue(Object lock, boolean imei) {
|
||||
long deadline = System.currentTimeMillis() + 2200;
|
||||
synchronized (lock) {
|
||||
while ((imei ? lastImei : lastVersion).length() == 0) {
|
||||
long remaining = deadline - System.currentTimeMillis();
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
lock.wait(remaining);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return imei ? lastImei : lastVersion;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isRunning(long generation) {
|
||||
return running && generation == connectionGeneration;
|
||||
}
|
||||
|
||||
void readSerialOnce(long generation) {
|
||||
if (!isRunning(generation)) {
|
||||
return;
|
||||
}
|
||||
InputStream stream = inputStream;
|
||||
if (stream == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
byte[] chunk = new byte[128];
|
||||
int size = stream.read(chunk);
|
||||
if (size > 0 && isRunning(generation)) {
|
||||
appendReceived(chunk, size);
|
||||
} else if (size < 0) {
|
||||
// EOF 表示本次连接已经失效,必须退出读循环,避免空转占满 CPU。
|
||||
closeIfCurrent(generation);
|
||||
}
|
||||
} catch (Exception error) {
|
||||
if (isRunning(generation)) {
|
||||
Log.e(TAG, "read failed", error);
|
||||
closeIfCurrent(generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void closeIfCurrent(long generation) {
|
||||
synchronized (ioLock) {
|
||||
if (generation != connectionGeneration) {
|
||||
return;
|
||||
}
|
||||
// 连接代次用于隔离旧读线程,避免快速重连后两个线程同时读取新串口。
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
private void appendReceived(byte[] chunk, int size) {
|
||||
synchronized (receiveBuffer) {
|
||||
if (size > receiveBuffer.length) {
|
||||
size = receiveBuffer.length;
|
||||
}
|
||||
if (receiveLength + size > receiveBuffer.length) {
|
||||
receiveLength = 0;
|
||||
}
|
||||
System.arraycopy(chunk, 0, receiveBuffer, receiveLength, size);
|
||||
receiveLength += size;
|
||||
Log.i(TAG, "recv " + toHex(chunk, size));
|
||||
parseFrames();
|
||||
}
|
||||
}
|
||||
|
||||
private void parseFrames() {
|
||||
int offset = 0;
|
||||
while (true) {
|
||||
int header = findHeader(offset);
|
||||
if (header < 0) {
|
||||
retainPartialHeader();
|
||||
return;
|
||||
}
|
||||
if (header > 0) {
|
||||
shiftLeft(header);
|
||||
header = 0;
|
||||
}
|
||||
if (receiveLength < 8) {
|
||||
return;
|
||||
}
|
||||
int length = (unsigned(receiveBuffer[4]) << 8) | unsigned(receiveBuffer[5]);
|
||||
int total = 8 + length;
|
||||
if (total > receiveBuffer.length) {
|
||||
shiftLeft(1);
|
||||
continue;
|
||||
}
|
||||
if (receiveLength < total) {
|
||||
return;
|
||||
}
|
||||
if (receiveBuffer[total - 1] != 0x16) {
|
||||
shiftLeft(1);
|
||||
continue;
|
||||
}
|
||||
byte order = receiveBuffer[3];
|
||||
byte[] body = new byte[length];
|
||||
System.arraycopy(receiveBuffer, 6, body, 0, length);
|
||||
byte actualChecksum = receiveBuffer[6 + length];
|
||||
byte expectedChecksum = checksum(order, body);
|
||||
if (actualChecksum == expectedChecksum) {
|
||||
lastValidFrameAt = System.currentTimeMillis();
|
||||
handleFrame(order, body);
|
||||
} else {
|
||||
Log.w(TAG, "checksum mismatch order=" + unsigned(order)
|
||||
+ " expected=" + unsigned(expectedChecksum)
|
||||
+ " actual=" + unsigned(actualChecksum));
|
||||
}
|
||||
shiftLeft(total);
|
||||
}
|
||||
}
|
||||
|
||||
private int findHeader(int offset) {
|
||||
for (int i = offset; i + 2 < receiveLength; i += 1) {
|
||||
if (receiveBuffer[i] == 0x68 && receiveBuffer[i + 1] == 0x00 && receiveBuffer[i + 2] == 0x68) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void retainPartialHeader() {
|
||||
int keep = 0;
|
||||
if (receiveLength >= 2
|
||||
&& receiveBuffer[receiveLength - 2] == 0x68
|
||||
&& receiveBuffer[receiveLength - 1] == 0x00) {
|
||||
keep = 2;
|
||||
} else if (receiveLength >= 1 && receiveBuffer[receiveLength - 1] == 0x68) {
|
||||
keep = 1;
|
||||
}
|
||||
if (keep > 0) {
|
||||
System.arraycopy(receiveBuffer, receiveLength - keep, receiveBuffer, 0, keep);
|
||||
}
|
||||
receiveLength = keep;
|
||||
}
|
||||
|
||||
private void shiftLeft(int count) {
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
if (count >= receiveLength) {
|
||||
receiveLength = 0;
|
||||
return;
|
||||
}
|
||||
System.arraycopy(receiveBuffer, count, receiveBuffer, 0, receiveLength - count);
|
||||
receiveLength -= count;
|
||||
}
|
||||
|
||||
private void handleFrame(byte order, byte[] body) {
|
||||
int code = unsigned(order);
|
||||
if (code == unsigned(ORDER_MEMBER_VERIFY)) {
|
||||
handleIdentity(body);
|
||||
sendFrame(ORDER_MEMBER_VERIFY, new byte[0]);
|
||||
} else if (code == unsigned(ORDER_DEVICE_IMEI)) {
|
||||
handleImei(body);
|
||||
} else if (code == unsigned(ORDER_DEVICE_VERSION)) {
|
||||
handleVersion(body);
|
||||
} else if (code == unsigned(ORDER_OPEN_RECEIVE_DOOR)) {
|
||||
Log.i(TAG, "receive door command result");
|
||||
} else if (code == unsigned(ORDER_QUERY_WEIGHT)) {
|
||||
handleQueryWeight(body);
|
||||
} else if (code == unsigned(ORDER_QUERY_FILL)) {
|
||||
handleQueryFill(body);
|
||||
} else if (code == unsigned(ORDER_QUERY_TEMPERATURE)) {
|
||||
handleQueryTemperature(body);
|
||||
} else if (code == unsigned(ORDER_THROW_WEIGHT)) {
|
||||
handleThrowWeight(body);
|
||||
sendFrame(ORDER_THROW_WEIGHT, new byte[0]);
|
||||
} else if (code == unsigned(ORDER_DEVICE_STATUS)) {
|
||||
sendFrame(ORDER_DEVICE_STATUS, new byte[0]);
|
||||
handleDeviceStatus(body);
|
||||
} else if (code == unsigned(ORDER_CLOSE_TIME)) {
|
||||
sendFrame(ORDER_CLOSE_TIME, new byte[0]);
|
||||
} else if (code == unsigned(ORDER_GPS)) {
|
||||
sendFrame(ORDER_GPS, new byte[0]);
|
||||
} else if (code == unsigned(ORDER_POWER_ON)) {
|
||||
sendFrame(ORDER_POWER_ON, new byte[0]);
|
||||
} else {
|
||||
Log.i(TAG, "unhandled order=0x" + Integer.toHexString(code));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleIdentity(byte[] body) {
|
||||
if (body.length < 3) {
|
||||
return;
|
||||
}
|
||||
int version = unsigned(body[0]);
|
||||
if (version != 1) {
|
||||
return;
|
||||
}
|
||||
int type = unsigned(body[1]);
|
||||
int length = Math.min(unsigned(body[2]), body.length - 3);
|
||||
String identity = new String(body, 3, Math.max(length, 0), GBK).trim();
|
||||
if (identity.startsWith("http") && identity.contains("card=")) {
|
||||
identity = identity.substring(identity.indexOf("card=") + 5);
|
||||
}
|
||||
if (identity.matches("(?i)^[0-9a-f]{8}$")) {
|
||||
type = 1;
|
||||
}
|
||||
if (identity.length() > 0 && listener != null) {
|
||||
listener.onIdentity(type, identity);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleImei(byte[] body) {
|
||||
if (body.length < 2) {
|
||||
return;
|
||||
}
|
||||
int length = Math.min(unsigned(body[1]), body.length - 2);
|
||||
String imei = new String(body, 2, Math.max(length, 0), GBK).trim();
|
||||
synchronized (imeiLock) {
|
||||
lastImei = imei;
|
||||
imeiLock.notifyAll();
|
||||
}
|
||||
if (imei.length() > 0 && listener != null) {
|
||||
listener.onImei(imei);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleVersion(byte[] body) {
|
||||
if (body.length < 2) {
|
||||
return;
|
||||
}
|
||||
int length = Math.min(unsigned(body[1]), body.length - 2);
|
||||
String version = new String(body, 2, Math.max(length, 0), GBK).trim();
|
||||
synchronized (versionLock) {
|
||||
lastVersion = version;
|
||||
versionLock.notifyAll();
|
||||
}
|
||||
if (version.length() > 0 && listener != null) {
|
||||
listener.onVersion(version);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleThrowWeight(byte[] body) {
|
||||
if (body.length < 2 || listener == null) {
|
||||
Log.w(TAG, "invalid throw weight body length=" + body.length + " data=" + toHex(body));
|
||||
return;
|
||||
}
|
||||
int count = unsigned(body[1]);
|
||||
int expectedLength = 2 + count * 8;
|
||||
if (body.length < expectedLength) {
|
||||
Log.w(TAG, "invalid throw weight body count=" + count
|
||||
+ " expected=" + expectedLength + " actual=" + body.length
|
||||
+ " data=" + toHex(body));
|
||||
return;
|
||||
}
|
||||
List<ThrowWeightItem> items = new ArrayList<>();
|
||||
for (int i = 0; i < count; i += 1) {
|
||||
int base = 2 + i * 8;
|
||||
if (base + 7 >= body.length) {
|
||||
break;
|
||||
}
|
||||
int port = unsigned(body[base]);
|
||||
double weight = centWeight(body, base + 1);
|
||||
double before = centWeight(body, base + 3);
|
||||
double after = centWeight(body, base + 5);
|
||||
int range = unsigned(body[base + 7]);
|
||||
items.add(new ThrowWeightItem(port, weight, before, after, range, false));
|
||||
}
|
||||
for (ThrowWeightItem item : items) {
|
||||
listener.onThrowWeight(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleQueryWeight(byte[] body) {
|
||||
if (body.length < 2 || listener == null) {
|
||||
return;
|
||||
}
|
||||
int count = unsigned(body[1]);
|
||||
for (int i = 0; i < count; i += 1) {
|
||||
int base = 2 + i * 3;
|
||||
if (base + 2 >= body.length) {
|
||||
break;
|
||||
}
|
||||
int port = unsigned(body[base]);
|
||||
double weight = centWeight(body, base + 1);
|
||||
listener.onThrowWeight(new ThrowWeightItem(port, weight, weight, weight, -1, true));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleQueryFill(byte[] body) {
|
||||
if (body.length < 2 || listener == null) {
|
||||
return;
|
||||
}
|
||||
int count = unsigned(body[1]);
|
||||
for (int i = 0; i < count; i += 1) {
|
||||
int base = 2 + i * 2;
|
||||
if (base + 1 >= body.length) {
|
||||
break;
|
||||
}
|
||||
listener.onDeviceStatus(new DeviceStatusItem(unsigned(body[base]), Double.NaN, unsigned(body[base + 1])));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleQueryTemperature(byte[] body) {
|
||||
if (body.length < 2 || listener == null) {
|
||||
return;
|
||||
}
|
||||
int count = unsigned(body[1]);
|
||||
for (int i = 0; i < count; i += 1) {
|
||||
int base = 2 + i * 4;
|
||||
if (base + 3 >= body.length) {
|
||||
break;
|
||||
}
|
||||
listener.onDeviceStatus(new DeviceStatusItem(unsigned(body[base]), signedTemperature(body, base + 1), -1));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDeviceStatus(byte[] body) {
|
||||
if (body.length < 2 || listener == null) {
|
||||
return;
|
||||
}
|
||||
int count = unsigned(body[1]);
|
||||
for (int i = 0; i < count; i += 1) {
|
||||
int base = 2 + i * 5;
|
||||
if (base + 4 >= body.length) {
|
||||
break;
|
||||
}
|
||||
int port = unsigned(body[base]);
|
||||
double temperature = signedTemperature(body, base + 1);
|
||||
int range = unsigned(body[base + 4]);
|
||||
listener.onDeviceStatus(new DeviceStatusItem(port, temperature, range));
|
||||
}
|
||||
}
|
||||
|
||||
private double centWeight(byte[] bytes, int index) {
|
||||
int value = (unsigned(bytes[index]) << 8) | unsigned(bytes[index + 1]);
|
||||
return new BigDecimal(value).divide(new BigDecimal(100)).doubleValue();
|
||||
}
|
||||
|
||||
private double signedTemperature(byte[] bytes, int signIndex) {
|
||||
int value = (unsigned(bytes[signIndex + 1]) << 8) | unsigned(bytes[signIndex + 2]);
|
||||
BigDecimal temperature = new BigDecimal(value).divide(new BigDecimal(10));
|
||||
return bytes[signIndex] == 0x01 ? temperature.negate().doubleValue() : temperature.doubleValue();
|
||||
}
|
||||
|
||||
private String normalizePath(String path) {
|
||||
if (path == null || path.trim().length() == 0) {
|
||||
return DEFAULT_PATH;
|
||||
}
|
||||
return path.trim();
|
||||
}
|
||||
|
||||
private boolean hasRecentValidFrame() {
|
||||
long at = lastValidFrameAt;
|
||||
return at > 0 && System.currentTimeMillis() - at <= 15000;
|
||||
}
|
||||
|
||||
private boolean waitForValidFrameAfter(long baseline, long timeoutMs) {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (lastValidFrameAt > baseline) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(40);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int unsigned(byte value) {
|
||||
return value & 0xff;
|
||||
}
|
||||
|
||||
private static int clamp(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private static String toHex(byte[] data) {
|
||||
return toHex(data, data.length);
|
||||
}
|
||||
|
||||
private static String toHex(byte[] data, int size) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < size; i += 1) {
|
||||
if (i > 0) {
|
||||
builder.append(' ');
|
||||
}
|
||||
int value = data[i] & 0xff;
|
||||
if (value < 16) {
|
||||
builder.append('0');
|
||||
}
|
||||
builder.append(Integer.toHexString(value).toUpperCase(Locale.ROOT));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
final class ZhitouSerialReadLoop implements Runnable {
|
||||
private final ZhitouSerialPortManager manager;
|
||||
private final long generation;
|
||||
|
||||
ZhitouSerialReadLoop(ZhitouSerialPortManager manager, long generation) {
|
||||
this.manager = manager;
|
||||
this.generation = generation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
while (manager.isRunning(generation)) {
|
||||
manager.readSerialOnce(generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouSerialThrowWeightTask implements Runnable {
|
||||
private final WebView webView;
|
||||
private final ZhitouSerialPortManager.ThrowWeightItem item;
|
||||
|
||||
ZhitouSerialThrowWeightTask(WebView webView, ZhitouSerialPortManager.ThrowWeightItem item) {
|
||||
this.webView = webView;
|
||||
this.item = item;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
JSONObject payload = new JSONObject();
|
||||
try {
|
||||
payload.put("port", item.port);
|
||||
payload.put("weight", item.weight);
|
||||
payload.put("beforeWeight", item.beforeWeight);
|
||||
payload.put("afterWeight", item.afterWeight);
|
||||
payload.put("queryResponse", item.queryResponse);
|
||||
if (item.range >= 0) {
|
||||
payload.put("range", item.range);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
String js = "window.ZhitouSerial&&window.ZhitouSerial.onThrowWeight(" + payload.toString() + ");";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouSerialVersionRequestTask implements Runnable {
|
||||
private final ZhitouSerialPortManager serialPortManager;
|
||||
private final String json;
|
||||
|
||||
ZhitouSerialVersionRequestTask(ZhitouSerialPortManager serialPortManager, String json) {
|
||||
this.serialPortManager = serialPortManager;
|
||||
this.json = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
JSONObject data = json == null || json.length() == 0 ? new JSONObject() : new JSONObject(json);
|
||||
serialPortManager.requestVersion(
|
||||
data.optString("path", "/dev/ttyS4"),
|
||||
data.optInt("baudRate", 9600));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.webkit.WebView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class ZhitouSerialVersionTask implements Runnable {
|
||||
private final WebView webView;
|
||||
private final String version;
|
||||
|
||||
ZhitouSerialVersionTask(WebView webView, String version) {
|
||||
this.webView = webView;
|
||||
this.version = version == null ? "" : version;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
String js = "window.ZhitouSerial&&window.ZhitouSerial.onVersion("
|
||||
+ JSONObject.quote(version) + ");";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.Locale;
|
||||
|
||||
final class ZhitouTcpReporter {
|
||||
interface Listener {
|
||||
void onReporterLog(String kind, String message);
|
||||
}
|
||||
|
||||
private static final String TAG = "ZhitouTcpReporter";
|
||||
private static final Charset UTF8 = Charset.forName("UTF-8");
|
||||
private static final Charset ASCII = Charset.forName("US-ASCII");
|
||||
private static final byte[] HEADER = "HERO".getBytes(ASCII);
|
||||
private static final int ORDER_HEART = 1001;
|
||||
private static final int ORDER_MEMBER_INFO = 4301;
|
||||
private static final int ORDER_MEMBER_INFO_RESP = 4302;
|
||||
private static final int ORDER_DEVICE_STATUS_2003 = 2003;
|
||||
private static final int ORDER_THROW_INFO = 6011;
|
||||
private static final int ORDER_ALARM_EVENT = 6006;
|
||||
private static final int DEVICE_TYPE_GARBAGE_CAN = 0x01;
|
||||
private static final int HEARTBEAT_INTERVAL_SECONDS = 30;
|
||||
private static final int STATUS_INTERVAL_SECONDS = 10 * 60;
|
||||
|
||||
private final Context context;
|
||||
private final ExecutorService sender = Executors.newSingleThreadExecutor();
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
private final Object socketLock = new Object();
|
||||
private final Listener listener;
|
||||
private final AtomicBoolean stopped = new AtomicBoolean(false);
|
||||
private ScheduledFuture<?> heartbeatFuture;
|
||||
private ScheduledFuture<?> statusFuture;
|
||||
private Socket socket;
|
||||
private InputStream inputStream;
|
||||
private OutputStream outputStream;
|
||||
private volatile String host = "112.74.41.228";
|
||||
private volatile int port = 9956;
|
||||
private volatile long terminalId;
|
||||
private long lastDeviceStatusSentAt;
|
||||
private int serialNum = 1;
|
||||
|
||||
ZhitouTcpReporter(Context context, Listener listener) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.listener = listener;
|
||||
this.terminalId = 0L;
|
||||
}
|
||||
|
||||
synchronized void configure(String host, int port, String terminalIdText) {
|
||||
if (stopped.get()) {
|
||||
return;
|
||||
}
|
||||
boolean connectionChanged = false;
|
||||
boolean terminalChanged = false;
|
||||
if (host != null && host.trim().length() > 0) {
|
||||
String nextHost = host.trim();
|
||||
if (!nextHost.equals(this.host)) {
|
||||
this.host = nextHost;
|
||||
connectionChanged = true;
|
||||
}
|
||||
}
|
||||
if (port > 0) {
|
||||
if (this.port != port) {
|
||||
this.port = port;
|
||||
connectionChanged = true;
|
||||
}
|
||||
}
|
||||
if (terminalIdText != null) {
|
||||
long parsed = parseTerminalId(terminalIdText);
|
||||
long nextTerminalId = parsed > 0 ? parsed : 0L;
|
||||
if (nextTerminalId != this.terminalId) {
|
||||
this.terminalId = nextTerminalId;
|
||||
connectionChanged = true;
|
||||
terminalChanged = true;
|
||||
}
|
||||
}
|
||||
if (connectionChanged) {
|
||||
closeSocketQuietly();
|
||||
}
|
||||
emitLog("传输", "配置 TCP " + this.host + ":" + this.port + " / IMEI " + getTerminalIdText());
|
||||
if (terminalChanged && this.terminalId > 0) {
|
||||
sendHeartbeatNow();
|
||||
sendDeviceStatusNow();
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void startHeartbeat() {
|
||||
if (stopped.get()) {
|
||||
return;
|
||||
}
|
||||
if (heartbeatFuture != null) {
|
||||
heartbeatFuture.cancel(false);
|
||||
}
|
||||
sendHeartbeatNow();
|
||||
heartbeatFuture = scheduler.scheduleAtFixedRate(
|
||||
new ZhitouHeartbeatTask(this),
|
||||
HEARTBEAT_INTERVAL_SECONDS,
|
||||
HEARTBEAT_INTERVAL_SECONDS,
|
||||
TimeUnit.SECONDS);
|
||||
startDeviceStatusReporter();
|
||||
emitLog("传输", "心跳任务已启动");
|
||||
}
|
||||
|
||||
private synchronized void startDeviceStatusReporter() {
|
||||
if (stopped.get()) {
|
||||
return;
|
||||
}
|
||||
if (statusFuture != null) {
|
||||
statusFuture.cancel(false);
|
||||
}
|
||||
sendDeviceStatusNow();
|
||||
statusFuture = scheduler.scheduleAtFixedRate(
|
||||
new ZhitouDeviceStatusTask(this),
|
||||
STATUS_INTERVAL_SECONDS,
|
||||
STATUS_INTERVAL_SECONDS,
|
||||
TimeUnit.SECONDS);
|
||||
emitLog("传输", "设备状态任务已启动");
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (!stopped.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (heartbeatFuture != null) {
|
||||
heartbeatFuture.cancel(false);
|
||||
}
|
||||
if (statusFuture != null) {
|
||||
statusFuture.cancel(false);
|
||||
}
|
||||
scheduler.shutdownNow();
|
||||
sender.shutdownNow();
|
||||
// connect() 最长可能阻塞 10 秒;退出 Activity 时异步收尾,避免主线程被 socketLock 卡住。
|
||||
Thread closer = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
closeSocketQuietly();
|
||||
}
|
||||
}, "zhitou-tcp-close");
|
||||
closer.setDaemon(true);
|
||||
closer.start();
|
||||
}
|
||||
|
||||
String getTerminalIdText() {
|
||||
return terminalId > 0 ? String.valueOf(terminalId) : "";
|
||||
}
|
||||
|
||||
void reportMemberVerify(int idType, String idContent) {
|
||||
sendFrameAsync(ORDER_MEMBER_INFO, buildMemberVerifyBody(idType, idContent));
|
||||
}
|
||||
|
||||
ZhitouMemberInfo requestMemberVerify(int idType, String idContent) {
|
||||
if (stopped.get()) {
|
||||
return ZhitouMemberInfo.failed("网络服务已停止");
|
||||
}
|
||||
if (terminalId <= 0) {
|
||||
emitLog("传输", "4301 跳过:未获取下位机IMEI");
|
||||
return ZhitouMemberInfo.failed("未获取下位机IMEI");
|
||||
}
|
||||
byte[] body = buildMemberVerifyBody(idType, idContent);
|
||||
byte[] frame = buildFrame(ORDER_MEMBER_INFO, body);
|
||||
try {
|
||||
synchronized (socketLock) {
|
||||
ensureSocket();
|
||||
socket.setSoTimeout(500);
|
||||
outputStream.write(frame);
|
||||
outputStream.flush();
|
||||
emitTx(ORDER_MEMBER_INFO, frame);
|
||||
long deadline = System.currentTimeMillis() + 8000;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ResponseFrame response = readResponseFrame(deadline);
|
||||
if (response == null) {
|
||||
continue;
|
||||
}
|
||||
emitRx(response);
|
||||
if (response.orderCode == ORDER_MEMBER_INFO_RESP) {
|
||||
return parseMemberInfo(response.body);
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "member verify timeout, closing socket and refreshing heartbeat");
|
||||
emitLog("传输", "4301 超时:未收到 4302");
|
||||
closeSocketQuietly();
|
||||
sendHeartbeatNow();
|
||||
return ZhitouMemberInfo.failed("会员资料返回超时");
|
||||
} catch (Exception error) {
|
||||
closeSocketQuietly();
|
||||
Log.e(TAG, "member verify failed", error);
|
||||
emitLog("传输", "4301 失败:" + error.getClass().getSimpleName());
|
||||
sendHeartbeatNow();
|
||||
return ZhitouMemberInfo.failed("会员鉴权通讯失败");
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildMemberVerifyBody(int idType, String idContent) {
|
||||
byte[] idBytes = fixedIdentityBytes(idType, idContent);
|
||||
byte[] body = new byte[3 + 33];
|
||||
int index = 0;
|
||||
body[index++] = (byte) DEVICE_TYPE_GARBAGE_CAN;
|
||||
body[index++] = (byte) idType;
|
||||
body[index++] = (byte) Math.min(rawIdentityLength(idType, idContent), 33);
|
||||
System.arraycopy(idBytes, 0, body, index, 33);
|
||||
return body;
|
||||
}
|
||||
|
||||
void reportDelivery(
|
||||
int idType,
|
||||
String idContent,
|
||||
int throwIndex,
|
||||
int garbageType,
|
||||
int range,
|
||||
double throwWeight,
|
||||
double beforeWeight,
|
||||
double afterWeight,
|
||||
long createdAt) {
|
||||
byte[] idBytes = fixedIdentityBytes(idType, idContent);
|
||||
byte[] body = new byte[1 + 1 + 33 + 1 + 2 + 1 + 2 + 2];
|
||||
int index = 0;
|
||||
body[index++] = (byte) idType;
|
||||
body[index++] = (byte) Math.min(rawIdentityLength(idType, idContent), 33);
|
||||
System.arraycopy(idBytes, 0, body, index, 33);
|
||||
index += 33;
|
||||
body[index++] = (byte) garbageType;
|
||||
putUInt16(body, index, kgToCentWeight(throwWeight));
|
||||
index += 2;
|
||||
body[index++] = (byte) clamp(range, 0, 255);
|
||||
putUInt16(body, index, kgToCentWeight(beforeWeight));
|
||||
index += 2;
|
||||
putUInt16(body, index, kgToCentWeight(afterWeight));
|
||||
sendFrameAsync(ORDER_THROW_INFO, body);
|
||||
}
|
||||
|
||||
void reportDeviceStatus(JSONObject data) {
|
||||
sendDeviceStatus(data);
|
||||
}
|
||||
|
||||
void sendDeviceStatusNow() {
|
||||
sendDeviceStatus(null);
|
||||
}
|
||||
|
||||
private synchronized void sendDeviceStatus(JSONObject data) {
|
||||
if (terminalId <= 0) {
|
||||
sendFrameAsync(ORDER_DEVICE_STATUS_2003, buildDeviceStatusBody(data));
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (lastDeviceStatusSentAt > 0 && now - lastDeviceStatusSentAt < 60 * 1000) {
|
||||
emitLog("传输", "设备状态 2003 跳过:60秒内已上报");
|
||||
return;
|
||||
}
|
||||
lastDeviceStatusSentAt = now;
|
||||
sendFrameAsync(ORDER_DEVICE_STATUS_2003, buildDeviceStatusBody(data));
|
||||
}
|
||||
|
||||
private byte[] buildDeviceStatusBody(JSONObject data) {
|
||||
if (data == null) {
|
||||
data = new JSONObject();
|
||||
}
|
||||
JSONArray ports = data.optJSONArray("ports");
|
||||
int count = ports == null ? 0 : ports.length();
|
||||
if (count == 0) {
|
||||
count = 2;
|
||||
}
|
||||
byte[] body = new byte[19 + count * 2];
|
||||
int index = 0;
|
||||
body[index++] = 0;
|
||||
double temperature = data.optDouble("temperature", 0);
|
||||
body[index++] = (byte) (temperature < 0 ? 1 : 0);
|
||||
putUInt16(body, index, (int) Math.round(Math.abs(temperature) * 10));
|
||||
index += 2;
|
||||
body[index++] = (byte) clamp(data.optInt("humidity", 0), 0, 255);
|
||||
body[index++] = (byte) clamp(data.optInt("signal", 30), 0, 255);
|
||||
body[index++] = hasLocation(data) ? (byte) 1 : (byte) 0;
|
||||
putUInt32(body, index, decimal4(data.optString("lng", "0")));
|
||||
index += 4;
|
||||
putUInt32(body, index, decimal4(data.optString("lat", "0")));
|
||||
index += 4;
|
||||
putUInt32(body, index, decimal4(data.optString("altitude", "0")));
|
||||
index += 4;
|
||||
for (int i = 0; i < count; i += 1) {
|
||||
JSONObject item = ports == null ? null : ports.optJSONObject(i);
|
||||
if (item == null) {
|
||||
body[index++] = (byte) (i == 0 ? 3 : 1);
|
||||
body[index++] = (byte) 150;
|
||||
} else {
|
||||
body[index++] = (byte) clamp(item.optInt("garbageType", 18), 0, 255);
|
||||
body[index++] = (byte) clamp(item.optInt("range", 0), 0, 255);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
void reportAlarm(JSONArray alarms) {
|
||||
if (alarms.length() == 0) return;
|
||||
boolean hot = false;
|
||||
for (int i = 0; i < alarms.length(); i += 1) {
|
||||
JSONObject alarm = alarms.optJSONObject(i);
|
||||
if (alarm != null && alarm.optInt("errorCode", 0) == 311) {
|
||||
hot = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
byte[] body = new byte[(hot ? 7 : 5) * alarms.length() + 2];
|
||||
int index = 0;
|
||||
body[index++] = 1;
|
||||
body[index++] = (byte) alarms.length();
|
||||
for (int i = 0; i < alarms.length(); i += 1) {
|
||||
JSONObject alarm = alarms.optJSONObject(i);
|
||||
if (alarm == null) alarm = new JSONObject();
|
||||
int errorCode = alarm.optInt("errorCode", 301);
|
||||
body[index++] = (byte) clamp(alarm.optInt("port", 0), 0, 255);
|
||||
body[index++] = (byte) clamp(alarm.optInt("garbageType", 18), 0, 255);
|
||||
putUInt16(body, index, errorCode);
|
||||
index += 2;
|
||||
body[index++] = (byte) clamp(alarm.optInt("isError", 1), 0, 1);
|
||||
if (hot) {
|
||||
if (errorCode == 311) {
|
||||
putUInt16(body, index, (int) Math.round(alarm.optDouble("temperature", 0) * 10));
|
||||
} else {
|
||||
putUInt16(body, index, 0);
|
||||
}
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
sendFrameAsync(ORDER_ALARM_EVENT, body);
|
||||
}
|
||||
|
||||
void sendHeartbeatNow() {
|
||||
byte[] body = new byte[1];
|
||||
body[0] = (byte) DEVICE_TYPE_GARBAGE_CAN;
|
||||
sendFrameAsync(ORDER_HEART, body);
|
||||
}
|
||||
|
||||
private void sendFrameAsync(int orderCode, byte[] body) {
|
||||
if (stopped.get()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sender.execute(new ZhitouSendFrameTask(this, orderCode, body));
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
// Activity 销毁与页面回调并发发生时,丢弃已经过期的上报任务。
|
||||
}
|
||||
}
|
||||
|
||||
void sendFrameNow(int orderCode, byte[] body) {
|
||||
if (stopped.get()) {
|
||||
return;
|
||||
}
|
||||
if (terminalId <= 0) {
|
||||
Log.w(TAG, "skip send order=" + orderCode + ", terminal imei not ready");
|
||||
emitLog("传输", "TX " + orderCode + " 跳过:未获取下位机IMEI");
|
||||
return;
|
||||
}
|
||||
byte[] frame = buildFrame(orderCode, body);
|
||||
try {
|
||||
synchronized (socketLock) {
|
||||
writeFrameLocked(frame);
|
||||
emitTx(orderCode, frame);
|
||||
drainResponsesLocked(350);
|
||||
}
|
||||
Log.i(TAG, "sent order=" + orderCode + " bytes=" + frame.length + " to " + host + ":" + port);
|
||||
} catch (Exception error) {
|
||||
closeSocketQuietly();
|
||||
Log.e(TAG, "send failed order=" + orderCode + " to " + host + ":" + port, error);
|
||||
emitLog("传输", "TX " + orderCode + " 失败:" + errorSummary(error));
|
||||
retryFrameOnce(orderCode, frame);
|
||||
}
|
||||
}
|
||||
|
||||
private void retryFrameOnce(int orderCode, byte[] frame) {
|
||||
if (stopped.get()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
synchronized (socketLock) {
|
||||
writeFrameLocked(frame);
|
||||
drainResponsesLocked(350);
|
||||
}
|
||||
Log.i(TAG, "retry sent order=" + orderCode + " bytes=" + frame.length + " to " + host + ":" + port);
|
||||
emitLog("传输", "TX " + orderCode + " 重连重发成功 bytes=" + frame.length);
|
||||
} catch (Exception retryError) {
|
||||
closeSocketQuietly();
|
||||
Log.e(TAG, "retry send failed order=" + orderCode + " to " + host + ":" + port, retryError);
|
||||
emitLog("传输", "TX " + orderCode + " 重连重发失败:" + errorSummary(retryError));
|
||||
}
|
||||
}
|
||||
|
||||
private void writeFrameLocked(byte[] frame) throws Exception {
|
||||
ensureSocket();
|
||||
socket.setSoTimeout(500);
|
||||
outputStream.write(frame);
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
private void ensureSocket() throws Exception {
|
||||
if (stopped.get()) {
|
||||
throw new IOException("TCP reporter stopped");
|
||||
}
|
||||
if (socket != null && socket.isConnected() && !socket.isClosed()) {
|
||||
return;
|
||||
}
|
||||
closeSocketQuietly();
|
||||
Socket nextSocket = new Socket();
|
||||
nextSocket.setKeepAlive(true);
|
||||
nextSocket.setTcpNoDelay(true);
|
||||
nextSocket.setSoTimeout(500);
|
||||
nextSocket.connect(new InetSocketAddress(host, port), 10000);
|
||||
if (stopped.get()) {
|
||||
try {
|
||||
nextSocket.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
throw new IOException("TCP reporter stopped");
|
||||
}
|
||||
socket = nextSocket;
|
||||
inputStream = nextSocket.getInputStream();
|
||||
outputStream = nextSocket.getOutputStream();
|
||||
Log.i(TAG, "connected to " + host + ":" + port);
|
||||
emitLog("传输", "TCP 已连接 " + host + ":" + port);
|
||||
}
|
||||
|
||||
private void emitLog(String kind, String message) {
|
||||
if (!stopped.get() && listener != null) {
|
||||
listener.onReporterLog(kind, message);
|
||||
}
|
||||
}
|
||||
|
||||
private void emitTx(int orderCode, byte[] frame) {
|
||||
emitLog("传输", "TX " + orderCode + " bytes=" + frame.length + " sn=" + frameSerial(frame));
|
||||
emitLog("HEX", "TX " + orderCode + " " + toHex(frame, 160));
|
||||
Log.i(TAG, "TX " + orderCode + " " + toHex(frame, 160));
|
||||
}
|
||||
|
||||
private void emitRx(ResponseFrame response) {
|
||||
emitLog("传输", "RX " + response.orderCode + " body=" + response.body.length + " sn=" + response.serialNum);
|
||||
emitLog("HEX", "RX " + response.orderCode + " " + toHex(response.fullFrame, 160));
|
||||
Log.i(TAG, "RX " + response.orderCode + " " + toHex(response.fullFrame, 160));
|
||||
}
|
||||
|
||||
private String errorSummary(Exception error) {
|
||||
if (error == null) {
|
||||
return "未知错误";
|
||||
}
|
||||
String message = error.getMessage();
|
||||
if (message == null || message.length() == 0) {
|
||||
return error.getClass().getSimpleName();
|
||||
}
|
||||
return error.getClass().getSimpleName() + " " + message;
|
||||
}
|
||||
|
||||
private void closeSocketQuietly() {
|
||||
synchronized (socketLock) {
|
||||
if (outputStream != null) {
|
||||
try {
|
||||
outputStream.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
outputStream = null;
|
||||
if (inputStream != null) {
|
||||
try {
|
||||
inputStream.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
inputStream = null;
|
||||
if (socket != null) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildFrame(int orderCode, byte[] body) {
|
||||
if (body == null) body = new byte[0];
|
||||
int currentSerial = nextSerialNum();
|
||||
int length = 8 + 2 + body.length + 2 + 1;
|
||||
byte[] frame = new byte[HEADER.length + 2 + length];
|
||||
int index = 0;
|
||||
System.arraycopy(HEADER, 0, frame, index, HEADER.length);
|
||||
index += HEADER.length;
|
||||
putUInt16(frame, index, length);
|
||||
index += 2;
|
||||
putUInt64(frame, index, terminalId);
|
||||
index += 8;
|
||||
putUInt16(frame, index, orderCode);
|
||||
index += 2;
|
||||
System.arraycopy(body, 0, frame, index, body.length);
|
||||
index += body.length;
|
||||
putUInt16(frame, index, currentSerial);
|
||||
index += 2;
|
||||
frame[index] = calcCrc(frame, HEADER.length + 2, 8 + 2 + body.length + 2);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private synchronized int nextSerialNum() {
|
||||
int value = serialNum;
|
||||
serialNum += 1;
|
||||
if (serialNum > 65535) {
|
||||
serialNum = 1;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private void drainResponsesLocked(long waitMs) {
|
||||
if (inputStream == null) {
|
||||
return;
|
||||
}
|
||||
long deadline = System.currentTimeMillis() + Math.max(50, waitMs);
|
||||
try {
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ResponseFrame response = readResponseFrame(deadline);
|
||||
if (response == null) {
|
||||
return;
|
||||
}
|
||||
emitRx(response);
|
||||
}
|
||||
} catch (Exception error) {
|
||||
emitLog("传输", "RX 读取异常:" + errorSummary(error));
|
||||
closeSocketQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseFrame readResponseFrame(long deadline) throws Exception {
|
||||
if (inputStream == null) {
|
||||
return null;
|
||||
}
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
int first;
|
||||
try {
|
||||
first = inputStream.read();
|
||||
} catch (SocketTimeoutException timeout) {
|
||||
return null;
|
||||
}
|
||||
if (first < 0) {
|
||||
return null;
|
||||
}
|
||||
if (first != (HEADER[0] & 0xff)) {
|
||||
continue;
|
||||
}
|
||||
if (!readRemainingHeader()) {
|
||||
continue;
|
||||
}
|
||||
byte[] lengthBytes = new byte[2];
|
||||
readFully(lengthBytes, 0, 2);
|
||||
int length = uint16(lengthBytes, 0);
|
||||
if (length < 13 || length > 65535) {
|
||||
continue;
|
||||
}
|
||||
byte[] payload = new byte[length];
|
||||
readFully(payload, 0, length);
|
||||
byte[] full = new byte[HEADER.length + 2 + length];
|
||||
System.arraycopy(HEADER, 0, full, 0, HEADER.length);
|
||||
full[HEADER.length] = lengthBytes[0];
|
||||
full[HEADER.length + 1] = lengthBytes[1];
|
||||
System.arraycopy(payload, 0, full, HEADER.length + 2, length);
|
||||
byte expected = calcCrc(full, HEADER.length + 2, length - 1);
|
||||
byte actual = payload[length - 1];
|
||||
if (expected != actual) {
|
||||
Log.w(TAG, "response crc mismatch expected=" + (expected & 0xff) + " actual=" + (actual & 0xff));
|
||||
Log.w(TAG, "RX CRC mismatch frame=" + toHex(full, 160));
|
||||
emitLog("传输", "RX CRC错误 expected=" + (expected & 0xff) + " actual=" + (actual & 0xff));
|
||||
}
|
||||
int orderCode = uint16(payload, 8);
|
||||
int bodyLength = length - 8 - 2 - 2 - 1;
|
||||
byte[] body = new byte[bodyLength];
|
||||
System.arraycopy(payload, 10, body, 0, bodyLength);
|
||||
int serialNum = uint16(payload, 10 + bodyLength);
|
||||
return new ResponseFrame(orderCode, body, serialNum, full);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean readRemainingHeader() throws Exception {
|
||||
for (int i = 1; i < HEADER.length; i += 1) {
|
||||
int value = inputStream.read();
|
||||
if (value != (HEADER[i] & 0xff)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void readFully(byte[] target, int offset, int length) throws Exception {
|
||||
int read = 0;
|
||||
while (read < length) {
|
||||
int size;
|
||||
try {
|
||||
size = inputStream.read(target, offset + read, length - read);
|
||||
} catch (SocketTimeoutException timeout) {
|
||||
throw timeout;
|
||||
}
|
||||
if (size < 0) {
|
||||
throw new java.io.IOException("socket closed");
|
||||
}
|
||||
read += size;
|
||||
}
|
||||
}
|
||||
|
||||
private ZhitouMemberInfo parseMemberInfo(byte[] data) {
|
||||
if (data == null || data.length < 1) {
|
||||
return ZhitouMemberInfo.failed("会员资料为空");
|
||||
}
|
||||
ZhitouMemberInfo info = new ZhitouMemberInfo();
|
||||
info.verifyStatus = data[0] & 0xff;
|
||||
if (info.verifyStatus == 0) {
|
||||
info.error = "会员鉴权未通过";
|
||||
return info;
|
||||
}
|
||||
if (info.verifyStatus == 2) {
|
||||
info.error = "用户已被禁用";
|
||||
return info;
|
||||
}
|
||||
if (info.verifyStatus != 1) {
|
||||
info.error = "会员鉴权失败:" + info.verifyStatus;
|
||||
return info;
|
||||
}
|
||||
if (data.length < 16) {
|
||||
return ZhitouMemberInfo.failed("会员资料长度不足");
|
||||
}
|
||||
info.memberId = String.valueOf(uint32(data, 1));
|
||||
info.memberCode = String.valueOf(uint32(data, 5));
|
||||
int nameLength = data[9] & 0xff;
|
||||
if (10 + nameLength + 6 > data.length) {
|
||||
return ZhitouMemberInfo.failed("会员资料字段长度异常");
|
||||
}
|
||||
info.name = nameLength > 0 ? new String(data, 10, nameLength, UTF8) : "";
|
||||
info.resiCount = data[14 + nameLength] & 0xff;
|
||||
long score = uint32(data, 10 + nameLength);
|
||||
info.balance = formatMoney(score);
|
||||
return info;
|
||||
}
|
||||
|
||||
private String formatMoney(long cents) {
|
||||
return new BigDecimal(cents).movePointLeft(2).setScale(2).toPlainString();
|
||||
}
|
||||
|
||||
private byte calcCrc(byte[] data, int offset, int length) {
|
||||
byte crc = data[offset];
|
||||
for (int i = offset + 1; i < offset + length; i += 1) {
|
||||
crc ^= data[i];
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
private byte[] fixedIdentityBytes(int idType, String value) {
|
||||
byte[] result = new byte[33];
|
||||
if (value == null) return result;
|
||||
byte[] source = identityContentBytes(idType, value);
|
||||
System.arraycopy(source, 0, result, 0, Math.min(source.length, result.length));
|
||||
return result;
|
||||
}
|
||||
|
||||
private int rawIdentityLength(int idType, String value) {
|
||||
if (value == null) return 0;
|
||||
return identityContentBytes(idType, value).length;
|
||||
}
|
||||
|
||||
private byte[] identityContentBytes(int idType, String value) {
|
||||
if (value == null) {
|
||||
return new byte[0];
|
||||
}
|
||||
String text = value.trim();
|
||||
if (idType == 1 && text.matches("(?i)^[0-9a-f]{8}$")) {
|
||||
byte[] bytes = new byte[4];
|
||||
for (int i = 0; i < bytes.length; i += 1) {
|
||||
int start = i * 2;
|
||||
bytes[i] = (byte) Integer.parseInt(text.substring(start, start + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
return text.getBytes(UTF8);
|
||||
}
|
||||
|
||||
private int kgToCentWeight(double kg) {
|
||||
return clamp((int) Math.round(kg * 100), 0, 65534);
|
||||
}
|
||||
|
||||
private int decimal4(String value) {
|
||||
try {
|
||||
return new BigDecimal(value).multiply(new BigDecimal(10000)).intValue();
|
||||
} catch (Exception ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasLocation(JSONObject data) {
|
||||
return decimal4(data.optString("lng", "0")) != 0
|
||||
|| decimal4(data.optString("lat", "0")) != 0
|
||||
|| decimal4(data.optString("altitude", "0")) != 0;
|
||||
}
|
||||
|
||||
private long parseTerminalId(String text) {
|
||||
if (text == null || text.trim().length() == 0) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(text.trim());
|
||||
} catch (Exception ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private int clamp(int value, int min, int max) {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
private int uint16(byte[] data, int offset) {
|
||||
return ((data[offset] & 0xff) << 8) | (data[offset + 1] & 0xff);
|
||||
}
|
||||
|
||||
private int frameSerial(byte[] frame) {
|
||||
if (frame == null || frame.length < HEADER.length + 2 + 8 + 2 + 2 + 1) {
|
||||
return 0;
|
||||
}
|
||||
return uint16(frame, frame.length - 3);
|
||||
}
|
||||
|
||||
private String toHex(byte[] data, int maxBytes) {
|
||||
if (data == null) {
|
||||
return "";
|
||||
}
|
||||
int limit = Math.min(data.length, Math.max(0, maxBytes));
|
||||
StringBuilder builder = new StringBuilder(limit * 3 + 16);
|
||||
for (int i = 0; i < limit; i += 1) {
|
||||
if (i > 0) builder.append(' ');
|
||||
int value = data[i] & 0xff;
|
||||
if (value < 16) builder.append('0');
|
||||
builder.append(Integer.toHexString(value).toUpperCase(Locale.ROOT));
|
||||
}
|
||||
if (limit < data.length) {
|
||||
builder.append(" ...");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private long uint32(byte[] data, int offset) {
|
||||
return ((long) (data[offset] & 0xff) << 24)
|
||||
| ((long) (data[offset + 1] & 0xff) << 16)
|
||||
| ((long) (data[offset + 2] & 0xff) << 8)
|
||||
| (long) (data[offset + 3] & 0xff);
|
||||
}
|
||||
|
||||
private void putUInt16(byte[] data, int offset, int value) {
|
||||
int v = clamp(value, 0, 65535);
|
||||
data[offset] = (byte) ((v >> 8) & 0xff);
|
||||
data[offset + 1] = (byte) (v & 0xff);
|
||||
}
|
||||
|
||||
private void putUInt32(byte[] data, int offset, int value) {
|
||||
data[offset] = (byte) ((value >> 24) & 0xff);
|
||||
data[offset + 1] = (byte) ((value >> 16) & 0xff);
|
||||
data[offset + 2] = (byte) ((value >> 8) & 0xff);
|
||||
data[offset + 3] = (byte) (value & 0xff);
|
||||
}
|
||||
|
||||
private void putUInt64(byte[] data, int offset, long value) {
|
||||
data[offset] = (byte) ((value >>> 56) & 0xff);
|
||||
data[offset + 1] = (byte) ((value >>> 48) & 0xff);
|
||||
data[offset + 2] = (byte) ((value >>> 40) & 0xff);
|
||||
data[offset + 3] = (byte) ((value >>> 32) & 0xff);
|
||||
data[offset + 4] = (byte) ((value >>> 24) & 0xff);
|
||||
data[offset + 5] = (byte) ((value >>> 16) & 0xff);
|
||||
data[offset + 6] = (byte) ((value >>> 8) & 0xff);
|
||||
data[offset + 7] = (byte) (value & 0xff);
|
||||
}
|
||||
|
||||
private static final class ResponseFrame {
|
||||
final int orderCode;
|
||||
final byte[] body;
|
||||
final int serialNum;
|
||||
final byte[] fullFrame;
|
||||
|
||||
ResponseFrame(int orderCode, byte[] body, int serialNum, byte[] fullFrame) {
|
||||
this.orderCode = orderCode;
|
||||
this.body = body;
|
||||
this.serialNum = serialNum;
|
||||
this.fullFrame = fullFrame;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.xingyuan.zhiling;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.webkit.ValueCallback;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.PermissionRequest;
|
||||
import android.webkit.WebView;
|
||||
import android.widget.Toast;
|
||||
|
||||
final class ZhitouWebChromeClient extends WebChromeClient {
|
||||
private final MainActivity activity;
|
||||
|
||||
ZhitouWebChromeClient(MainActivity activity) {
|
||||
this.activity = activity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPermissionRequest(final PermissionRequest request) {
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
activity.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
for (String resource : request.getResources()) {
|
||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)) {
|
||||
request.grant(new String[] { PermissionRequest.RESOURCE_VIDEO_CAPTURE });
|
||||
return;
|
||||
}
|
||||
}
|
||||
request.deny();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onShowFileChooser(
|
||||
WebView view,
|
||||
ValueCallback<Uri[]> filePath,
|
||||
FileChooserParams fileChooserParams) {
|
||||
activity.setFilePathCallback(filePath);
|
||||
Intent intent = fileChooserParams.createIntent();
|
||||
try {
|
||||
activity.launchFileChooser(intent);
|
||||
} catch (ActivityNotFoundException exception) {
|
||||
activity.clearFilePathCallback();
|
||||
Toast.makeText(activity, "无法打开文件选择器", Toast.LENGTH_SHORT).show();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">星元智灵</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:colorAccent">#23777F</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 星元智灵 APK 手动编译脚本 (无 Gradle)
|
||||
# 使用 Android SDK 命令行工具: aapt / javac / d8 / zipalign / apksigner
|
||||
# ============================================================
|
||||
set -e
|
||||
|
||||
# ---- 路径配置 ----
|
||||
PROJECT="$(cd "$(dirname "$0")" && pwd)"
|
||||
SDK="$HOME/Library/Android/sdk"
|
||||
BT="$SDK/build-tools/34.0.0"
|
||||
PLATFORM="$SDK/platforms/android-28/android.jar"
|
||||
SRC="$PROJECT/app/src/main"
|
||||
BUILD="$PROJECT/build"
|
||||
KEYSTORE="$PROJECT/keystore/zhitou-debug.keystore"
|
||||
APK_BASENAME="xingyuan-zhiling"
|
||||
STORE_PASS="android"
|
||||
KEY_PASS="android"
|
||||
KEY_ALIAS="androiddebugkey"
|
||||
|
||||
# ---- 工具路径 ----
|
||||
AAPT="$BT/aapt"
|
||||
D8="$BT/d8"
|
||||
ZIPALIGN="$BT/zipalign"
|
||||
APKSIGNER="$BT/apksigner"
|
||||
NDK_VERSION="27.3.13750724"
|
||||
NDK="$SDK/ndk/$NDK_VERSION"
|
||||
ARM_CLANGXX="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi21-clang++"
|
||||
|
||||
# ---- 输出目录 ----
|
||||
OUT="$BUILD/out"
|
||||
GEN="$BUILD/gen"
|
||||
CLASSES="$BUILD/classes"
|
||||
DEX="$BUILD/dex"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 星元智灵 APK 编译"
|
||||
echo "=========================================="
|
||||
echo "SDK: $SDK"
|
||||
echo "BuildTools: 34.0.0"
|
||||
echo "Platform: android-28"
|
||||
echo "Project: $PROJECT"
|
||||
echo ""
|
||||
|
||||
# ---- 恢复超过 Git 单文件限制的人脸模型 ----
|
||||
"$PROJECT/scripts/restore-face-models.sh"
|
||||
|
||||
# ---- Debug 签名证书 ----
|
||||
if [ ! -f "$KEYSTORE" ]; then
|
||||
echo "未找到 debug keystore,正在生成: $KEYSTORE"
|
||||
mkdir -p "$(dirname "$KEYSTORE")"
|
||||
keytool -genkeypair \
|
||||
-keystore "$KEYSTORE" \
|
||||
-storepass "$STORE_PASS" \
|
||||
-keypass "$KEY_PASS" \
|
||||
-alias "$KEY_ALIAS" \
|
||||
-keyalg RSA \
|
||||
-keysize 2048 \
|
||||
-validity 10000 \
|
||||
-dname "CN=Android Debug,O=Xingyuan Zhiling,C=CN" >/dev/null
|
||||
fi
|
||||
|
||||
# ---- Step 1: 清理 ----
|
||||
echo "[1/8] 清理旧的编译产物..."
|
||||
rm -rf "$GEN" "$CLASSES" "$DEX"
|
||||
mkdir -p "$GEN" "$CLASSES" "$DEX" "$OUT"
|
||||
|
||||
# ---- Step 2: 生成 R.java ----
|
||||
echo "[2/8] 生成 R.java..."
|
||||
"$AAPT" package -f -m \
|
||||
-J "$GEN" \
|
||||
-M "$SRC/AndroidManifest.xml" \
|
||||
-S "$SRC/res" \
|
||||
-A "$SRC/assets" \
|
||||
-I "$PLATFORM"
|
||||
|
||||
# ---- Step 3: 收集 Java 源文件 ----
|
||||
echo "[3/8] 收集 Java 源文件..."
|
||||
SRC_FILES=$(find "$SRC/java" "$GEN" -name "*.java" | tr '\n' ' ')
|
||||
echo " 源文件数量: $(echo $SRC_FILES | wc -w | tr -d ' ')"
|
||||
|
||||
# ---- Step 4: 编译 Java ----
|
||||
echo "[4/8] 编译 Java 源码..."
|
||||
javac --release 8 -parameters \
|
||||
-classpath "$PLATFORM" \
|
||||
-sourcepath "$GEN" \
|
||||
-d "$CLASSES" \
|
||||
$SRC_FILES 2>&1
|
||||
echo " 编译完成"
|
||||
|
||||
# ---- Step 5: 转换为 DEX ----
|
||||
echo "[5/8] 转换为 DEX..."
|
||||
CLASS_FILES=$(find "$CLASSES" -name "*.class" | tr '\n' ' ')
|
||||
"$D8" \
|
||||
--output "$DEX" \
|
||||
--lib "$PLATFORM" \
|
||||
$CLASS_FILES 2>&1
|
||||
echo " DEX 生成完成: $DEX/classes.dex"
|
||||
|
||||
# ---- Step 6: 打包资源 + Manifest + Assets ----
|
||||
echo "[6/8] 打包 APK (资源 + Manifest + Assets)..."
|
||||
rm -f "$OUT/$APK_BASENAME-unsigned.apk" "$OUT/$APK_BASENAME-aligned.apk" "$OUT/$APK_BASENAME-signed.apk"
|
||||
"$AAPT" package -f \
|
||||
-M "$SRC/AndroidManifest.xml" \
|
||||
-S "$SRC/res" \
|
||||
-A "$SRC/assets" \
|
||||
-I "$PLATFORM" \
|
||||
-F "$OUT/$APK_BASENAME-unsigned.apk"
|
||||
|
||||
# ---- Step 7: 添加 DEX 和 Native 库 ----
|
||||
echo "[7/8] 添加 DEX 和 Native 库到 APK..."
|
||||
# 添加 classes.dex
|
||||
cd "$DEX"
|
||||
zip -j "$OUT/$APK_BASENAME-unsigned.apk" classes.dex
|
||||
cd "$PROJECT"
|
||||
|
||||
# 构建 SeetaFace6 JNI 桥(官方 Android 库就位后自动启用)
|
||||
FACE_LIB_DIR="$SRC/jniLibs/armeabi-v7a"
|
||||
FACE_JNI_SOURCE="$SRC/cpp/face_engine_jni.cpp"
|
||||
FACE_JNI_OUTPUT="$FACE_LIB_DIR/libxingyuan_face.so"
|
||||
FACE_REQUIRED_LIBS=(
|
||||
libTenniS.so
|
||||
libSeetaAuthorize.so
|
||||
libSeetaFaceDetector600.so
|
||||
libSeetaFaceLandmarker600.so
|
||||
libSeetaFaceRecognizer600.so
|
||||
)
|
||||
FACE_LIBS_READY=true
|
||||
for library in "${FACE_REQUIRED_LIBS[@]}"; do
|
||||
if [ ! -f "$FACE_LIB_DIR/$library" ]; then
|
||||
FACE_LIBS_READY=false
|
||||
fi
|
||||
done
|
||||
if [ "$FACE_LIBS_READY" = true ] && [ -x "$ARM_CLANGXX" ]; then
|
||||
echo " 编译 SeetaFace6 JNI 桥..."
|
||||
"$ARM_CLANGXX" \
|
||||
--sysroot="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot" \
|
||||
-std=c++11 -O2 -fPIC -shared -static-libstdc++ \
|
||||
-I"$SRC/cpp/include" \
|
||||
"$FACE_JNI_SOURCE" \
|
||||
-L"$FACE_LIB_DIR" \
|
||||
-lSeetaFaceDetector600 \
|
||||
-lSeetaFaceLandmarker600 \
|
||||
-lSeetaFaceRecognizer600 \
|
||||
-lSeetaAuthorize \
|
||||
-lTenniS \
|
||||
-llog -lz -ldl -lm \
|
||||
-o "$FACE_JNI_OUTPUT"
|
||||
else
|
||||
rm -f "$FACE_JNI_OUTPUT"
|
||||
echo " ⚠ SeetaFace6 官方 Android 动态库尚未齐备,人脸 JNI 暂不打包"
|
||||
fi
|
||||
|
||||
# 添加 native 库 (APK 中需要 lib/<abi>/ 结构)
|
||||
NATIVE_STAGING="$BUILD/native_staging"
|
||||
rm -rf "$NATIVE_STAGING"
|
||||
mkdir -p "$NATIVE_STAGING/lib/armeabi-v7a"
|
||||
find "$SRC/jniLibs/armeabi-v7a" -maxdepth 1 -type f -name "*.so" -exec cp {} "$NATIVE_STAGING/lib/armeabi-v7a/" \;
|
||||
cd "$NATIVE_STAGING"
|
||||
zip -r "$OUT/$APK_BASENAME-unsigned.apk" lib/
|
||||
cd "$PROJECT"
|
||||
echo " APK 打包完成"
|
||||
|
||||
# ---- Step 8: Zipalign + 签名 ----
|
||||
echo "[8/8] Zipalign 并签名..."
|
||||
# Zipalign
|
||||
"$ZIPALIGN" -f 4 "$OUT/$APK_BASENAME-unsigned.apk" "$OUT/$APK_BASENAME-aligned.apk"
|
||||
|
||||
# 签名
|
||||
"$APKSIGNER" sign \
|
||||
--ks "$KEYSTORE" \
|
||||
--ks-pass "pass:$STORE_PASS" \
|
||||
--ks-key-alias "$KEY_ALIAS" \
|
||||
--key-pass "pass:$KEY_PASS" \
|
||||
--out "$OUT/$APK_BASENAME-signed.apk" \
|
||||
"$OUT/$APK_BASENAME-aligned.apk"
|
||||
|
||||
# 验证签名
|
||||
"$APKSIGNER" verify --verbose "$OUT/$APK_BASENAME-signed.apk" 2>&1
|
||||
|
||||
# ---- 完成 ----
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " ✅ 编译成功!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
ls -lh "$OUT/$APK_BASENAME-signed.apk"
|
||||
echo ""
|
||||
echo "输出文件: $OUT/$APK_BASENAME-signed.apk"
|
||||
echo ""
|
||||
echo "安装命令:"
|
||||
echo " adb install -r $OUT/$APK_BASENAME-signed.apk"
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
.wrangler
|
||||
.git
|
||||
npm-debug.log
|
||||
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.vinext/
|
||||
/out/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
next-env.d.ts
|
||||
/dist/
|
||||
/.wrangler/
|
||||
/outputs/
|
||||
/work/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"d1": "DB",
|
||||
"r2": null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:22-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app ./
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# 智能回收运营平台
|
||||
|
||||
面向工厂内网部署的称重与设备管理后台,按公开演示后台的业务范围重新实现,不复用对方代码或视觉素材。
|
||||
|
||||
## 已实现
|
||||
|
||||
- 工作台:投放量、称重次数、待核对数据、设备在线率、最近流水
|
||||
- 废料称重:记录查询、统计、CSV 导出
|
||||
- 事件列表:按投放事件聚合原子称重记录
|
||||
- 地磅核对:台秤与地磅偏差判定
|
||||
- 导体追溯:RFID、批次、皮重、毛重、净重、MES 状态
|
||||
- 人员设备:人员、人脸版本、车间、设备心跳、满溢率
|
||||
- 告警中心:接单、关闭、阈值配置
|
||||
- 系统设置:角色、车间、设备监控、MES、审计日志
|
||||
- 终端模拟:废料投放与导体称重流程演示
|
||||
- 设备接口:Token、投放、心跳、状态、告警、人员拉取、人脸查询、同步回执
|
||||
|
||||
## 本机开发
|
||||
|
||||
需要 Node.js 22 或更高版本:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
浏览器访问 `http://localhost:3000`。
|
||||
|
||||
## Ubuntu 24.04 部署
|
||||
|
||||
推荐使用 Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
后台地址:`http://服务器IP:3000`。数据库持久化在 Docker 卷 `console-data` 中。
|
||||
|
||||
首次部署后建议在路由器或防火墙中只允许内网访问,并通过 Nginx 配置 HTTPS。
|
||||
|
||||
## 设备接口示例
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/tokens
|
||||
POST /api/v1/devices/{deviceId}/deliveries
|
||||
POST /api/v1/devices/{deviceId}/heartbeat
|
||||
POST /api/v1/devices/{deviceId}/status
|
||||
POST /api/v1/devices/{deviceId}/alerts
|
||||
GET /api/v1/devices/{deviceId}/people/incremental?cursor=0
|
||||
GET /api/v1/devices/{deviceId}/faces/{personId}
|
||||
POST /api/v1/devices/{deviceId}/sync-acks
|
||||
GET /api/v1/deliveries/{recordId}
|
||||
```
|
||||
|
||||
当前版本用于本地联调。正式投产前还需要补充设备密钥、签名校验、管理员登录和自动备份。
|
||||
@@ -0,0 +1,820 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, ReactNode, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Row = Record<string, string | number | null>;
|
||||
type ConsoleData = {
|
||||
people: Row[];
|
||||
workshops: Row[];
|
||||
devices: Row[];
|
||||
weighings: Row[];
|
||||
events: Row[];
|
||||
reconciliations: Row[];
|
||||
traces: Row[];
|
||||
tags: Row[];
|
||||
alerts: Row[];
|
||||
settings: Record<string, string>;
|
||||
logs: Row[];
|
||||
serverTime: number;
|
||||
};
|
||||
|
||||
type PageKey =
|
||||
| "dashboard"
|
||||
| "weighings"
|
||||
| "events"
|
||||
| "reconcile"
|
||||
| "trace"
|
||||
| "assets"
|
||||
| "alerts"
|
||||
| "settings"
|
||||
| "simulator";
|
||||
|
||||
type RoleKey = "warehouse" | "worker" | "admin";
|
||||
|
||||
const pageMeta: Record<PageKey, { name: string; en: string; icon: string }> = {
|
||||
dashboard: { name: "工作台", en: "Dashboard", icon: "◫" },
|
||||
weighings: { name: "废料称重", en: "Weighing records", icon: "▦" },
|
||||
events: { name: "事件列表", en: "Events", icon: "◎" },
|
||||
reconcile: { name: "地磅核对", en: "Reconciliation", icon: "⚖" },
|
||||
trace: { name: "导体追溯", en: "Cable trace", icon: "⌁" },
|
||||
assets: { name: "人员设备", en: "People & devices", icon: "♙" },
|
||||
alerts: { name: "告警中心", en: "Alerts", icon: "!" },
|
||||
settings: { name: "系统设置", en: "Settings", icon: "⌘" },
|
||||
simulator: { name: "终端模拟", en: "Terminal simulator", icon: "▣" },
|
||||
};
|
||||
|
||||
const roleMeta: Record<RoleKey, { name: string; person: string; pages: PageKey[] }> = {
|
||||
warehouse: {
|
||||
name: "仓管员",
|
||||
person: "张三",
|
||||
pages: ["dashboard", "weighings", "events", "reconcile", "assets", "simulator"],
|
||||
},
|
||||
worker: {
|
||||
name: "产线工人",
|
||||
person: "李四",
|
||||
pages: ["dashboard", "trace", "assets", "simulator"],
|
||||
},
|
||||
admin: {
|
||||
name: "管理员",
|
||||
person: "王工",
|
||||
pages: Object.keys(pageMeta) as PageKey[],
|
||||
},
|
||||
};
|
||||
|
||||
const navGroups: Array<{ title: string; pages: PageKey[] }> = [
|
||||
{ title: "总览", pages: ["dashboard"] },
|
||||
{ title: "废料库回收", pages: ["weighings", "events", "reconcile"] },
|
||||
{ title: "导体称重", pages: ["trace"] },
|
||||
{ title: "运营管理", pages: ["assets", "alerts"] },
|
||||
{ title: "系统", pages: ["settings"] },
|
||||
{ title: "演示工具", pages: ["simulator"] },
|
||||
];
|
||||
|
||||
const n = (row: Row, key: string) => Number(row[key] ?? 0);
|
||||
const s = (row: Row, key: string) => String(row[key] ?? "");
|
||||
|
||||
function formatTime(value: string | number | null, includeDate = false) {
|
||||
const date = new Date(Number(value ?? 0));
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
...(includeDate ? { month: "2-digit", day: "2-digit" } : {}),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function Status({ value }: { value: string }) {
|
||||
const level =
|
||||
/超标|严重|失败|离线|停用|损坏|异常/.test(value)
|
||||
? "bad"
|
||||
: /警告|预警|处理中|待推送|需更新|未用/.test(value)
|
||||
? "warn"
|
||||
: /在线|正常|成功|已同步|已确认|已录入|在用|已自动|已推送/.test(value)
|
||||
? "ok"
|
||||
: "muted";
|
||||
return <span className={`status ${level}`}>{value}</span>;
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
subtitle,
|
||||
tools,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
tools?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`card ${className}`}>
|
||||
{(title || tools) && (
|
||||
<div className="card-head">
|
||||
<div>
|
||||
{title && <h3>{title}</h3>}
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
{tools && <div className="card-tools">{tools}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTable({
|
||||
columns,
|
||||
rows,
|
||||
}: {
|
||||
columns: Array<{ key: string; label: string; render?: (row: Row) => ReactNode }>;
|
||||
rows: Row[];
|
||||
}) {
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{columns.map((column) => <th key={column.key}>{column.label}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={columns.length} className="empty">没有符合条件的数据</td></tr>
|
||||
) : rows.map((row, index) => (
|
||||
<tr key={String(row.id ?? row.record_id ?? row.code ?? index)}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>{column.render ? column.render(row) : String(row[column.key] ?? "—")}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "搜索…",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="search-box">
|
||||
<span>⌕</span>
|
||||
<input value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function exportCsv(filename: string, rows: Row[]) {
|
||||
if (!rows.length) return;
|
||||
const headers = Object.keys(rows[0]);
|
||||
const escape = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`;
|
||||
const csv = "\ufeff" + [headers.map(escape).join(","), ...rows.map((row) => headers.map((key) => escape(row[key])).join(","))].join("\n");
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function AdminConsole() {
|
||||
const [data, setData] = useState<ConsoleData | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState<PageKey>("dashboard");
|
||||
const [role, setRole] = useState<RoleKey>("admin");
|
||||
const [query, setQuery] = useState("");
|
||||
const [tab, setTab] = useState("records");
|
||||
const [toast, setToast] = useState("");
|
||||
const [clock, setClock] = useState(new Date());
|
||||
const [modal, setModal] = useState<"workshop" | "tag" | "threshold" | null>(null);
|
||||
const [simStep, setSimStep] = useState(0);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/console", { cache: "no-store" });
|
||||
const payload = await response.json() as { code: number; message?: string; data?: ConsoleData };
|
||||
if (!response.ok || !payload.data) throw new Error(payload.message || "数据加载失败");
|
||||
setData(payload.data);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "数据加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setInterval(() => setClock(new Date()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
if (page === "trace") setTab("trace");
|
||||
else if (page === "assets") setTab("people");
|
||||
else if (page === "settings") setTab("accounts");
|
||||
else if (page === "weighings") setTab("records");
|
||||
}, [page]);
|
||||
|
||||
async function action(name: string, payload: Record<string, unknown> = {}, message = "操作成功") {
|
||||
try {
|
||||
const response = await fetch("/api/console", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: name, payload }),
|
||||
});
|
||||
const result = await response.json() as { code: number; message?: string; data?: ConsoleData };
|
||||
if (!response.ok || result.code !== 0) throw new Error(result.message || "操作失败");
|
||||
if (result.data) setData(result.data);
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(""), 2_800);
|
||||
} catch (cause) {
|
||||
setToast(cause instanceof Error ? cause.message : "操作失败");
|
||||
window.setTimeout(() => setToast(""), 3_600);
|
||||
}
|
||||
}
|
||||
|
||||
function switchRole(nextRole: RoleKey) {
|
||||
setRole(nextRole);
|
||||
if (!roleMeta[nextRole].pages.includes(page)) setPage("dashboard");
|
||||
setToast(`已切换为${roleMeta[nextRole].name}视角`);
|
||||
window.setTimeout(() => setToast(""), 2_200);
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<main className="loading-screen">
|
||||
<div className="loading-mark">智</div>
|
||||
<h1>智能回收运营平台</h1>
|
||||
<p>正在连接本地数据服务…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<main className="loading-screen error-screen">
|
||||
<div className="loading-mark">!</div>
|
||||
<h1>数据服务暂不可用</h1>
|
||||
<p>{error}</p>
|
||||
<button className="primary-button" onClick={load}>重新连接</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const visiblePages = roleMeta[role].pages;
|
||||
const openAlerts = data.alerts.filter((item) => s(item, "status") !== "已关闭").length;
|
||||
const onlineDevices = data.devices.filter((item) => s(item, "status") === "在线").length;
|
||||
const title = pageMeta[page];
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-symbol">智</div>
|
||||
<div>
|
||||
<strong>智能回收</strong>
|
||||
<small>OPERATIONS HUB</small>
|
||||
</div>
|
||||
<span className="version">V1.0</span>
|
||||
</div>
|
||||
<nav>
|
||||
{navGroups.map((group) => {
|
||||
const pages = group.pages.filter((item) => visiblePages.includes(item));
|
||||
if (!pages.length) return null;
|
||||
return (
|
||||
<div className="nav-group" key={group.title}>
|
||||
<p>{group.title}</p>
|
||||
{pages.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
className={page === item ? "active" : ""}
|
||||
onClick={() => setPage(item)}
|
||||
>
|
||||
<span className="nav-icon">{pageMeta[item].icon}</span>
|
||||
{pageMeta[item].name}
|
||||
{item === "alerts" && openAlerts > 0 && <b>{openAlerts}</b>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<div><i className="online-dot" />设备在线 <strong>{onlineDevices}/{data.devices.length}</strong></div>
|
||||
<div><i className="online-dot" />本地数据库正常</div>
|
||||
<small>内网服务 · 数据不出厂区</small>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<div className="page-title">
|
||||
<h1>{title.name}</h1>
|
||||
<span>{title.en}</span>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="connection"><i className="online-dot" /> 服务正常</div>
|
||||
<div className="clock">
|
||||
<strong>{clock.toLocaleTimeString("zh-CN", { hour12: false })}</strong>
|
||||
<span>{clock.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", weekday: "short" })}</span>
|
||||
</div>
|
||||
<button className="alert-button" onClick={() => setPage("alerts")}>
|
||||
! {openAlerts > 0 && <b>{openAlerts}</b>}
|
||||
</button>
|
||||
<div className="role-switch">
|
||||
{(Object.keys(roleMeta) as RoleKey[]).map((key) => (
|
||||
<button key={key} className={role === key ? "active" : ""} onClick={() => switchRole(key)}>
|
||||
{roleMeta[key].name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
{page === "dashboard" && <Dashboard data={data} role={role} setPage={setPage} />}
|
||||
{page === "weighings" && (
|
||||
<Weighings data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} />
|
||||
)}
|
||||
{page === "events" && <EventsPage data={data} query={query} setQuery={setQuery} />}
|
||||
{page === "reconcile" && <Reconcile data={data} />}
|
||||
{page === "trace" && (
|
||||
<TracePage data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} action={action} openTag={() => setModal("tag")} />
|
||||
)}
|
||||
{page === "assets" && (
|
||||
<Assets data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} role={role} action={action} />
|
||||
)}
|
||||
{page === "alerts" && <AlertsPage data={data} role={role} action={action} openThreshold={() => setModal("threshold")} />}
|
||||
{page === "settings" && (
|
||||
<SettingsPage data={data} tab={tab} setTab={setTab} action={action} openWorkshop={() => setModal("workshop")} />
|
||||
)}
|
||||
{page === "simulator" && <Simulator step={simStep} setStep={setSimStep} />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<Modal
|
||||
type={modal}
|
||||
settings={data.settings}
|
||||
onClose={() => setModal(null)}
|
||||
onSubmit={async (values) => {
|
||||
if (modal === "workshop") await action("workshop.add", values, "车间已新增");
|
||||
if (modal === "tag") await action("tag.add", values, "RFID 标签已新增");
|
||||
if (modal === "threshold") await action("settings.save", values, "告警阈值已保存");
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ data, role, setPage }: { data: ConsoleData; role: RoleKey; setPage: (page: PageKey) => void }) {
|
||||
const todayTotal = data.weighings.reduce((total, row) => total + n(row, "weight"), 0);
|
||||
const netTotal = data.traces.reduce((total, row) => total + n(row, "net"), 0);
|
||||
const pending = data.reconciliations.filter((row) => s(row, "verdict") !== "正常").length;
|
||||
const deviceOnline = data.devices.filter((row) => s(row, "status") === "在线").length;
|
||||
const showWaste = role !== "worker";
|
||||
const kpis = showWaste
|
||||
? [
|
||||
["今日废料投放", `${todayTotal.toFixed(1)} kg`, "按已接收记录实时汇总", "orange"],
|
||||
["称重次数", `${data.weighings.length} 次`, "原子称重记录", "blue"],
|
||||
["待核对", `${pending} 条`, "警告与超标记录", "amber"],
|
||||
["设备在线率", `${deviceOnline}/${data.devices.length}`, "最近心跳状态", "green"],
|
||||
]
|
||||
: [
|
||||
["今日称重轴数", `${data.traces.length} 轴`, "导体追溯记录", "blue"],
|
||||
["净重累计", `${netTotal.toFixed(1)} kg`, "毛重减皮重", "green"],
|
||||
["MES 待推送", `${data.traces.filter((row) => s(row, "mes_status") !== "已推送").length} 条`, "自动重试队列", "amber"],
|
||||
["RFID 标签", `${data.tags.length} 个`, "在用与备用标签", "orange"],
|
||||
];
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<div className="greeting">
|
||||
<div>
|
||||
<span>你好,{roleMeta[role].person}</span>
|
||||
<h2>{roleMeta[role].name}视角 · 今日运营概览</h2>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={() => setPage(showWaste ? "weighings" : "trace")}>查看明细 →</button>
|
||||
</div>
|
||||
<div className="kpi-grid">
|
||||
{kpis.map(([label, value, note, color]) => (
|
||||
<div className={`kpi ${color}`} key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{note}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="two-columns wide-left">
|
||||
<Card title={showWaste ? "实时称重 · SB-01" : "最近导体称重任务"} subtitle={showWaste ? "数据稳定后自动生成称重记录" : "RFID 与生产批次全程关联"}>
|
||||
{showWaste ? (
|
||||
<div className="live-scale">
|
||||
<div>
|
||||
<strong>12.48</strong><span>kg</span>
|
||||
<p><i className="online-dot" /> 采样稳定 · 精度 ±10g</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>操作人</dt><dd>张三</dd></div>
|
||||
<div><dt>机台</dt><dd>MC-101</dd></div>
|
||||
<div><dt>类别</dt><dd>废铜</dd></div>
|
||||
<div><dt>来源</dt><dd>小秤台</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data.traces.slice(0, 4)}
|
||||
columns={[
|
||||
{ key: "task_id", label: "任务号" },
|
||||
{ key: "rfid", label: "RFID" },
|
||||
{ key: "batch", label: "批次" },
|
||||
{ key: "net", label: "净重", render: (row) => `${n(row, "net").toFixed(1)} kg` },
|
||||
{ key: "mes_status", label: "MES", render: (row) => <Status value={s(row, "mes_status")} /> },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<Card title="设备状态" subtitle={`${deviceOnline} 台在线`}>
|
||||
<div className="device-list">
|
||||
{data.devices.slice(0, 6).map((device) => (
|
||||
<div key={s(device, "id")}>
|
||||
<i className={s(device, "status") === "在线" ? "online-dot" : "offline-dot"} />
|
||||
<span><strong>{s(device, "id")}</strong>{s(device, "type")}</span>
|
||||
<small>{formatTime(device.last_seen)}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="two-columns">
|
||||
<Card title="近 7 日投放趋势" subtitle="公斤">
|
||||
<div className="bar-chart">
|
||||
{[54, 72, 61, 86, 66, 78, 93].map((height, index) => (
|
||||
<div key={index}><i style={{ height: `${height}%` }} /><span>{["一", "二", "三", "四", "五", "六", "日"][index]}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="最近操作流水" subtitle="自动刷新">
|
||||
<div className="activity-list">
|
||||
{data.weighings.slice(0, 5).map((row) => (
|
||||
<div key={s(row, "id")}>
|
||||
<time>{formatTime(row.created_at)}</time>
|
||||
<span>{s(row, "operator")} · {s(row, "machine")} · {s(row, "category")}</span>
|
||||
<strong>{n(row, "weight").toFixed(2)} kg</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Weighings({ data, query, setQuery, tab, setTab }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void }) {
|
||||
const rows = useMemo(() => {
|
||||
const key = query.toLowerCase();
|
||||
return data.weighings.filter((row) => !key || [row.operator, row.machine, row.category, row.event_id].some((value) => String(value ?? "").toLowerCase().includes(key)));
|
||||
}, [data.weighings, query]);
|
||||
const categoryTotals = ["废铜", "废电线", "废铝", "废锡"].map((category) => ({
|
||||
category,
|
||||
value: rows.filter((row) => s(row, "category") === category).reduce((total, row) => total + n(row, "weight"), 0),
|
||||
}));
|
||||
return (
|
||||
<Card
|
||||
title="废料称重数据"
|
||||
subtitle="原子称重记录与统计分析"
|
||||
tools={<button className="ghost-button" onClick={() => exportCsv("废料称重记录.csv", rows)}>导出 CSV</button>}
|
||||
>
|
||||
<div className="toolbar">
|
||||
<div className="tabs">
|
||||
<button className={tab === "records" ? "active" : ""} onClick={() => setTab("records")}>记录列表</button>
|
||||
<button className={tab === "statistics" ? "active" : ""} onClick={() => setTab("statistics")}>统计视图</button>
|
||||
</div>
|
||||
<SearchBox value={query} onChange={setQuery} placeholder="搜索操作人、机台或事件…" />
|
||||
</div>
|
||||
{tab === "records" ? (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={[
|
||||
{ key: "created_at", label: "称重时间", render: (row) => formatTime(row.created_at, true) },
|
||||
{ key: "operator", label: "操作人" },
|
||||
{ key: "machine", label: "关联机台" },
|
||||
{ key: "category", label: "废料类别" },
|
||||
{ key: "source", label: "来源设备" },
|
||||
{ key: "weight", label: "精确重量", render: (row) => <strong>{n(row, "weight").toFixed(2)} kg</strong> },
|
||||
{ key: "event_id", label: "所属事件" },
|
||||
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="statistics-grid">
|
||||
{categoryTotals.map((item) => (
|
||||
<div key={item.category}>
|
||||
<span>{item.category}</span>
|
||||
<strong>{item.value.toFixed(1)} kg</strong>
|
||||
<i style={{ width: `${Math.min(100, item.value * 4)}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsPage({ data, query, setQuery }: { data: ConsoleData; query: string; setQuery: (value: string) => void }) {
|
||||
const rows = data.events.filter((row) => !query || `${s(row, "id")}${s(row, "operator")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<Card title="废料投放事件" subtitle="按进门到离门聚合原子称重记录" tools={<button className="ghost-button" onClick={() => exportCsv("事件列表.csv", rows)}>导出 CSV</button>}>
|
||||
<div className="toolbar"><SearchBox value={query} onChange={setQuery} placeholder="搜索事件 ID 或操作人…" /></div>
|
||||
<DataTable rows={rows} columns={[
|
||||
{ key: "id", label: "事件 ID" },
|
||||
{ key: "operator", label: "操作人" },
|
||||
{ key: "entered_at", label: "进门时间", render: (row) => formatTime(row.entered_at, true) },
|
||||
{ key: "left_at", label: "离门时间", render: (row) => formatTime(row.left_at) },
|
||||
{ key: "duration", label: "历时", render: (row) => `${Math.max(1, Math.round((n(row, "left_at") - n(row, "entered_at")) / 60_000))} 分钟` },
|
||||
{ key: "atomic_count", label: "原子笔数" },
|
||||
{ key: "scale_total", label: "台秤总重", render: (row) => `${n(row, "scale_total").toFixed(2)} kg` },
|
||||
{ key: "verdict", label: "判定", render: (row) => <Status value={s(row, "verdict")} /> },
|
||||
]} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Reconcile({ data }: { data: ConsoleData }) {
|
||||
return (
|
||||
<Card title="双秤数据核对" subtitle="正常 ≤2% · 警告 2–5% · 超标 >5%" tools={<button className="ghost-button" onClick={() => exportCsv("地磅核对.csv", data.reconciliations)}>导出 CSV</button>}>
|
||||
<DataTable rows={data.reconciliations} columns={[
|
||||
{ key: "event_id", label: "事件 ID" },
|
||||
{ key: "category", label: "废料类别" },
|
||||
{ key: "small_scale", label: "台秤汇总", render: (row) => `${n(row, "small_scale").toFixed(2)} kg` },
|
||||
{ key: "floor_scale", label: "地磅增量", render: (row) => `${n(row, "floor_scale").toFixed(2)} kg` },
|
||||
{ key: "difference", label: "偏差值", render: (row) => `${n(row, "difference").toFixed(2)} kg` },
|
||||
{ key: "difference_rate", label: "偏差率", render: (row) => `${n(row, "difference_rate").toFixed(2)}%` },
|
||||
{ key: "verdict", label: "判定", render: (row) => <Status value={s(row, "verdict")} /> },
|
||||
{ key: "storage_status", label: "入库状态", render: (row) => <Status value={s(row, "storage_status")} /> },
|
||||
]} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TracePage({ data, query, setQuery, tab, setTab, action, openTag }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openTag: () => void }) {
|
||||
const rows = data.traces.filter((row) => !query || `${s(row, "task_id")}${s(row, "rfid")}${s(row, "batch")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<Card title="导体称重追溯" subtitle="RFID、生产批次与两次称重完整关联">
|
||||
<div className="toolbar">
|
||||
<div className="tabs">
|
||||
<button className={tab === "trace" ? "active" : ""} onClick={() => setTab("trace")}>称重溯源</button>
|
||||
<button className={tab === "tags" ? "active" : ""} onClick={() => setTab("tags")}>RFID 标签</button>
|
||||
</div>
|
||||
{tab === "trace" ? <SearchBox value={query} onChange={setQuery} placeholder="搜索任务、RFID 或批次…" /> : <button className="primary-button" onClick={openTag}>添加标签</button>}
|
||||
</div>
|
||||
{tab === "trace" ? <DataTable rows={rows} columns={[
|
||||
{ key: "created_at", label: "称重时间", render: (row) => formatTime(row.created_at, true) },
|
||||
{ key: "workshop", label: "所属车间" },
|
||||
{ key: "task_id", label: "任务号" },
|
||||
{ key: "rfid", label: "RFID 标签" },
|
||||
{ key: "tare", label: "皮重", render: (row) => `${n(row, "tare").toFixed(2)} kg` },
|
||||
{ key: "gross", label: "毛重", render: (row) => `${n(row, "gross").toFixed(2)} kg` },
|
||||
{ key: "net", label: "净重", render: (row) => <strong>{n(row, "net").toFixed(2)} kg</strong> },
|
||||
{ key: "machine", label: "机台" },
|
||||
{ key: "batch", label: "批次号" },
|
||||
{ key: "mes_status", label: "MES 推送", render: (row) => <Status value={s(row, "mes_status")} /> },
|
||||
]} /> : <DataTable rows={data.tags} columns={[
|
||||
{ key: "id", label: "标签 ID" },
|
||||
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
{ key: "name", label: "名称" },
|
||||
{ key: "last_checked", label: "最后检测", render: (row) => row.last_checked ? formatTime(row.last_checked, true) : "—" },
|
||||
{ key: "actions", label: "操作", render: (row) => (
|
||||
<div className="row-actions">
|
||||
<button onClick={() => action("tag.status", { id: row.id, status: "在用" }, "标签已写入")}>写入</button>
|
||||
<button onClick={() => action("tag.status", { id: row.id, status: "损坏" }, "标签已标记损坏")}>报损</button>
|
||||
</div>
|
||||
) },
|
||||
]} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Assets({ data, query, setQuery, tab, setTab, role, action }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void; role: RoleKey; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void> }) {
|
||||
const people = data.people.filter((row) => !query || `${s(row, "name")}${s(row, "id")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<Card title="人员与设备" subtitle={role === "admin" ? "人员主数据由服务端统一维护并向设备下发" : "当前为只读视图"}>
|
||||
<div className="toolbar">
|
||||
<div className="tabs">
|
||||
<button className={tab === "people" ? "active" : ""} onClick={() => setTab("people")}>人员</button>
|
||||
<button className={tab === "machines" ? "active" : ""} onClick={() => setTab("machines")}>机台设备</button>
|
||||
<button className={tab === "devices" ? "active" : ""} onClick={() => setTab("devices")}>回收设备</button>
|
||||
</div>
|
||||
{tab === "people" && <SearchBox value={query} onChange={setQuery} placeholder="搜索姓名或员工 ID…" />}
|
||||
{tab === "people" && role === "admin" && <button className="primary-button" onClick={() => action("mes.sync", {}, "MES 人员同步完成")}>同步 MES</button>}
|
||||
</div>
|
||||
{tab === "people" && <DataTable rows={people} columns={[
|
||||
{ key: "name", label: "姓名" },
|
||||
{ key: "id", label: "员工 ID" },
|
||||
{ key: "workshop", label: "所属车间", render: (row) => role === "admin" ? (
|
||||
<select value={s(row, "workshop")} onChange={(event) => action("person.workshop", { id: row.id, workshop: event.target.value }, "所属车间已更新")}>
|
||||
{data.workshops.map((workshop) => <option key={s(workshop, "code")}>{s(workshop, "name")}</option>)}
|
||||
</select>
|
||||
) : s(row, "workshop") },
|
||||
{ key: "role", label: "角色" },
|
||||
{ key: "face_status", label: "人脸状态", render: (row) => <Status value={s(row, "face_status")} /> },
|
||||
{ key: "sync_status", label: "同步状态", render: (row) => <Status value={s(row, "sync_status")} /> },
|
||||
{ key: "active", label: "账号状态", render: (row) => <Status value={n(row, "active") ? "启用" : "停用"} /> },
|
||||
{ key: "actions", label: "操作", render: (row) => role === "admin" ? (
|
||||
<div className="row-actions">
|
||||
<button onClick={() => action("person.face", { id: row.id }, "人脸版本已更新")}>更新人脸</button>
|
||||
<button onClick={() => action("person.toggle", { id: row.id }, "人员状态已切换")}>{n(row, "active") ? "停用" : "启用"}</button>
|
||||
</div>
|
||||
) : "只读" },
|
||||
]} />}
|
||||
{tab === "machines" && <DataTable rows={[
|
||||
{ id: "MC-101", name: "1# 切割机", type: "生产机台", workshop: "一楼新能源车间", status: "运行中", mes: "正常" },
|
||||
{ id: "MC-105", name: "2# 剥线机", type: "生产机台", workshop: "一楼新能源车间", status: "运行中", mes: "正常" },
|
||||
{ id: "MC-201", name: "8# 绞线机", type: "导体机台", workshop: "二楼导体车间", status: "维护中", mes: "正常" },
|
||||
]} columns={[
|
||||
{ key: "id", label: "机台编号" }, { key: "name", label: "机台名称" }, { key: "type", label: "类型" },
|
||||
{ key: "workshop", label: "所属车间" }, { key: "status", label: "运行状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
{ key: "mes", label: "MES 同步", render: (row) => <Status value={s(row, "mes")} /> },
|
||||
]} />}
|
||||
{tab === "devices" && (
|
||||
<div className="device-grid">
|
||||
{data.devices.map((device) => (
|
||||
<article key={s(device, "id")}>
|
||||
<div><strong>{s(device, "id")}</strong><Status value={s(device, "status")} /></div>
|
||||
<h4>{s(device, "type")}</h4>
|
||||
<p>{s(device, "ip")} · 心跳 {s(device, "heartbeat_seconds")}s</p>
|
||||
{n(device, "fill_percent") > 0 && (
|
||||
<>
|
||||
<div className="meter"><i style={{ width: `${n(device, "fill_percent")}%` }} /></div>
|
||||
<small>满溢率 {n(device, "fill_percent")}% · {s(device, "category")}</small>
|
||||
</>
|
||||
)}
|
||||
<footer>最后心跳 {formatTime(device.last_seen)}</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsPage({ data, role, action, openThreshold }: { data: ConsoleData; role: RoleKey; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openThreshold: () => void }) {
|
||||
return (
|
||||
<Card title="设备与业务告警" subtitle="满溢、设备离线、超时与称重偏差统一管理" tools={role === "admin" && <button className="ghost-button" onClick={openThreshold}>阈值设置</button>}>
|
||||
<div className="alert-list">
|
||||
{data.alerts.map((alert) => (
|
||||
<article key={s(alert, "id")} className={s(alert, "severity") === "严重" ? "critical" : ""}>
|
||||
<div className="alert-sign">!</div>
|
||||
<div>
|
||||
<div className="alert-title"><h3>{s(alert, "type")}</h3><Status value={s(alert, "status")} /></div>
|
||||
<p>{s(alert, "description")}</p>
|
||||
<small>{formatTime(alert.created_at, true)} · {s(alert, "source")} · {s(alert, "reference")}</small>
|
||||
</div>
|
||||
{role === "admin" && s(alert, "status") !== "已关闭" && (
|
||||
<div className="row-actions">
|
||||
<button onClick={() => action("alert.status", { id: alert.id, status: "处理中" }, "告警已接单")}>接单</button>
|
||||
<button onClick={() => action("alert.status", { id: alert.id, status: "已关闭" }, "告警已关闭")}>关闭</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage({ data, tab, setTab, action, openWorkshop }: { data: ConsoleData; tab: string; setTab: (value: string) => void; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openWorkshop: () => void }) {
|
||||
const tabs = [["accounts", "账号与权限"], ["workshops", "车间字典"], ["monitor", "设备监控"], ["mes", "MES 接口"], ["logs", "操作日志"]];
|
||||
return (
|
||||
<Card title="系统配置" subtitle="管理员专属">
|
||||
<div className="toolbar"><div className="tabs">{tabs.map(([key, label]) => <button key={key} className={tab === key ? "active" : ""} onClick={() => setTab(key)}>{label}</button>)}</div></div>
|
||||
{tab === "accounts" && <DataTable rows={[
|
||||
{ name: "王工", id: "A0001", department: "设备部", role: "管理员", permission: "全部功能与系统配置" },
|
||||
{ name: "张三", id: "P1001", department: "仓储部", role: "仓管员", permission: "废料库、人员设备只读" },
|
||||
{ name: "李四", id: "P1002", department: "生产部", role: "产线工人", permission: "导体追溯、人员设备只读" },
|
||||
]} columns={[
|
||||
{ key: "name", label: "姓名" }, { key: "id", label: "工号" }, { key: "department", label: "部门" },
|
||||
{ key: "role", label: "系统角色" }, { key: "permission", label: "权限说明" },
|
||||
]} />}
|
||||
{tab === "workshops" && (
|
||||
<>
|
||||
<div className="sub-toolbar"><span>车间主数据字典</span><button className="primary-button" onClick={openWorkshop}>新增车间</button></div>
|
||||
<DataTable rows={data.workshops} columns={[
|
||||
{ key: "code", label: "车间编码" }, { key: "name", label: "车间名称" }, { key: "location", label: "位置" }, { key: "note", label: "备注" },
|
||||
{ key: "people", label: "关联人员", render: (row) => `${data.people.filter((person) => s(person, "workshop") === s(row, "name")).length} 人` },
|
||||
{ key: "actions", label: "操作", render: (row) => <div className="row-actions"><button onClick={() => action("workshop.delete", { code: row.code }, "车间已删除")}>删除</button></div> },
|
||||
]} />
|
||||
</>
|
||||
)}
|
||||
{tab === "monitor" && <DataTable rows={data.devices} columns={[
|
||||
{ key: "id", label: "设备编号" }, { key: "type", label: "设备类型" }, { key: "ip", label: "IP 地址" },
|
||||
{ key: "heartbeat_seconds", label: "心跳周期", render: (row) => `${s(row, "heartbeat_seconds")} 秒` },
|
||||
{ key: "last_seen", label: "最后心跳", render: (row) => formatTime(row.last_seen, true) },
|
||||
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
]} />}
|
||||
{tab === "mes" && <MesSettings settings={data.settings} action={action} />}
|
||||
{tab === "logs" && <DataTable rows={data.logs} columns={[
|
||||
{ key: "created_at", label: "时间", render: (row) => formatTime(row.created_at, true) },
|
||||
{ key: "actor", label: "操作人" }, { key: "action", label: "动作" }, { key: "details", label: "详情" },
|
||||
{ key: "level", label: "级别", render: (row) => <Status value={s(row, "level")} /> },
|
||||
]} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MesSettings({ settings, action }: { settings: Record<string, string>; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void> }) {
|
||||
const [url, setUrl] = useState(settings.mes_url ?? "");
|
||||
const [timeout, setTimeoutValue] = useState(settings.mes_timeout ?? "10");
|
||||
const [testing, setTesting] = useState(false);
|
||||
return (
|
||||
<div className="settings-form">
|
||||
<label><span>接口地址</span><input value={url} onChange={(event) => setUrl(event.target.value)} /></label>
|
||||
<label><span>推送超时</span><input type="number" value={timeout} onChange={(event) => setTimeoutValue(event.target.value)} /><small>秒</small></label>
|
||||
<label><span>重试策略</span><select defaultValue="3"><option value="3">自动重试 3 次 · 间隔 5 秒</option><option value="0">仅人工重试</option></select></label>
|
||||
<div className="form-actions">
|
||||
<button className="ghost-button" onClick={() => { setTesting(true); window.setTimeout(() => setTesting(false), 900); }}>{testing ? "连接中…" : "测试连接"}</button>
|
||||
<button className="primary-button" onClick={() => action("settings.save", { mes_url: url, mes_timeout: timeout }, "MES 配置已保存")}>保存配置</button>
|
||||
{!testing && <Status value="连接正常" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Simulator({ step, setStep }: { step: number; setStep: (value: number) => void }) {
|
||||
const wasteSteps = [
|
||||
["身份验证", "请将面部对准摄像头,也可输入工号", "模拟识别成功 · 张三"],
|
||||
["门锁已开启", "请进入投放,最长停留 05:00", "模拟投放完成并关门"],
|
||||
["投放完成", "台秤 38.50kg · 地磅 39.00kg · 偏差 1.3%", "重新开始"],
|
||||
];
|
||||
const cableSteps = [
|
||||
["导体轴称重", "RFID 全程关联生产任务与批次", "开始一次称重"],
|
||||
["一次称重完成", "RFID E280-6102-0345 · 皮重 152.30kg", "模拟二次称重"],
|
||||
["MES 上报成功", "净重 115.80kg · 条码已打印", "重新开始"],
|
||||
];
|
||||
return (
|
||||
<div className="simulator-grid">
|
||||
{[["废料库入库终端", wasteSteps, "orange"], ["导体称重终端", cableSteps, "blue"]].map(([name, steps, color], index) => {
|
||||
const items = steps as string[][];
|
||||
const current = index === 0 ? step % 3 : Math.floor(step / 3) % 3;
|
||||
return (
|
||||
<Card title={String(name)} subtitle="点击屏幕推进业务流程" className="sim-card" key={String(name)}>
|
||||
<button className={`terminal ${color}`} onClick={() => setStep(index === 0 ? (current + 1) % 3 : ((current + 1) % 3) * 3)}>
|
||||
<span className="terminal-camera" />
|
||||
<small>LOCAL TERMINAL · ONLINE</small>
|
||||
<div className="terminal-icon">{current === 0 ? "◉" : current === 1 ? "⇄" : "✓"}</div>
|
||||
<h3>{items[current][0]}</h3>
|
||||
<p>{items[current][1]}</p>
|
||||
<strong>{items[current][2]} →</strong>
|
||||
<div className="step-dots">{items.map((_, itemIndex) => <i className={itemIndex === current ? "active" : ""} key={itemIndex} />)}</div>
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ type, settings, onClose, onSubmit }: { type: "workshop" | "tag" | "threshold"; settings: Record<string, string>; onClose: () => void; onSubmit: (values: Record<string, string>) => Promise<void> }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
const values = Object.fromEntries(new FormData(event.currentTarget).entries()) as Record<string, string>;
|
||||
await onSubmit(values);
|
||||
setSaving(false);
|
||||
}
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}>
|
||||
<form className="modal" onSubmit={submit}>
|
||||
<header><h2>{type === "workshop" ? "新增车间" : type === "tag" ? "添加 RFID 标签" : "告警阈值设置"}</h2><button type="button" onClick={onClose}>×</button></header>
|
||||
<div className="modal-body">
|
||||
{type === "workshop" && <>
|
||||
<label><span>车间名称</span><input name="name" required placeholder="例如:三楼精密车间" /></label>
|
||||
<label><span>位置</span><input name="location" placeholder="例如:3F · 北侧" /></label>
|
||||
<label><span>备注</span><input name="note" placeholder="选填" /></label>
|
||||
</>}
|
||||
{type === "tag" && <label><span>标签名称</span><input name="name" required placeholder="例如:8# 导体轴标签" /></label>}
|
||||
{type === "threshold" && <>
|
||||
<label><span>偏差警告阈值 (%)</span><input name="deviation_warn" type="number" defaultValue={settings.deviation_warn} /></label>
|
||||
<label><span>偏差严重阈值 (%)</span><input name="deviation_critical" type="number" defaultValue={settings.deviation_critical} /></label>
|
||||
<label><span>满溢预警阈值 (%)</span><input name="fill_warn" type="number" defaultValue={settings.fill_warn} /></label>
|
||||
<label><span>满溢严重阈值 (%)</span><input name="fill_critical" type="number" defaultValue={settings.fill_critical} /></label>
|
||||
</>}
|
||||
</div>
|
||||
<footer><button type="button" className="ghost-button" onClick={onClose}>取消</button><button className="primary-button" disabled={saving}>{saving ? "保存中…" : "确认保存"}</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { addAudit, ensureDatabase, loadConsoleData } from "@/db/store";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return Response.json({ code: 0, data: await loadConsoleData() });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ code: 5001, message: error instanceof Error ? error.message : "数据库不可用" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const db = await ensureDatabase();
|
||||
const body = (await request.json()) as {
|
||||
action?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
const payload = body.payload ?? {};
|
||||
const action = body.action ?? "";
|
||||
|
||||
if (action === "person.toggle") {
|
||||
const id = String(payload.id ?? "");
|
||||
await db
|
||||
.prepare("UPDATE people SET active=CASE active WHEN 1 THEN 0 ELSE 1 END, updated_at=? WHERE id=?")
|
||||
.bind(Date.now(), id)
|
||||
.run();
|
||||
await addAudit("管理员", "人员状态变更", `${id} 启用状态已切换`, "重要");
|
||||
} else if (action === "person.workshop") {
|
||||
const id = String(payload.id ?? "");
|
||||
const workshop = String(payload.workshop ?? "");
|
||||
await db.prepare("UPDATE people SET workshop=?, updated_at=? WHERE id=?").bind(workshop, Date.now(), id).run();
|
||||
await addAudit("管理员", "修改人员车间", `${id} 调整到 ${workshop}`);
|
||||
} else if (action === "person.face") {
|
||||
const id = String(payload.id ?? "");
|
||||
await db.prepare("UPDATE people SET face_status='已录入', face_version=face_version+1, sync_status='已同步', updated_at=? WHERE id=?").bind(Date.now(), id).run();
|
||||
await addAudit("管理员", "更新人脸", `${id} 人脸版本已更新`, "重要");
|
||||
} else if (action === "workshop.add") {
|
||||
const name = String(payload.name ?? "").trim();
|
||||
const location = String(payload.location ?? "—").trim();
|
||||
const note = String(payload.note ?? "—").trim();
|
||||
if (!name) return Response.json({ code: 1001, message: "车间名称不能为空" }, { status: 400 });
|
||||
const row = await db.prepare("SELECT COUNT(*) AS total FROM workshops").first<{ total: number }>();
|
||||
const code = `WS-${String(Number(row?.total ?? 0) + 1).padStart(2, "0")}`;
|
||||
await db.prepare("INSERT INTO workshops(code,name,location,note) VALUES(?,?,?,?)").bind(code, name, location || "—", note || "—").run();
|
||||
await addAudit("管理员", "新增车间", `${code} ${name}`, "重要");
|
||||
} else if (action === "workshop.delete") {
|
||||
const code = String(payload.code ?? "");
|
||||
const workshop = await db.prepare("SELECT name FROM workshops WHERE code=?").bind(code).first<{ name: string }>();
|
||||
if (workshop) {
|
||||
const used = await db.prepare("SELECT COUNT(*) AS total FROM people WHERE workshop=?").bind(workshop.name).first<{ total: number }>();
|
||||
if (Number(used?.total ?? 0) > 0) {
|
||||
return Response.json({ code: 4001, message: "该车间仍有关联人员,不能删除" }, { status: 409 });
|
||||
}
|
||||
await db.prepare("DELETE FROM workshops WHERE code=?").bind(code).run();
|
||||
await addAudit("管理员", "删除车间", `${code} ${workshop.name}`, "重要");
|
||||
}
|
||||
} else if (action === "tag.add") {
|
||||
const name = String(payload.name ?? "未命名标签").trim() || "未命名标签";
|
||||
const id = `E280-6102-${String(Math.floor(Math.random() * 9000) + 1000)}`;
|
||||
await db.prepare("INSERT INTO rfid_tags(id,status,name,last_checked) VALUES(?,'未用',?,NULL)").bind(id, name).run();
|
||||
await addAudit("管理员", "新增 RFID", `${id} ${name}`);
|
||||
} else if (action === "tag.status") {
|
||||
const id = String(payload.id ?? "");
|
||||
const status = String(payload.status ?? "未用");
|
||||
await db.prepare("UPDATE rfid_tags SET status=?, last_checked=? WHERE id=?").bind(status, Date.now(), id).run();
|
||||
await addAudit("管理员", "RFID 状态变更", `${id} → ${status}`);
|
||||
} else if (action === "alert.status") {
|
||||
const id = String(payload.id ?? "");
|
||||
const status = String(payload.status ?? "处理中");
|
||||
await db.prepare("UPDATE alerts SET status=? WHERE id=?").bind(status, id).run();
|
||||
await addAudit("管理员", "处理告警", `${id} → ${status}`, "重要");
|
||||
} else if (action === "settings.save") {
|
||||
const entries = Object.entries(payload);
|
||||
for (const [key, value] of entries) {
|
||||
await db
|
||||
.prepare("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at")
|
||||
.bind(key, String(value), Date.now())
|
||||
.run();
|
||||
}
|
||||
await addAudit("管理员", "更新系统设置", entries.map(([key]) => key).join("、"), "重要");
|
||||
} else if (action === "mes.sync") {
|
||||
await db.prepare("UPDATE people SET sync_status='已同步', updated_at=? WHERE sync_status<>'已同步'").bind(Date.now()).run();
|
||||
await addAudit("管理员", "同步 MES 人员", "增量同步完成");
|
||||
} else {
|
||||
return Response.json({ code: 1001, message: "未知操作" }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({ code: 0, message: "操作成功", data: await loadConsoleData() });
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { addAudit, ensureDatabase } from "@/db/store";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteContext = { params: Promise<{ path: string[] }> };
|
||||
|
||||
function ok(data: unknown, requestId = crypto.randomUUID()) {
|
||||
return Response.json({
|
||||
code: 0,
|
||||
message: "success",
|
||||
requestId,
|
||||
serverTime: Date.now(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
function fail(code: number, message: string, status = 400) {
|
||||
return Response.json(
|
||||
{ code, message, requestId: crypto.randomUUID(), serverTime: Date.now(), data: null },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const db = await ensureDatabase();
|
||||
const path = (await context.params).path;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (path.length === 4 && path[0] === "devices" && path[2] === "people") {
|
||||
const cursor = Number(url.searchParams.get("cursor") ?? 0);
|
||||
const limit = Math.min(Number(url.searchParams.get("limit") ?? 200), 500);
|
||||
const rows = await db
|
||||
.prepare("SELECT id AS personId,name,workshop,role,active,face_status AS faceStatus,face_version AS faceVersion,updated_at AS updatedAt FROM people WHERE updated_at>? ORDER BY updated_at LIMIT ?")
|
||||
.bind(cursor, limit)
|
||||
.all();
|
||||
const nextCursor = rows.results.reduce((value, row) => Math.max(value, Number(row.updatedAt ?? 0)), cursor);
|
||||
return ok({ items: rows.results, nextCursor, hasMore: rows.results.length === limit });
|
||||
}
|
||||
|
||||
if (path.length === 4 && path[0] === "devices" && path[2] === "faces") {
|
||||
const person = await db
|
||||
.prepare("SELECT id AS personId,name,face_status AS faceStatus,face_version AS faceVersion,updated_at AS updatedAt FROM people WHERE id=? AND active=1")
|
||||
.bind(path[3])
|
||||
.first();
|
||||
if (!person) return fail(4002, "PERSON_DELETED", 404);
|
||||
return ok({ ...person, featureEncoding: "server-managed", feature: null });
|
||||
}
|
||||
|
||||
if (path.length === 2 && path[0] === "deliveries") {
|
||||
const row = await db.prepare("SELECT * FROM deliveries WHERE record_id=?").bind(path[1]).first();
|
||||
if (!row) return fail(3004, "DELIVERY_NOT_FOUND", 404);
|
||||
return ok(row);
|
||||
}
|
||||
|
||||
return fail(1001, "接口不存在", 404);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const db = await ensureDatabase();
|
||||
const path = (await context.params).path;
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
if (path.join("/") === "auth/tokens") {
|
||||
const deviceId = String(body.deviceId ?? body.deviceNo ?? "");
|
||||
if (!deviceId) return fail(1001, "deviceId 必填");
|
||||
return ok({ accessToken: `local-${deviceId}-${Date.now()}`, tokenType: "Bearer", expiresIn: 28_800 });
|
||||
}
|
||||
|
||||
if (path.length === 3 && path[0] === "devices" && path[2] === "heartbeat") {
|
||||
const deviceId = path[1];
|
||||
const ip = String(body.ip ?? "0.0.0.0");
|
||||
await db
|
||||
.prepare("INSERT INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,'在线',?,30,0,'终端') ON CONFLICT(id) DO UPDATE SET status='在线',last_seen=excluded.last_seen,ip=excluded.ip")
|
||||
.bind(deviceId, String(body.deviceType ?? "回收终端"), ip, Date.now())
|
||||
.run();
|
||||
return ok({ accepted: true, nextHeartbeatIn: 30 });
|
||||
}
|
||||
|
||||
if (path.length === 3 && path[0] === "devices" && path[2] === "status") {
|
||||
const deviceId = path[1];
|
||||
const fillPercent = Number(body.fillPercent ?? body.fullRate ?? 0);
|
||||
await db.prepare("UPDATE devices SET status=?,last_seen=?,fill_percent=? WHERE id=?").bind(String(body.status ?? "在线"), Date.now(), fillPercent, deviceId).run();
|
||||
return ok({ accepted: true });
|
||||
}
|
||||
|
||||
if (path.length === 3 && path[0] === "devices" && path[2] === "alerts") {
|
||||
const deviceId = path[1];
|
||||
const alerts = Array.isArray(body.alerts) ? body.alerts : [body];
|
||||
for (const item of alerts as Array<Record<string, unknown>>) {
|
||||
await db
|
||||
.prepare("INSERT OR IGNORE INTO alerts(id,created_at,type,severity,source,reference,description,status) VALUES(?,?,?,?,?,?,?,'待处理')")
|
||||
.bind(String(item.alarmId ?? crypto.randomUUID()), Number(item.reportedAt ?? Date.now()), String(item.type ?? "设备告警"), String(item.severity ?? "警告"), "设备上报", deviceId, String(item.message ?? "设备异常"))
|
||||
.run();
|
||||
}
|
||||
return ok({ accepted: alerts.length });
|
||||
}
|
||||
|
||||
if (path.length === 3 && path[0] === "devices" && path[2] === "deliveries") {
|
||||
const deviceId = path[1];
|
||||
const recordId = String(body.recordId ?? "");
|
||||
if (!recordId) return fail(1001, "recordId 必填");
|
||||
const weight = Number(body.weight ?? body.afterWeight ?? 0);
|
||||
await db
|
||||
.prepare("INSERT OR IGNORE INTO deliveries(record_id,device_id,created_at,employee_name,employee_no,machine_no,production_batch,garbage_type,weight,payload,status) VALUES(?,?,?,?,?,?,?,?,?,?,?)")
|
||||
.bind(recordId, deviceId, Number(body.createdAt ?? Date.now()), String(body.employeeName ?? body.person ?? ""), String(body.employeeNo ?? ""), String(body.machineNo ?? ""), String(body.productionBatch ?? ""), Number(body.garbageType ?? body.typeCode ?? 0), weight, JSON.stringify(body), "已接收")
|
||||
.run();
|
||||
await db
|
||||
.prepare("INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)")
|
||||
.bind(recordId, Number(body.createdAt ?? Date.now()), String(body.employeeName ?? body.person ?? "未知"), String(body.machineNo ?? "—"), `类型 ${String(body.garbageType ?? body.typeCode ?? 0)}`, deviceId, weight, String(body.eventId ?? recordId), "已确认")
|
||||
.run();
|
||||
await addAudit(deviceId, "投放数据上报", `${recordId} · ${weight}kg`);
|
||||
return ok({ accepted: true, duplicate: false });
|
||||
}
|
||||
|
||||
if (path.length === 3 && path[0] === "devices" && path[2] === "sync-acks") {
|
||||
await addAudit(path[1], "同步结果上报", JSON.stringify(body).slice(0, 500));
|
||||
return ok({ accepted: true });
|
||||
}
|
||||
|
||||
return fail(1001, "接口不存在", 404);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ChatGPTUser = {
|
||||
displayName: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
|
||||
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
||||
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
||||
const USER_FULL_NAME_ENCODING_HEADER =
|
||||
"oai-authenticated-user-full-name-encoding";
|
||||
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
||||
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
||||
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
||||
const CALLBACK_PATH = "/callback";
|
||||
|
||||
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
||||
const requestHeaders = await headers();
|
||||
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||
if (!email) return null;
|
||||
|
||||
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
||||
? safeDecodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
return {
|
||||
displayName: fullName ?? email,
|
||||
email,
|
||||
fullName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireChatGPTUser(
|
||||
returnTo: string,
|
||||
): Promise<ChatGPTUser> {
|
||||
const user = await getChatGPTUser();
|
||||
if (user) return user;
|
||||
|
||||
redirect(chatGPTSignInPath(returnTo));
|
||||
}
|
||||
|
||||
export function chatGPTSignInPath(returnTo: string): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
export function chatGPTSignOutPath(returnTo = "/"): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
function safeRelativeReturnPath(value: string): string {
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, "https://app.local");
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
if (url.origin !== "https://app.local") return "/";
|
||||
if (isReservedAuthPath(url.pathname)) return "/";
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function isReservedAuthPath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === SIGN_IN_PATH ||
|
||||
pathname === SIGN_OUT_PATH ||
|
||||
pathname === CALLBACK_PATH
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg: #f3f5f8;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f8fafc;
|
||||
--ink: #172234;
|
||||
--muted: #6b778a;
|
||||
--line: #e2e7ee;
|
||||
--nav: #142333;
|
||||
--nav-soft: #1a2e43;
|
||||
--blue: #1f72e5;
|
||||
--cyan: #23a9c9;
|
||||
--green: #21a675;
|
||||
--orange: #f08a35;
|
||||
--amber: #d99614;
|
||||
--red: #dc4f4f;
|
||||
--shadow: 0 14px 34px rgba(20, 35, 51, .07);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
|
||||
body {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.loading-screen {
|
||||
min-height: 100vh; display: grid; place-content: center; text-align: center;
|
||||
background: radial-gradient(circle at 50% 30%, #fff, #edf2f8);
|
||||
}
|
||||
.loading-mark {
|
||||
width: 64px; height: 64px; margin: 0 auto 18px; border-radius: 18px;
|
||||
display: grid; place-items: center; color: white; font-size: 26px; font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--blue), var(--cyan)); box-shadow: 0 15px 30px rgba(31,114,229,.25);
|
||||
}
|
||||
.loading-screen h1 { font-size: 22px; margin: 0; }
|
||||
.loading-screen p { color: var(--muted); margin: 10px 0 22px; }
|
||||
.error-screen .loading-mark { background: var(--red); }
|
||||
|
||||
.app-shell { min-height: 100vh; }
|
||||
.sidebar {
|
||||
position: fixed; inset: 0 auto 0 0; z-index: 20; width: 236px; display: flex; flex-direction: column;
|
||||
color: #d9e4ef; background:
|
||||
radial-gradient(circle at -10% 0, rgba(35,169,201,.18), transparent 28%),
|
||||
linear-gradient(180deg, #142536 0%, #101d2a 100%);
|
||||
border-right: 1px solid #20364b;
|
||||
}
|
||||
.brand {
|
||||
min-height: 76px; padding: 17px 16px; display: flex; align-items: center; gap: 11px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.08); position: relative;
|
||||
}
|
||||
.brand-symbol {
|
||||
width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px;
|
||||
background: linear-gradient(135deg, #2686ff, #27b1c5); color: white; font-weight: 900; font-size: 17px;
|
||||
box-shadow: 0 8px 20px rgba(31,114,229,.25);
|
||||
}
|
||||
.brand strong { display: block; font-size: 16px; letter-spacing: .08em; }
|
||||
.brand small { display: block; margin-top: 3px; color: #71869a; font-size: 8px; letter-spacing: .18em; }
|
||||
.version { position: absolute; top: 17px; right: 13px; color: #4fc0d8; font: 700 9px monospace; }
|
||||
.sidebar nav { flex: 1; overflow: auto; padding: 10px 8px; }
|
||||
.nav-group { margin-bottom: 8px; }
|
||||
.nav-group p { padding: 8px 11px 5px; margin: 0; color: #647d92; font-size: 10px; letter-spacing: .16em; }
|
||||
.nav-group button {
|
||||
width: 100%; height: 39px; padding: 0 11px; display: flex; align-items: center; gap: 10px;
|
||||
border: 0; border-radius: 8px; background: transparent; color: #9fb1c2; font-size: 13px; text-align: left;
|
||||
transition: .18s ease; position: relative;
|
||||
}
|
||||
.nav-group button:hover { color: #fff; background: rgba(255,255,255,.05); }
|
||||
.nav-group button.active { color: #fff; background: linear-gradient(90deg, rgba(31,114,229,.35), rgba(35,169,201,.13)); }
|
||||
.nav-group button.active::before { content: ""; position: absolute; left: 0; width: 3px; height: 20px; border-radius: 4px; background: #47b9e9; }
|
||||
.nav-icon { width: 19px; color: #5aa7d6; font-weight: 800; text-align: center; }
|
||||
.nav-group button b {
|
||||
margin-left: auto; min-width: 19px; height: 19px; display: grid; place-items: center;
|
||||
color: white; background: var(--red); border-radius: 10px; font-size: 10px;
|
||||
}
|
||||
.sidebar-foot { padding: 13px 17px 18px; border-top: 1px solid rgba(255,255,255,.08); font-size: 11px; color: #8fa4b7; }
|
||||
.sidebar-foot div { display: flex; align-items: center; gap: 7px; margin: 6px 0; }
|
||||
.sidebar-foot strong { color: #d8e3ec; margin-left: auto; }
|
||||
.sidebar-foot small { display: block; color: #586f83; margin-top: 8px; }
|
||||
.online-dot, .offline-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; background: #3bd29b; box-shadow: 0 0 0 4px rgba(59,210,155,.1); }
|
||||
.offline-dot { background: var(--red); box-shadow: 0 0 0 4px rgba(220,79,79,.1); }
|
||||
|
||||
.workspace { min-height: 100vh; margin-left: 236px; }
|
||||
.topbar {
|
||||
height: 76px; padding: 0 24px; display: flex; align-items: center; gap: 20px;
|
||||
background: rgba(255,255,255,.9); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.page-title { display: flex; align-items: baseline; gap: 11px; }
|
||||
.page-title h1 { margin: 0; font-size: 20px; }
|
||||
.page-title span { color: #9aa5b4; font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
|
||||
.connection {
|
||||
display: flex; align-items: center; gap: 7px; padding: 6px 10px; color: var(--green);
|
||||
background: #f2fbf7; border: 1px solid #cfefe2; border-radius: 8px; font-size: 11px; font-weight: 700;
|
||||
}
|
||||
.clock { padding-right: 4px; text-align: right; }
|
||||
.clock strong { display: block; font: 700 13px ui-monospace, monospace; }
|
||||
.clock span { display: block; margin-top: 2px; color: var(--muted); font-size: 9px; }
|
||||
.alert-button {
|
||||
width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 9px;
|
||||
background: white; color: var(--red); font-weight: 900; position: relative;
|
||||
}
|
||||
.alert-button b { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; line-height: 17px; border-radius: 9px; color: white; background: var(--red); font-size: 9px; }
|
||||
.role-switch { padding: 3px; display: flex; background: #edf1f5; border-radius: 9px; }
|
||||
.role-switch button { padding: 6px 10px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-size: 11px; }
|
||||
.role-switch button.active { color: #fff; background: var(--blue); box-shadow: 0 4px 10px rgba(31,114,229,.2); }
|
||||
.content { max-width: 1500px; margin: 0 auto; padding: 22px 24px 40px; }
|
||||
.page-stack { display: grid; gap: 16px; }
|
||||
|
||||
.greeting {
|
||||
padding: 18px 21px; display: flex; align-items: center; justify-content: space-between;
|
||||
background: linear-gradient(125deg, #18334c, #164664 60%, #176079); color: white;
|
||||
border-radius: 14px; box-shadow: var(--shadow); overflow: hidden; position: relative;
|
||||
}
|
||||
.greeting::after { content: ""; position: absolute; right: -60px; width: 280px; height: 280px; border: 54px solid rgba(255,255,255,.05); border-radius: 50%; }
|
||||
.greeting span { color: #b9deee; font-size: 12px; }
|
||||
.greeting h2 { margin: 4px 0 0; font-size: 18px; }
|
||||
.greeting button { position: relative; z-index: 1; color: white; border-color: rgba(255,255,255,.2); background: rgba(255,255,255,.08); }
|
||||
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||
.kpi {
|
||||
min-height: 124px; padding: 18px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
box-shadow: 0 8px 25px rgba(20,35,51,.04); position: relative; overflow: hidden;
|
||||
}
|
||||
.kpi::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--blue); }
|
||||
.kpi::after { content: ""; position: absolute; right: -20px; top: -20px; width: 84px; height: 84px; border-radius: 50%; background: rgba(31,114,229,.06); }
|
||||
.kpi.orange::before { background: var(--orange); } .kpi.orange::after { background: rgba(240,138,53,.08); }
|
||||
.kpi.green::before { background: var(--green); } .kpi.green::after { background: rgba(33,166,117,.08); }
|
||||
.kpi.amber::before { background: var(--amber); } .kpi.amber::after { background: rgba(217,150,20,.08); }
|
||||
.kpi > span { color: var(--muted); font-size: 12px; }
|
||||
.kpi > strong { display: block; margin: 11px 0 7px; font-size: 28px; letter-spacing: -.03em; }
|
||||
.kpi > small { color: #9aa5b4; font-size: 10px; }
|
||||
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.two-columns.wide-left { grid-template-columns: 1.45fr 1fr; }
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 8px 26px rgba(20,35,51,.04); overflow: hidden; }
|
||||
.card-head { min-height: 61px; padding: 14px 17px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
||||
.card-head h3 { margin: 0; font-size: 14px; }
|
||||
.card-head p { margin: 4px 0 0; color: var(--muted); font-size: 10px; }
|
||||
.card-tools { display: flex; gap: 8px; }
|
||||
.live-scale { min-height: 235px; padding: 34px 28px; display: grid; grid-template-columns: 1.2fr 1fr; align-items: center; background: radial-gradient(circle at 20% 50%, #eef8ff, transparent 48%); }
|
||||
.live-scale > div > strong { color: var(--blue); font-size: 58px; letter-spacing: -.06em; }
|
||||
.live-scale > div > span { margin-left: 8px; color: var(--muted); font-size: 16px; }
|
||||
.live-scale p { display: flex; align-items: center; gap: 8px; color: var(--green); font-size: 11px; }
|
||||
.live-scale dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.live-scale dl div { padding: 10px; background: white; border: 1px solid var(--line); border-radius: 9px; }
|
||||
.live-scale dt { color: var(--muted); font-size: 9px; }
|
||||
.live-scale dd { margin: 5px 0 0; font-size: 12px; font-weight: 700; }
|
||||
.device-list { padding: 8px 17px 13px; }
|
||||
.device-list > div { min-height: 40px; display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef1f4; }
|
||||
.device-list > div:last-child { border: 0; }
|
||||
.device-list span { display: flex; gap: 9px; color: var(--muted); font-size: 11px; }
|
||||
.device-list span strong { width: 60px; color: var(--ink); }
|
||||
.device-list small { color: #9aa5b4; font-size: 9px; }
|
||||
.bar-chart { height: 190px; padding: 25px 25px 15px; display: flex; align-items: end; justify-content: space-around; gap: 13px; }
|
||||
.bar-chart div { height: 100%; flex: 1; display: flex; flex-direction: column; justify-content: end; align-items: center; gap: 8px; }
|
||||
.bar-chart i { width: min(24px, 65%); min-height: 10px; border-radius: 6px 6px 2px 2px; background: linear-gradient(180deg, #39b8d5, #247ae4); box-shadow: 0 5px 12px rgba(31,114,229,.15); }
|
||||
.bar-chart span { color: var(--muted); font-size: 9px; }
|
||||
.activity-list { padding: 7px 17px 13px; }
|
||||
.activity-list > div { min-height: 38px; display: grid; grid-template-columns: 46px 1fr auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef1f4; font-size: 11px; }
|
||||
.activity-list time { color: #97a4b2; font: 600 10px ui-monospace, monospace; }
|
||||
.activity-list span { color: var(--muted); }
|
||||
.activity-list strong { font-size: 11px; }
|
||||
|
||||
.toolbar { min-height: 58px; padding: 10px 15px; display: flex; align-items: center; gap: 10px; background: var(--panel-soft); border-bottom: 1px solid var(--line); }
|
||||
.toolbar > :last-child { margin-left: auto; }
|
||||
.tabs { display: flex; gap: 3px; }
|
||||
.tabs button { height: 34px; padding: 0 13px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; font-size: 11px; }
|
||||
.tabs button.active { color: var(--blue); background: #eaf3ff; border-color: #d4e6ff; font-weight: 700; }
|
||||
.search-box { width: min(310px, 35vw); height: 36px; padding: 0 11px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 9px; background: white; color: #99a5b3; }
|
||||
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 11px; }
|
||||
.primary-button, .ghost-button {
|
||||
min-height: 34px; padding: 0 14px; border-radius: 8px; font-size: 11px; font-weight: 700; transition: .16s;
|
||||
}
|
||||
.primary-button { color: white; border: 1px solid var(--blue); background: var(--blue); box-shadow: 0 5px 12px rgba(31,114,229,.15); }
|
||||
.primary-button:hover { background: #1767d2; }
|
||||
.primary-button:disabled { opacity: .55; cursor: wait; }
|
||||
.ghost-button { color: #476079; border: 1px solid var(--line); background: white; }
|
||||
.ghost-button:hover { border-color: #b7c4d1; color: var(--blue); }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; min-width: 850px; border-collapse: collapse; }
|
||||
th { padding: 11px 14px; color: #8794a3; background: #fbfcfd; border-bottom: 1px solid var(--line); text-align: left; font-size: 10px; font-weight: 700; white-space: nowrap; }
|
||||
td { padding: 12px 14px; border-bottom: 1px solid #edf0f4; color: #4c5a6c; font-size: 11px; white-space: nowrap; }
|
||||
tbody tr:hover { background: #f9fbfd; }
|
||||
td strong { color: var(--ink); }
|
||||
td select { max-width: 150px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; background: white; color: var(--ink); font-size: 10px; }
|
||||
.empty { height: 180px; text-align: center; color: #9aa5b4; }
|
||||
.status { display: inline-flex; align-items: center; gap: 4px; padding: 3px 7px; border: 1px solid; border-radius: 12px; font-size: 9px; font-weight: 700; }
|
||||
.status.ok { color: #17835c; border-color: #bce7d5; background: #effaf5; }
|
||||
.status.warn { color: #9a6d0d; border-color: #f0d89d; background: #fff9e8; }
|
||||
.status.bad { color: #b73939; border-color: #f0c3c3; background: #fff3f3; }
|
||||
.status.muted { color: #6e7c8d; border-color: #dce2e8; background: #f6f8fa; }
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
.row-actions button { padding: 4px 8px; border: 1px solid #d9e1e9; border-radius: 6px; color: #52708f; background: white; font-size: 9px; }
|
||||
.row-actions button:hover { color: var(--blue); border-color: #a9c8ed; }
|
||||
.statistics-grid { padding: 28px; display: grid; grid-template-columns: repeat(4,1fr); gap: 15px; }
|
||||
.statistics-grid > div { padding: 18px; border: 1px solid var(--line); border-radius: 10px; background: #fbfcfe; overflow: hidden; }
|
||||
.statistics-grid span { color: var(--muted); font-size: 11px; }
|
||||
.statistics-grid strong { display: block; margin: 9px 0 14px; font-size: 22px; }
|
||||
.statistics-grid i { display: block; height: 5px; border-radius: 4px; background: linear-gradient(90deg, var(--blue), var(--cyan)); }
|
||||
|
||||
.device-grid { padding: 15px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.device-grid article { min-height: 160px; padding: 15px; border: 1px solid var(--line); border-radius: 10px; background: linear-gradient(145deg, #fff, #f7fafc); }
|
||||
.device-grid article > div:first-child { display: flex; align-items: center; justify-content: space-between; }
|
||||
.device-grid article h4 { margin: 16px 0 5px; font-size: 14px; }
|
||||
.device-grid article p, .device-grid article small, .device-grid article footer { color: var(--muted); font-size: 10px; }
|
||||
.device-grid article footer { margin-top: 13px; padding-top: 10px; border-top: 1px solid var(--line); }
|
||||
.meter { height: 6px; margin: 16px 0 7px; overflow: hidden; background: #e7ebef; border-radius: 5px; }
|
||||
.meter i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--green), var(--amber)); }
|
||||
.alert-list { padding: 9px 15px 15px; display: grid; gap: 9px; }
|
||||
.alert-list article { min-height: 86px; padding: 13px; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 13px; border: 1px solid #eadfbf; border-left: 3px solid var(--amber); border-radius: 9px; background: #fffdf7; }
|
||||
.alert-list article.critical { border-color: #ecc7c7; border-left-color: var(--red); background: #fff9f9; }
|
||||
.alert-sign { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px; color: white; background: var(--amber); font-weight: 900; }
|
||||
.critical .alert-sign { background: var(--red); }
|
||||
.alert-title { display: flex; align-items: center; gap: 8px; }
|
||||
.alert-title h3 { margin: 0; font-size: 13px; }
|
||||
.alert-list p { margin: 5px 0; color: #596879; font-size: 11px; }
|
||||
.alert-list small { color: #97a3af; font-size: 9px; }
|
||||
.sub-toolbar { min-height: 50px; padding: 8px 15px; display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 10px; border-bottom: 1px solid var(--line); }
|
||||
.settings-form { max-width: 720px; padding: 24px; display: grid; gap: 14px; }
|
||||
.settings-form > label, .modal-body label { display: grid; grid-template-columns: 110px 1fr auto; align-items: center; gap: 10px; }
|
||||
.settings-form label span, .modal-body label span { color: var(--muted); font-size: 11px; }
|
||||
.settings-form input, .settings-form select, .modal-body input {
|
||||
height: 38px; padding: 0 11px; border: 1px solid var(--line); border-radius: 8px; outline: 0; color: var(--ink); background: white; font-size: 11px;
|
||||
}
|
||||
.settings-form input:focus, .modal-body input:focus { border-color: #8bb8ee; box-shadow: 0 0 0 3px rgba(31,114,229,.08); }
|
||||
.settings-form small { color: var(--muted); font-size: 10px; }
|
||||
.form-actions { padding-left: 120px; display: flex; align-items: center; gap: 9px; }
|
||||
|
||||
.simulator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.sim-card .card-head { background: #fbfcfd; }
|
||||
.terminal {
|
||||
width: 100%; min-height: 520px; padding: 48px 38px; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
border: 18px solid #1e2b38; background: radial-gradient(circle at 50% 42%, #f9fcff, #eaf1f7); color: var(--ink); position: relative;
|
||||
}
|
||||
.terminal-camera { position: absolute; top: -12px; width: 7px; height: 7px; border-radius: 50%; background: #090e13; box-shadow: 0 0 0 2px #354758; }
|
||||
.terminal > small { position: absolute; top: 14px; left: 17px; color: #7d8b99; font: 600 9px ui-monospace, monospace; }
|
||||
.terminal-icon { width: 100px; height: 100px; display: grid; place-items: center; border: 2px solid var(--orange); border-radius: 50%; color: var(--orange); font-size: 38px; box-shadow: 0 0 0 12px rgba(240,138,53,.08); }
|
||||
.terminal.blue .terminal-icon { color: var(--blue); border-color: var(--blue); box-shadow: 0 0 0 12px rgba(31,114,229,.08); }
|
||||
.terminal h3 { margin: 33px 0 10px; font-size: 24px; }
|
||||
.terminal p { color: var(--muted); font-size: 12px; }
|
||||
.terminal > strong { margin-top: 25px; padding: 11px 18px; border-radius: 8px; color: white; background: var(--orange); font-size: 11px; }
|
||||
.terminal.blue > strong { background: var(--blue); }
|
||||
.step-dots { position: absolute; bottom: 20px; display: flex; gap: 7px; }
|
||||
.step-dots i { width: 7px; height: 7px; border-radius: 50%; background: #c5ced6; }
|
||||
.step-dots i.active { width: 22px; border-radius: 5px; background: var(--orange); }
|
||||
.terminal.blue .step-dots i.active { background: var(--blue); }
|
||||
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; background: rgba(12,24,37,.42); backdrop-filter: blur(4px); }
|
||||
.modal { width: min(500px, calc(100vw - 30px)); border-radius: 14px; background: white; box-shadow: 0 28px 70px rgba(12,24,37,.25); overflow: hidden; }
|
||||
.modal header { height: 60px; padding: 0 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
||||
.modal header h2 { margin: 0; font-size: 16px; }
|
||||
.modal header button { width: 32px; height: 32px; border: 0; border-radius: 8px; color: var(--muted); background: #f3f5f7; font-size: 20px; }
|
||||
.modal-body { padding: 22px; display: grid; gap: 13px; }
|
||||
.modal footer { padding: 13px 18px; display: flex; justify-content: end; gap: 8px; border-top: 1px solid var(--line); background: #fbfcfd; }
|
||||
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 70; padding: 12px 16px; color: white; background: #17334b; border-radius: 9px; box-shadow: 0 13px 30px rgba(12,24,37,.25); font-size: 11px; animation: toast-in .25s ease; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } }
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
.sidebar { width: 205px; }
|
||||
.workspace { margin-left: 205px; }
|
||||
.kpi-grid { grid-template-columns: 1fr 1fr; }
|
||||
.device-grid { grid-template-columns: 1fr 1fr; }
|
||||
.connection, .clock { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.sidebar { position: static; width: 100%; height: auto; }
|
||||
.sidebar nav { display: flex; overflow-x: auto; }
|
||||
.nav-group { display: contents; }
|
||||
.nav-group p, .sidebar-foot { display: none; }
|
||||
.nav-group button { min-width: 110px; justify-content: center; }
|
||||
.workspace { margin-left: 0; }
|
||||
.topbar { height: auto; min-height: 72px; align-items: flex-start; padding: 12px 14px; }
|
||||
.topbar-right { flex-wrap: wrap; justify-content: end; }
|
||||
.role-switch { order: 3; width: 100%; justify-content: end; }
|
||||
.content { padding: 14px; }
|
||||
.two-columns, .two-columns.wide-left, .simulator-grid { grid-template-columns: 1fr; }
|
||||
.kpi-grid { grid-template-columns: 1fr 1fr; }
|
||||
.device-grid, .statistics-grid { grid-template-columns: 1fr; }
|
||||
.search-box { width: 100%; }
|
||||
.toolbar { align-items: stretch; flex-direction: column; }
|
||||
.toolbar > :last-child { margin-left: 0; }
|
||||
.tabs { overflow-x: auto; }
|
||||
.live-scale { grid-template-columns: 1fr; gap: 18px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.brand { min-height: 64px; }
|
||||
.page-title span, .alert-button { display: none; }
|
||||
.kpi-grid { grid-template-columns: 1fr; }
|
||||
.greeting { align-items: flex-start; gap: 12px; flex-direction: column; }
|
||||
.terminal { min-height: 430px; border-width: 11px; }
|
||||
.modal-body label, .settings-form > label { grid-template-columns: 1fr; }
|
||||
.form-actions { padding-left: 0; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost:3000";
|
||||
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
const baseUrl = new URL(`${protocol}://${host}`);
|
||||
|
||||
return {
|
||||
metadataBase: baseUrl,
|
||||
title: "智能回收运营平台",
|
||||
description: "面向内网部署的称重、设备、人员、人脸与告警管理后台",
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
shortcut: "/favicon.svg",
|
||||
},
|
||||
openGraph: {
|
||||
title: "智能回收运营平台",
|
||||
description: "称重、设备、人员与告警的一体化内网管理平台",
|
||||
type: "website",
|
||||
images: [{ url: new URL("/og.png", baseUrl).toString(), width: 1672, height: 941 }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "智能回收运营平台",
|
||||
description: "称重、设备、人员与告警的一体化内网管理平台",
|
||||
images: [new URL("/og.png", baseUrl).toString()],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminConsole } from "./AdminConsole";
|
||||
|
||||
export default function Home() {
|
||||
return <AdminConsole />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export function getDb() {
|
||||
if (!env.DB) {
|
||||
throw new Error(
|
||||
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
|
||||
);
|
||||
}
|
||||
|
||||
return drizzle(env.DB, { schema });
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const people = sqliteTable("people", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
workshop: text("workshop").notNull(),
|
||||
role: text("role").notNull(),
|
||||
faceStatus: text("face_status").notNull(),
|
||||
faceVersion: integer("face_version").notNull().default(1),
|
||||
syncStatus: text("sync_status").notNull(),
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const workshops = sqliteTable("workshops", {
|
||||
code: text("code").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
location: text("location").notNull(),
|
||||
note: text("note").notNull(),
|
||||
});
|
||||
|
||||
export const devices = sqliteTable("devices", {
|
||||
id: text("id").primaryKey(),
|
||||
type: text("type").notNull(),
|
||||
ip: text("ip").notNull(),
|
||||
status: text("status").notNull(),
|
||||
lastSeen: integer("last_seen").notNull(),
|
||||
heartbeatSeconds: integer("heartbeat_seconds").notNull(),
|
||||
fillPercent: integer("fill_percent").notNull().default(0),
|
||||
category: text("category").notNull().default(""),
|
||||
});
|
||||
|
||||
export const weighings = sqliteTable("weighings", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
operator: text("operator").notNull(),
|
||||
machine: text("machine").notNull(),
|
||||
category: text("category").notNull(),
|
||||
source: text("source").notNull(),
|
||||
weight: real("weight").notNull(),
|
||||
eventId: text("event_id").notNull(),
|
||||
status: text("status").notNull(),
|
||||
});
|
||||
|
||||
export const events = sqliteTable("events", {
|
||||
id: text("id").primaryKey(),
|
||||
operator: text("operator").notNull(),
|
||||
enteredAt: integer("entered_at").notNull(),
|
||||
leftAt: integer("left_at").notNull(),
|
||||
atomicCount: integer("atomic_count").notNull(),
|
||||
scaleTotal: real("scale_total").notNull(),
|
||||
verdict: text("verdict").notNull(),
|
||||
});
|
||||
|
||||
export const reconciliations = sqliteTable("reconciliations", {
|
||||
id: text("id").primaryKey(),
|
||||
eventId: text("event_id").notNull(),
|
||||
category: text("category").notNull(),
|
||||
smallScale: real("small_scale").notNull(),
|
||||
floorScale: real("floor_scale").notNull(),
|
||||
difference: real("difference").notNull(),
|
||||
differenceRate: real("difference_rate").notNull(),
|
||||
verdict: text("verdict").notNull(),
|
||||
storageStatus: text("storage_status").notNull(),
|
||||
});
|
||||
|
||||
export const traces = sqliteTable("traces", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
workshop: text("workshop").notNull(),
|
||||
taskId: text("task_id").notNull(),
|
||||
rfid: text("rfid").notNull(),
|
||||
batch: text("batch").notNull(),
|
||||
tare: real("tare").notNull(),
|
||||
gross: real("gross").notNull(),
|
||||
net: real("net").notNull(),
|
||||
machine: text("machine").notNull(),
|
||||
operator: text("operator").notNull(),
|
||||
mesStatus: text("mes_status").notNull(),
|
||||
});
|
||||
|
||||
export const rfidTags = sqliteTable("rfid_tags", {
|
||||
id: text("id").primaryKey(),
|
||||
status: text("status").notNull(),
|
||||
name: text("name").notNull(),
|
||||
lastChecked: integer("last_checked"),
|
||||
});
|
||||
|
||||
export const alerts = sqliteTable("alerts", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
type: text("type").notNull(),
|
||||
severity: text("severity").notNull(),
|
||||
source: text("source").notNull(),
|
||||
reference: text("reference").notNull(),
|
||||
description: text("description").notNull(),
|
||||
status: text("status").notNull(),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const auditLogs = sqliteTable("audit_logs", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
actor: text("actor").notNull(),
|
||||
action: text("action").notNull(),
|
||||
details: text("details").notNull(),
|
||||
level: text("level").notNull(),
|
||||
});
|
||||
|
||||
export const deliveries = sqliteTable("deliveries", {
|
||||
recordId: text("record_id").primaryKey(),
|
||||
deviceId: text("device_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
employeeName: text("employee_name").notNull(),
|
||||
employeeNo: text("employee_no").notNull(),
|
||||
machineNo: text("machine_no").notNull(),
|
||||
productionBatch: text("production_batch").notNull(),
|
||||
garbageType: integer("garbage_type").notNull(),
|
||||
weight: real("weight").notNull(),
|
||||
payload: text("payload").notNull(),
|
||||
status: text("status").notNull(),
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
const schemaStatements = [
|
||||
`CREATE TABLE IF NOT EXISTS people (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, workshop TEXT NOT NULL, role TEXT NOT NULL,
|
||||
face_status TEXT NOT NULL, face_version INTEGER NOT NULL DEFAULT 1,
|
||||
sync_status TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS workshops (
|
||||
code TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, location TEXT NOT NULL, note TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY, type TEXT NOT NULL, ip TEXT NOT NULL, status TEXT NOT NULL,
|
||||
last_seen INTEGER NOT NULL, heartbeat_seconds INTEGER NOT NULL,
|
||||
fill_percent INTEGER NOT NULL DEFAULT 0, category TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS weighings (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, operator TEXT NOT NULL,
|
||||
machine TEXT NOT NULL, category TEXT NOT NULL, source TEXT NOT NULL,
|
||||
weight REAL NOT NULL, event_id TEXT NOT NULL, status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY, operator TEXT NOT NULL, entered_at INTEGER NOT NULL,
|
||||
left_at INTEGER NOT NULL, atomic_count INTEGER NOT NULL,
|
||||
scale_total REAL NOT NULL, verdict TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS reconciliations (
|
||||
id TEXT PRIMARY KEY, event_id TEXT NOT NULL, category TEXT NOT NULL,
|
||||
small_scale REAL NOT NULL, floor_scale REAL NOT NULL, difference REAL NOT NULL,
|
||||
difference_rate REAL NOT NULL, verdict TEXT NOT NULL, storage_status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS traces (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, workshop TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL, rfid TEXT NOT NULL, batch TEXT NOT NULL,
|
||||
tare REAL NOT NULL, gross REAL NOT NULL, net REAL NOT NULL,
|
||||
machine TEXT NOT NULL, operator TEXT NOT NULL, mes_status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS rfid_tags (
|
||||
id TEXT PRIMARY KEY, status TEXT NOT NULL, name TEXT NOT NULL, last_checked INTEGER
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS alerts (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL, source TEXT NOT NULL, reference TEXT NOT NULL,
|
||||
description TEXT NOT NULL, status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL, details TEXT NOT NULL, level TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS deliveries (
|
||||
record_id TEXT PRIMARY KEY, device_id TEXT NOT NULL, created_at INTEGER NOT NULL,
|
||||
employee_name TEXT NOT NULL, employee_no TEXT NOT NULL, machine_no TEXT NOT NULL,
|
||||
production_batch TEXT NOT NULL, garbage_type INTEGER NOT NULL, weight REAL NOT NULL,
|
||||
payload TEXT NOT NULL, status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS weighings_created_at_idx ON weighings(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS alerts_status_idx ON alerts(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS people_updated_at_idx ON people(updated_at)`,
|
||||
];
|
||||
|
||||
const now = Date.now();
|
||||
const minute = 60_000;
|
||||
|
||||
const seedStatements: Array<[string, unknown[]]> = [
|
||||
["INSERT OR IGNORE INTO workshops(code,name,location,note) VALUES(?,?,?,?)", ["WS-01", "一楼新能源车间", "1F · 东侧", "废料回收主区域"]],
|
||||
["INSERT OR IGNORE INTO workshops(code,name,location,note) VALUES(?,?,?,?)", ["WS-02", "二楼导体车间", "2F · 南侧", "导体称重与追溯"]],
|
||||
["INSERT OR IGNORE INTO workshops(code,name,location,note) VALUES(?,?,?,?)", ["WS-03", "仓储中心", "1F · 西侧", "地磅与成品暂存"]],
|
||||
["INSERT OR IGNORE INTO people(id,name,workshop,role,face_status,face_version,sync_status,active,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ["P1001", "张三", "一楼新能源车间", "仓管员", "已录入", 3, "已同步", 1, now - 3 * minute]],
|
||||
["INSERT OR IGNORE INTO people(id,name,workshop,role,face_status,face_version,sync_status,active,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ["P1002", "李四", "二楼导体车间", "产线工人", "已录入", 2, "已同步", 1, now - 8 * minute]],
|
||||
["INSERT OR IGNORE INTO people(id,name,workshop,role,face_status,face_version,sync_status,active,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ["P1003", "赵六", "一楼新能源车间", "仓管员", "需更新", 4, "同步失败", 1, now - 12 * minute]],
|
||||
["INSERT OR IGNORE INTO people(id,name,workshop,role,face_status,face_version,sync_status,active,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ["P1004", "王工", "仓储中心", "管理员", "已录入", 1, "已同步", 1, now - 20 * minute]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["WKS-01", "入库触摸屏", "192.168.10.11", "在线", now - 2_000, 30, 0, "终端"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["SB-01", "小秤台", "192.168.10.12", "在线", now - 3_000, 30, 0, "称重"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["IB-01", "智能回收箱 A", "192.168.10.13", "在线", now - 5_000, 30, 72, "废铜"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["IB-02", "智能回收箱 B", "192.168.10.14", "在线", now - 4_000, 30, 46, "废电线"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["FS-01", "3 吨地磅", "192.168.10.15", "在线", now - 1_000, 30, 0, "称重"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["RFID-01", "RFID 读写器", "192.168.10.16", "在线", now - 6_000, 30, 0, "追溯"]],
|
||||
["INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)", ["W-0001", now - 26 * minute, "张三", "MC-101", "废铜", "SB-01", 12.48, "EV-0728-001", "已确认"]],
|
||||
["INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)", ["W-0002", now - 24 * minute, "张三", "MC-101", "废铜", "SB-01", 9.82, "EV-0728-001", "已确认"]],
|
||||
["INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)", ["W-0003", now - 18 * minute, "赵六", "MC-108", "废电线", "IB-02", 14.50, "EV-0728-002", "偏差超阈"]],
|
||||
["INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)", ["W-0004", now - 9 * minute, "李四", "MC-105", "废锡", "IB-01", 19.50, "EV-0728-003", "已确认"]],
|
||||
["INSERT OR IGNORE INTO events(id,operator,entered_at,left_at,atomic_count,scale_total,verdict) VALUES(?,?,?,?,?,?,?)", ["EV-0728-001", "张三", now - 30 * minute, now - 22 * minute, 2, 22.30, "正常"]],
|
||||
["INSERT OR IGNORE INTO events(id,operator,entered_at,left_at,atomic_count,scale_total,verdict) VALUES(?,?,?,?,?,?,?)", ["EV-0728-002", "赵六", now - 21 * minute, now - 14 * minute, 1, 14.50, "超标"]],
|
||||
["INSERT OR IGNORE INTO events(id,operator,entered_at,left_at,atomic_count,scale_total,verdict) VALUES(?,?,?,?,?,?,?)", ["EV-0728-003", "李四", now - 12 * minute, now - 6 * minute, 1, 19.50, "警告"]],
|
||||
["INSERT OR IGNORE INTO reconciliations(id,event_id,category,small_scale,floor_scale,difference,difference_rate,verdict,storage_status) VALUES(?,?,?,?,?,?,?,?,?)", ["RC-001", "EV-0728-001", "废铜", 22.30, 22.60, 0.30, 1.35, "正常", "已自动入库"]],
|
||||
["INSERT OR IGNORE INTO reconciliations(id,event_id,category,small_scale,floor_scale,difference,difference_rate,verdict,storage_status) VALUES(?,?,?,?,?,?,?,?,?)", ["RC-002", "EV-0728-002", "废电线", 14.50, 15.40, 0.90, 6.21, "超标", "已入库 · 异常"]],
|
||||
["INSERT OR IGNORE INTO reconciliations(id,event_id,category,small_scale,floor_scale,difference,difference_rate,verdict,storage_status) VALUES(?,?,?,?,?,?,?,?,?)", ["RC-003", "EV-0728-003", "废锡", 19.50, 20.10, 0.60, 3.08, "警告", "已入库 · 预警"]],
|
||||
["INSERT OR IGNORE INTO traces(id,created_at,workshop,task_id,rfid,batch,tare,gross,net,machine,operator,mes_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", ["TR-001", now - 48 * minute, "二楼导体车间", "WT-260728-03", "E280-6102-0345", "B2607-018", 152.30, 268.10, 115.80, "MC-201", "李四", "已推送"]],
|
||||
["INSERT OR IGNORE INTO traces(id,created_at,workshop,task_id,rfid,batch,tare,gross,net,machine,operator,mes_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", ["TR-002", now - 90 * minute, "二楼导体车间", "WT-260728-02", "E280-6102-0288", "B2607-017", 148.20, 252.70, 104.50, "MC-203", "周七", "待推送"]],
|
||||
["INSERT OR IGNORE INTO rfid_tags(id,status,name,last_checked) VALUES(?,?,?,?)", ["E280-6102-0345", "在用", "8# 导体轴标签", now - 48 * minute]],
|
||||
["INSERT OR IGNORE INTO rfid_tags(id,status,name,last_checked) VALUES(?,?,?,?)", ["E280-6102-0288", "在用", "7# 导体轴标签", now - 90 * minute]],
|
||||
["INSERT OR IGNORE INTO rfid_tags(id,status,name,last_checked) VALUES(?,?,?,?)", ["E280-6102-0411", "未用", "备用标签", null]],
|
||||
["INSERT OR IGNORE INTO alerts(id,created_at,type,severity,source,reference,description,status) VALUES(?,?,?,?,?,?,?,?)", ["AL-001", now - 16 * minute, "满溢预警", "警告", "废料库", "IB-01", "满溢率达到 72%,建议安排清运", "待处理"]],
|
||||
["INSERT OR IGNORE INTO alerts(id,created_at,type,severity,source,reference,description,status) VALUES(?,?,?,?,?,?,?,?)", ["AL-002", now - 42 * minute, "双秤偏差", "严重", "地磅核对", "EV-0728-002", "地磅增量与台秤汇总偏差 6.21%", "处理中"]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["mes_url", "http://mes.local/api/v2", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["mes_timeout", "10", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["deviation_warn", "2", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["deviation_critical", "5", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["fill_warn", "70", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["fill_critical", "90", now]],
|
||||
["INSERT OR IGNORE INTO audit_logs(id,created_at,actor,action,details,level) VALUES(?,?,?,?,?,?)", ["LOG-001", now - 15 * minute, "系统", "设备心跳", "WKS-01 状态恢复为在线", "信息"]],
|
||||
["INSERT OR IGNORE INTO audit_logs(id,created_at,actor,action,details,level) VALUES(?,?,?,?,?,?)", ["LOG-002", now - 38 * minute, "王工", "处理告警", "AL-002 标记为处理中", "重要"]],
|
||||
];
|
||||
|
||||
export function getD1(): D1Database {
|
||||
if (!env.DB) {
|
||||
throw new Error("D1 数据库未绑定,请将 .openai/hosting.json 的 d1 设置为 DB。");
|
||||
}
|
||||
return env.DB;
|
||||
}
|
||||
|
||||
export async function ensureDatabase() {
|
||||
const db = getD1();
|
||||
await db.batch(schemaStatements.map((statement) => db.prepare(statement)));
|
||||
await db.batch(
|
||||
seedStatements.map(([statement, bindings]) =>
|
||||
db.prepare(statement).bind(...bindings),
|
||||
),
|
||||
);
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function addAudit(
|
||||
actor: string,
|
||||
action: string,
|
||||
details: string,
|
||||
level = "信息",
|
||||
) {
|
||||
const db = getD1();
|
||||
await db
|
||||
.prepare(
|
||||
"INSERT INTO audit_logs(id,created_at,actor,action,details,level) VALUES(?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(crypto.randomUUID(), Date.now(), actor, action, details, level)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function loadConsoleData() {
|
||||
const db = await ensureDatabase();
|
||||
const [
|
||||
peopleResult,
|
||||
workshopResult,
|
||||
deviceResult,
|
||||
weighingResult,
|
||||
eventResult,
|
||||
reconciliationResult,
|
||||
traceResult,
|
||||
tagResult,
|
||||
alertResult,
|
||||
settingResult,
|
||||
logResult,
|
||||
] = await Promise.all([
|
||||
db.prepare("SELECT * FROM people ORDER BY active DESC, name").all(),
|
||||
db.prepare("SELECT * FROM workshops ORDER BY code").all(),
|
||||
db.prepare("SELECT * FROM devices ORDER BY id").all(),
|
||||
db.prepare("SELECT * FROM weighings ORDER BY created_at DESC LIMIT 200").all(),
|
||||
db.prepare("SELECT * FROM events ORDER BY entered_at DESC LIMIT 100").all(),
|
||||
db.prepare("SELECT * FROM reconciliations ORDER BY event_id DESC LIMIT 100").all(),
|
||||
db.prepare("SELECT * FROM traces ORDER BY created_at DESC LIMIT 200").all(),
|
||||
db.prepare("SELECT * FROM rfid_tags ORDER BY status, id").all(),
|
||||
db.prepare("SELECT * FROM alerts ORDER BY created_at DESC LIMIT 200").all(),
|
||||
db.prepare("SELECT * FROM settings ORDER BY key").all(),
|
||||
db.prepare("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 200").all(),
|
||||
]);
|
||||
return {
|
||||
people: peopleResult.results,
|
||||
workshops: workshopResult.results,
|
||||
devices: deviceResult.results,
|
||||
weighings: weighingResult.results,
|
||||
events: eventResult.results,
|
||||
reconciliations: reconciliationResult.results,
|
||||
traces: traceResult.results,
|
||||
tags: tagResult.results,
|
||||
alerts: alertResult.results,
|
||||
settings: Object.fromEntries(
|
||||
settingResult.results.map((row) => [String(row.key), String(row.value)]),
|
||||
),
|
||||
logs: logResult.results,
|
||||
serverTime: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
management-console:
|
||||
build: .
|
||||
container_name: smart-recycling-console
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- console-data:/app/.wrangler/state
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
volumes:
|
||||
console-data:
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./db/schema.ts",
|
||||
out: "./drizzle",
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
CREATE TABLE `alerts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`severity` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`reference` text NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `audit_logs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`actor` text NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`details` text NOT NULL,
|
||||
`level` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `deliveries` (
|
||||
`record_id` text PRIMARY KEY NOT NULL,
|
||||
`device_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`employee_name` text NOT NULL,
|
||||
`employee_no` text NOT NULL,
|
||||
`machine_no` text NOT NULL,
|
||||
`production_batch` text NOT NULL,
|
||||
`garbage_type` integer NOT NULL,
|
||||
`weight` real NOT NULL,
|
||||
`payload` text NOT NULL,
|
||||
`status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `devices` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`ip` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`last_seen` integer NOT NULL,
|
||||
`heartbeat_seconds` integer NOT NULL,
|
||||
`fill_percent` integer DEFAULT 0 NOT NULL,
|
||||
`category` text DEFAULT '' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`operator` text NOT NULL,
|
||||
`entered_at` integer NOT NULL,
|
||||
`left_at` integer NOT NULL,
|
||||
`atomic_count` integer NOT NULL,
|
||||
`scale_total` real NOT NULL,
|
||||
`verdict` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `people` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`workshop` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`face_status` text NOT NULL,
|
||||
`face_version` integer DEFAULT 1 NOT NULL,
|
||||
`sync_status` text NOT NULL,
|
||||
`active` integer DEFAULT true NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `reconciliations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`event_id` text NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`small_scale` real NOT NULL,
|
||||
`floor_scale` real NOT NULL,
|
||||
`difference` real NOT NULL,
|
||||
`difference_rate` real NOT NULL,
|
||||
`verdict` text NOT NULL,
|
||||
`storage_status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `rfid_tags` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`last_checked` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `settings` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `traces` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`workshop` text NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`rfid` text NOT NULL,
|
||||
`batch` text NOT NULL,
|
||||
`tare` real NOT NULL,
|
||||
`gross` real NOT NULL,
|
||||
`net` real NOT NULL,
|
||||
`machine` text NOT NULL,
|
||||
`operator` text NOT NULL,
|
||||
`mes_status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `weighings` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`operator` text NOT NULL,
|
||||
`machine` text NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`weight` real NOT NULL,
|
||||
`event_id` text NOT NULL,
|
||||
`status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `workshops` (
|
||||
`code` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`location` text NOT NULL,
|
||||
`note` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `workshops_name_unique` ON `workshops` (`name`);
|
||||
@@ -0,0 +1,780 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "086a5b90-1490-42d6-a241-39b8df513cd8",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"alerts": {
|
||||
"name": "alerts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"severity": {
|
||||
"name": "severity",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reference": {
|
||||
"name": "reference",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"audit_logs": {
|
||||
"name": "audit_logs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"actor": {
|
||||
"name": "actor",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"level": {
|
||||
"name": "level",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"deliveries": {
|
||||
"name": "deliveries",
|
||||
"columns": {
|
||||
"record_id": {
|
||||
"name": "record_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"employee_name": {
|
||||
"name": "employee_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"employee_no": {
|
||||
"name": "employee_no",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"machine_no": {
|
||||
"name": "machine_no",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"production_batch": {
|
||||
"name": "production_batch",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"garbage_type": {
|
||||
"name": "garbage_type",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payload": {
|
||||
"name": "payload",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"devices": {
|
||||
"name": "devices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"heartbeat_seconds": {
|
||||
"name": "heartbeat_seconds",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fill_percent": {
|
||||
"name": "fill_percent",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"events": {
|
||||
"name": "events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operator": {
|
||||
"name": "operator",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entered_at": {
|
||||
"name": "entered_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"left_at": {
|
||||
"name": "left_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"atomic_count": {
|
||||
"name": "atomic_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scale_total": {
|
||||
"name": "scale_total",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verdict": {
|
||||
"name": "verdict",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"people": {
|
||||
"name": "people",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workshop": {
|
||||
"name": "workshop",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"face_status": {
|
||||
"name": "face_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"face_version": {
|
||||
"name": "face_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"sync_status": {
|
||||
"name": "sync_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"reconciliations": {
|
||||
"name": "reconciliations",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_scale": {
|
||||
"name": "small_scale",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"floor_scale": {
|
||||
"name": "floor_scale",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"difference": {
|
||||
"name": "difference",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"difference_rate": {
|
||||
"name": "difference_rate",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verdict": {
|
||||
"name": "verdict",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"storage_status": {
|
||||
"name": "storage_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rfid_tags": {
|
||||
"name": "rfid_tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_checked": {
|
||||
"name": "last_checked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"traces": {
|
||||
"name": "traces",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workshop": {
|
||||
"name": "workshop",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rfid": {
|
||||
"name": "rfid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"batch": {
|
||||
"name": "batch",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tare": {
|
||||
"name": "tare",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gross": {
|
||||
"name": "gross",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"net": {
|
||||
"name": "net",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"machine": {
|
||||
"name": "machine",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operator": {
|
||||
"name": "operator",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mes_status": {
|
||||
"name": "mes_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"weighings": {
|
||||
"name": "weighings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operator": {
|
||||
"name": "operator",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"machine": {
|
||||
"name": "machine",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"workshops": {
|
||||
"name": "workshops",
|
||||
"columns": {
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note": {
|
||||
"name": "note",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"workshops_name_unique": {
|
||||
"name": "workshops_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1785201529692,
|
||||
"tag": "0000_colorful_carmella_unuscione",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import { getDb } from "../../../../../db";
|
||||
import { notes } from "../../../db/schema";
|
||||
|
||||
function toRouteErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
const detail =
|
||||
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
|
||||
const combined = `${message}\n${detail}`;
|
||||
|
||||
if (combined.includes("no such table") || combined.includes('from "notes"')) {
|
||||
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.orderBy(desc(notes.createdAt), desc(notes.id))
|
||||
.limit(20);
|
||||
|
||||
return Response.json({ notes: rows });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: toRouteErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const payload = (await request.json()) as {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
const title = payload.title?.trim() ?? "";
|
||||
const content = payload.content?.trim() ?? "";
|
||||
|
||||
if (!title) {
|
||||
return Response.json({ error: "title is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [note] = await db.insert(notes).values({ title, content }).returning();
|
||||
return Response.json({ note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: toRouteErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const notes = sqliteTable("notes", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
title: text("title").notNull(),
|
||||
content: text("content").notNull().default(""),
|
||||
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "smart-recycling-operations-console",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
|
||||
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
|
||||
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log wrangler dev --config dist/server/wrangler.json --ip 0.0.0.0 --port 3000",
|
||||
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
||||
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "0.45.2",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "1.37.1",
|
||||
"@tailwindcss/postcss": "4.2.1",
|
||||
"@types/node": "22.19.19",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"@vitejs/plugin-rsc": "0.5.26",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"react-server-dom-webpack": "19.2.6",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3",
|
||||
"vinext": "0.0.50",
|
||||
"vite": "8.0.13",
|
||||
"wrangler": "4.92.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
|
||||
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
|
||||
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
|
||||
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 712 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |