|
|
@@ -0,0 +1,1116 @@
|
|
|
+<template>
|
|
|
+ <view
|
|
|
+ class="faceStorage-page"
|
|
|
+ :data-theme="'theme-' + proxy.$settingStore.themeColor.name"
|
|
|
+ >
|
|
|
+ <view class="faceStorage-header">
|
|
|
+ <text class="iconfont oaIcon-left" @click="handleExit()"></text>
|
|
|
+ <view class="faceStorage-title">人脸数据管理</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <oa-scroll
|
|
|
+ customClass="faceStorage-container scroll-height"
|
|
|
+ :customStyle="{
|
|
|
+ //#ifdef APP-PLUS || MP-WEIXIN
|
|
|
+ height: `calc(100vh - (60px))`,
|
|
|
+ //#endif
|
|
|
+ //#ifdef H5
|
|
|
+ height: `calc(100vh - (60px))`,
|
|
|
+ //#endif
|
|
|
+ }"
|
|
|
+ :refresherLoad="false"
|
|
|
+ :refresherEnabled="false"
|
|
|
+ :refresherDefaultStyle="'none'"
|
|
|
+ :refresherBackground="'#f5f6f7'"
|
|
|
+ :data-theme="'theme-' + proxy.$settingStore.themeColor.name"
|
|
|
+ >
|
|
|
+ <template #default>
|
|
|
+ <view class="container">
|
|
|
+ <!-- 顶部统计 -->
|
|
|
+ <view class="stats-bar">
|
|
|
+ <view class="stat-item">
|
|
|
+ <text class="stat-num">{{ stats.total || 0 }}</text>
|
|
|
+ <text class="stat-label">总数</text>
|
|
|
+ </view>
|
|
|
+ <view class="stat-item">
|
|
|
+ <text class="stat-num">{{ stats.unsynced || 0 }}</text>
|
|
|
+ <text class="stat-label">未同步</text>
|
|
|
+ </view>
|
|
|
+ <view class="stat-item">
|
|
|
+ <text class="stat-num">{{ stats.synced || 0 }}</text>
|
|
|
+ <text class="stat-label">已同步</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 操作按钮 -->
|
|
|
+ <view class="action-bar">
|
|
|
+ <button class="btn btn-primary" @click="addFace">添加人脸</button>
|
|
|
+ <button class="btn btn-info" @click="verifyFace">校验人脸</button>
|
|
|
+ <button class="btn btn-secondary" @click="syncServer">
|
|
|
+ 同步服务器
|
|
|
+ </button>
|
|
|
+ <button class="btn btn-success" @click="exportData">
|
|
|
+ 导出数据
|
|
|
+ </button>
|
|
|
+ <button class="btn btn-warning" @click="importData">
|
|
|
+ 导入数据
|
|
|
+ </button>
|
|
|
+ <button class="btn btn-danger" @click="clearAll">清空数据</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 搜索框 -->
|
|
|
+ <view class="search-box">
|
|
|
+ <input
|
|
|
+ class="search-input"
|
|
|
+ placeholder="搜索人脸ID或姓名..."
|
|
|
+ v-model="searchText"
|
|
|
+ @input="onSearch"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="face-list">
|
|
|
+ <view
|
|
|
+ v-for="face in displayList"
|
|
|
+ :key="face.faceId"
|
|
|
+ class="face-item"
|
|
|
+ >
|
|
|
+ <image
|
|
|
+ v-if="faceImageSrc(face)"
|
|
|
+ class="face-thumb"
|
|
|
+ :src="faceImageSrc(face)"
|
|
|
+ mode="aspectFill"
|
|
|
+ />
|
|
|
+ <view class="face-info">
|
|
|
+ <text class="face-id">{{ face.faceId }}</text>
|
|
|
+ <text v-if="face.faceName" class="face-name">{{
|
|
|
+ face.faceName
|
|
|
+ }}</text>
|
|
|
+ <text class="face-time">{{ formatTime(face.createTime) }}</text>
|
|
|
+ <text
|
|
|
+ class="face-status"
|
|
|
+ :class="getStatusClass(face.syncStatus)"
|
|
|
+ >
|
|
|
+ {{ getStatusText(face.syncStatus) }}
|
|
|
+ </text>
|
|
|
+ </view>
|
|
|
+ <view class="face-actions">
|
|
|
+ <button
|
|
|
+ class="btn-small btn-danger"
|
|
|
+ @click.stop="deleteFace(face.faceId)"
|
|
|
+ >
|
|
|
+ 删除
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="displayList.length === 0" class="empty-state">
|
|
|
+ <text class="empty-text">暂无数据</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 添加人脸弹窗 -->
|
|
|
+ <view v-if="showAddDialog" class="modal" @click="closeAddDialog">
|
|
|
+ <view class="modal-content" @click.stop>
|
|
|
+ <view class="modal-header">
|
|
|
+ <text class="modal-title">添加人脸</text>
|
|
|
+ <text class="modal-close" @click="closeAddDialog">×</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="modal-body">
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="form-label">姓名</text>
|
|
|
+ <input
|
|
|
+ class="form-input"
|
|
|
+ v-model="newFace.faceName"
|
|
|
+ placeholder="请输入姓名"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="form-label">选择图片</text>
|
|
|
+ <button class="btn btn-secondary" @click="selectImage">
|
|
|
+ {{ newFace.imageBase64 ? "重新选择" : "选择图片" }}
|
|
|
+ </button>
|
|
|
+ <image
|
|
|
+ v-if="newFace.imageBase64"
|
|
|
+ class="preview-img"
|
|
|
+ :src="newFace.imageBase64"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="modal-footer">
|
|
|
+ <button class="btn btn-secondary" @click="closeAddDialog">
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ class="btn btn-primary"
|
|
|
+ @click="confirmAdd"
|
|
|
+ :disabled="!canAdd"
|
|
|
+ >
|
|
|
+ 确定
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 校验人脸弹窗 -->
|
|
|
+ <view
|
|
|
+ v-if="showVerifyDialog"
|
|
|
+ class="modal"
|
|
|
+ @click="closeVerifyDialog"
|
|
|
+ >
|
|
|
+ <view class="modal-content large" @click.stop>
|
|
|
+ <view class="modal-header">
|
|
|
+ <text class="modal-title">校验人脸</text>
|
|
|
+ <text class="modal-close" @click="closeVerifyDialog">×</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="modal-body">
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="form-label">选择图片</text>
|
|
|
+ <button class="btn btn-secondary" @click="selectVerifyImage">
|
|
|
+ {{ verifyImage.imageBase64 ? "重新选择" : "选择图片" }}
|
|
|
+ </button>
|
|
|
+ <image
|
|
|
+ v-if="verifyImage.imageBase64"
|
|
|
+ class="preview-img"
|
|
|
+ :src="verifyImage.imageBase64"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 校验结果 -->
|
|
|
+ <view v-if="verifyResult" class="verify-result">
|
|
|
+ <view class="result-header">
|
|
|
+ <text class="result-title">校验结果</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="result-summary">
|
|
|
+ <text
|
|
|
+ class="result-text"
|
|
|
+ :class="verifyResult.hasFace ? 'success' : 'error'"
|
|
|
+ >
|
|
|
+ {{ verifyResult.hasFace ? "检测到人脸" : "未检测到人脸" }}
|
|
|
+ </text>
|
|
|
+ <text
|
|
|
+ v-if="verifyResult.hasFace && verifyResult.matchedFaceId"
|
|
|
+ class="result-text success"
|
|
|
+ >
|
|
|
+ 匹配的人脸ID: {{ verifyResult.matchedFaceId }}
|
|
|
+ </text>
|
|
|
+ <text
|
|
|
+ v-if="verifyResult.hasFace && verifyResult.matchedFaceId"
|
|
|
+ class="result-text success"
|
|
|
+ >
|
|
|
+ 相似度: {{ (verifyResult.similarity * 100).toFixed(1) }}%
|
|
|
+ </text>
|
|
|
+ <text
|
|
|
+ v-if="verifyResult.hasFace && !verifyResult.matchedFaceId"
|
|
|
+ class="result-text warning"
|
|
|
+ >
|
|
|
+ 未找到匹配的人脸
|
|
|
+ </text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="modal-footer">
|
|
|
+ <button class="btn btn-secondary" @click="closeVerifyDialog">
|
|
|
+ 关闭
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ class="btn btn-primary"
|
|
|
+ @click="confirmVerify"
|
|
|
+ :disabled="!verifyImage.imageBase64 || loading"
|
|
|
+ >
|
|
|
+ {{ loading ? "校验中..." : "开始校验" }}
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 加载提示 -->
|
|
|
+ <view v-if="loading" class="loading">
|
|
|
+ <text class="loading-text">{{ loadingText }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </template>
|
|
|
+ </oa-scroll>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { computed, getCurrentInstance, reactive, ref } from "vue";
|
|
|
+import { onLoad, onPullDownRefresh, onShow } from "@dcloudio/uni-app";
|
|
|
+import sysPlugins from "@/plugins/device/sys.plugins";
|
|
|
+
|
|
|
+const { proxy } = getCurrentInstance();
|
|
|
+
|
|
|
+// 人脸识别插件
|
|
|
+const faceAIModule = uni.requireNativePlugin("FaceAISDKModule");
|
|
|
+const modal = uni.requireNativePlugin("modal");
|
|
|
+
|
|
|
+// 数据
|
|
|
+const faceList = ref([]);
|
|
|
+const stats = ref({});
|
|
|
+const searchText = ref("");
|
|
|
+
|
|
|
+// 弹窗状态
|
|
|
+const showAddDialog = ref(false);
|
|
|
+const showVerifyDialog = ref(false);
|
|
|
+
|
|
|
+// 添加人脸(人脸ID 由原生插件按本地库规则生成,前端不传 faceId)
|
|
|
+const newFace = reactive({
|
|
|
+ faceName: "",
|
|
|
+ imageBase64: "",
|
|
|
+});
|
|
|
+
|
|
|
+// 校验人脸
|
|
|
+const verifyImage = reactive({
|
|
|
+ imageBase64: "",
|
|
|
+});
|
|
|
+const verifyResult = ref(null);
|
|
|
+
|
|
|
+// 加载状态
|
|
|
+const loading = ref(false);
|
|
|
+const loadingText = ref("加载中...");
|
|
|
+
|
|
|
+const displayList = computed(() => {
|
|
|
+ const q = searchText.value.trim().toLowerCase();
|
|
|
+ if (!q) return faceList.value;
|
|
|
+ return faceList.value.filter((face) => {
|
|
|
+ const idMatch =
|
|
|
+ face.faceId && String(face.faceId).toLowerCase().includes(q);
|
|
|
+ const nameMatch =
|
|
|
+ face.faceName && String(face.faceName).toLowerCase().includes(q);
|
|
|
+ return idMatch || nameMatch;
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+const canAdd = computed(() => {
|
|
|
+ return Boolean(newFace.faceName.trim() && newFace.imageBase64);
|
|
|
+});
|
|
|
+
|
|
|
+function handleExit() {
|
|
|
+ proxy.$tab.navigateBack(1);
|
|
|
+}
|
|
|
+
|
|
|
+async function loadData() {
|
|
|
+ loading.value = true;
|
|
|
+ loadingText.value = "加载数据中...";
|
|
|
+
|
|
|
+ try {
|
|
|
+ await Promise.all([getStats(), getFaceList()]);
|
|
|
+ } catch (e) {
|
|
|
+ console.error("加载数据失败:", e);
|
|
|
+ modal.toast({ message: "加载失败", duration: 1500 });
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function getStats() {
|
|
|
+ await faceAIModule.getFaceStorageStats({}, (result) => {
|
|
|
+ if (result && result.code === 1) {
|
|
|
+ stats.value = result.data || {};
|
|
|
+ } else {
|
|
|
+ console.error("获取统计失败:", result);
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function getFaceList() {
|
|
|
+ await faceAIModule.getFaceList({ page: 0, pageSize: 1000 }, (result) => {
|
|
|
+ if (result && result.code === 1) {
|
|
|
+ faceList.value = result.data || [];
|
|
|
+ } else {
|
|
|
+ console.error("获取列表失败:", result);
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function addFace() {
|
|
|
+ newFace.faceName = "";
|
|
|
+ newFace.imageBase64 = "";
|
|
|
+ showAddDialog.value = true;
|
|
|
+}
|
|
|
+
|
|
|
+function verifyFace() {
|
|
|
+ verifyImage.imageBase64 = "";
|
|
|
+ verifyResult.value = null;
|
|
|
+ showVerifyDialog.value = true;
|
|
|
+}
|
|
|
+
|
|
|
+// 同步服务器数据:deviceCode 从 sysPlugins.getDeviceInfo().serial 获取
|
|
|
+async function syncServer() {
|
|
|
+ const info = sysPlugins.getDeviceInfo();
|
|
|
+ const deviceCode = info && info.serial ? String(info.serial).trim() : "";
|
|
|
+ if (!deviceCode) {
|
|
|
+ modal.toast({ message: "获取设备编码失败(serial 为空)", duration: 2000 });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ loading.value = true;
|
|
|
+ loadingText.value = "同步服务器数据中...";
|
|
|
+
|
|
|
+ await faceAIModule.downloadFacesFromServer(
|
|
|
+ { deviceCode, faceIds: [] },
|
|
|
+ (result) => {
|
|
|
+ if (result && result.code === 1) {
|
|
|
+ modal.toast({ message: result.msg || "同步完成", duration: 2000 });
|
|
|
+ loadData();
|
|
|
+ } else {
|
|
|
+ modal.toast({
|
|
|
+ message: "同步失败: " + (result ? result.msg : "未知错误"),
|
|
|
+ duration: 2500,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ loading.value = false;
|
|
|
+ },
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function selectImage() {
|
|
|
+ uni.chooseImage({
|
|
|
+ count: 1,
|
|
|
+ sizeType: ["compressed"],
|
|
|
+ sourceType: ["album", "camera"],
|
|
|
+ success: (res) => readImageFile(res.tempFilePaths[0]),
|
|
|
+ fail: (e) => {
|
|
|
+ console.error("选择图片失败:", e);
|
|
|
+ modal.toast({ message: "选择图片失败", duration: 1500 });
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function selectVerifyImage() {
|
|
|
+ uni.chooseImage({
|
|
|
+ count: 1,
|
|
|
+ sizeType: ["compressed"],
|
|
|
+ sourceType: ["album", "camera"],
|
|
|
+ success: (res) => readVerifyImageFile(res.tempFilePaths[0]),
|
|
|
+ fail: (e) => {
|
|
|
+ console.error("选择图片失败:", e);
|
|
|
+ modal.toast({ message: "选择图片失败", duration: 1500 });
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function readImageFile(filePath) {
|
|
|
+ console.log("开始读取图片文件:", filePath);
|
|
|
+ readImageFileWithPlus(filePath);
|
|
|
+}
|
|
|
+
|
|
|
+function readVerifyImageFile(filePath) {
|
|
|
+ console.log("开始读取校验图片文件:", filePath);
|
|
|
+ readVerifyImageFileWithPlus(filePath);
|
|
|
+}
|
|
|
+
|
|
|
+function readImageFileWithPlus(filePath) {
|
|
|
+ if (typeof plus === "undefined") {
|
|
|
+ console.error("plus对象不可用,可能不在App环境中");
|
|
|
+ modal.toast({
|
|
|
+ message: "当前环境不支持文件读取,请重新选择",
|
|
|
+ duration: 1500,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ plus.io.resolveLocalFileSystemURL(
|
|
|
+ filePath,
|
|
|
+ (entry) => {
|
|
|
+ if (entry.isDirectory) {
|
|
|
+ console.error("路径指向目录,不是文件:", filePath);
|
|
|
+ modal.toast({
|
|
|
+ message: "选择的路径是目录,请选择图片文件",
|
|
|
+ duration: 1500,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ entry.file(
|
|
|
+ (file) => {
|
|
|
+ if (file.size === 0) {
|
|
|
+ modal.toast({ message: "文件为空,请重新选择", duration: 1500 });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const reader = new plus.io.FileReader();
|
|
|
+ reader.onloadend = (e) => {
|
|
|
+ const dataUrl = (e && e.target && e.target.result) || "";
|
|
|
+ if (dataUrl) {
|
|
|
+ const base64 = String(dataUrl).replace(/^data:[^;]+;base64,/, "");
|
|
|
+ newFace.imageBase64 = `data:image/jpeg;base64,${base64}`;
|
|
|
+ } else {
|
|
|
+ modal.toast({
|
|
|
+ message: "图片数据为空,请重新选择",
|
|
|
+ duration: 1500,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+ reader.onerror = (err) => {
|
|
|
+ console.error("文件读取失败:", err);
|
|
|
+ modal.toast({
|
|
|
+ message: "读取图片失败,请重新选择",
|
|
|
+ duration: 1500,
|
|
|
+ });
|
|
|
+ };
|
|
|
+ reader.readAsDataURL(file);
|
|
|
+ },
|
|
|
+ (err) => {
|
|
|
+ console.error("获取文件对象失败:", err);
|
|
|
+ modal.toast({ message: "文件访问失败,请重新选择", duration: 1500 });
|
|
|
+ },
|
|
|
+ );
|
|
|
+ },
|
|
|
+ (err) => {
|
|
|
+ console.error("解析文件路径失败:", err);
|
|
|
+ modal.toast({ message: "文件路径解析失败,请重新选择", duration: 1500 });
|
|
|
+ },
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function readVerifyImageFileWithPlus(filePath) {
|
|
|
+ if (typeof plus === "undefined") {
|
|
|
+ console.error("plus对象不可用,可能不在App环境中");
|
|
|
+ modal.toast({
|
|
|
+ message: "当前环境不支持文件读取,请重新选择",
|
|
|
+ duration: 1500,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ plus.io.resolveLocalFileSystemURL(
|
|
|
+ filePath,
|
|
|
+ (entry) => {
|
|
|
+ if (entry.isDirectory) {
|
|
|
+ console.error("路径指向目录,不是文件:", filePath);
|
|
|
+ modal.toast({
|
|
|
+ message: "选择的路径是目录,请选择图片文件",
|
|
|
+ duration: 1500,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ entry.file(
|
|
|
+ (file) => {
|
|
|
+ if (file.size === 0) {
|
|
|
+ modal.toast({ message: "文件为空,请重新选择", duration: 1500 });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const reader = new plus.io.FileReader();
|
|
|
+ reader.onloadend = (e) => {
|
|
|
+ const dataUrl = (e && e.target && e.target.result) || "";
|
|
|
+ if (dataUrl) {
|
|
|
+ const base64 = String(dataUrl).replace(/^data:[^;]+;base64,/, "");
|
|
|
+ verifyImage.imageBase64 = `data:image/jpeg;base64,${base64}`;
|
|
|
+ } else {
|
|
|
+ modal.toast({
|
|
|
+ message: "图片数据为空,请重新选择",
|
|
|
+ duration: 1500,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+ reader.onerror = (err) => {
|
|
|
+ console.error("校验图片文件读取失败:", err);
|
|
|
+ modal.toast({
|
|
|
+ message: "读取图片失败,请重新选择",
|
|
|
+ duration: 1500,
|
|
|
+ });
|
|
|
+ };
|
|
|
+ reader.readAsDataURL(file);
|
|
|
+ },
|
|
|
+ (err) => {
|
|
|
+ console.error("获取校验图片文件对象失败:", err);
|
|
|
+ modal.toast({ message: "文件访问失败,请重新选择", duration: 1500 });
|
|
|
+ },
|
|
|
+ );
|
|
|
+ },
|
|
|
+ (err) => {
|
|
|
+ console.error("解析校验图片文件路径失败:", err);
|
|
|
+ modal.toast({ message: "文件路径解析失败,请重新选择", duration: 1500 });
|
|
|
+ },
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+async function confirmAdd() {
|
|
|
+ if (!canAdd.value) {
|
|
|
+ modal.toast({ message: "请填写完整信息", duration: 1500 });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ loading.value = true;
|
|
|
+ loadingText.value = "添加中...";
|
|
|
+
|
|
|
+ await faceAIModule.addFaceToStorage(
|
|
|
+ {
|
|
|
+ faceName: newFace.faceName.trim(),
|
|
|
+ faceBase64: newFace.imageBase64,
|
|
|
+ source: 0,
|
|
|
+ },
|
|
|
+ (result) => {
|
|
|
+ if (result && result.code === 1) {
|
|
|
+ const fid = result.data && result.data.faceId;
|
|
|
+ modal.toast({
|
|
|
+ message: fid ? `添加成功,人脸ID:${fid}` : "添加成功",
|
|
|
+ duration: 1500,
|
|
|
+ });
|
|
|
+ closeAddDialog();
|
|
|
+ loadData();
|
|
|
+ } else {
|
|
|
+ modal.toast({
|
|
|
+ message: "添加失败: " + (result ? result.msg : "未知错误"),
|
|
|
+ duration: 2000,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ loading.value = false;
|
|
|
+ },
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+async function confirmVerify() {
|
|
|
+ if (!verifyImage.imageBase64) {
|
|
|
+ modal.toast({ message: "请选择图片", duration: 1500 });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ loading.value = true;
|
|
|
+ loadingText.value = "校验中...";
|
|
|
+
|
|
|
+ await faceAIModule.verifyFaceByImage(
|
|
|
+ { imageBase64: verifyImage.imageBase64 },
|
|
|
+ (result) => {
|
|
|
+ if (result && result.code === 1) {
|
|
|
+ modal.toast({
|
|
|
+ message: `人脸验证成功: ${result.matchedFaceId}`,
|
|
|
+ duration: 2000,
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ modal.toast({
|
|
|
+ message: `人脸验证失败: ${result ? result.msg : "未知错误"}`,
|
|
|
+ duration: 2000,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ loading.value = false;
|
|
|
+ },
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteFace(faceId) {
|
|
|
+ const res = await new Promise((resolve) => {
|
|
|
+ uni.showModal({
|
|
|
+ title: "确认删除",
|
|
|
+ content: `确定要删除 "${faceId}" 吗?`,
|
|
|
+ success: (r) => resolve(Boolean(r.confirm)),
|
|
|
+ });
|
|
|
+ });
|
|
|
+ if (!res) return;
|
|
|
+
|
|
|
+ loading.value = true;
|
|
|
+ loadingText.value = "删除中...";
|
|
|
+
|
|
|
+ await faceAIModule.deleteFaceFromStorage({ faceId }, (result) => {
|
|
|
+ if (result && result.code === 1) {
|
|
|
+ modal.toast({ message: "删除成功", duration: 1500 });
|
|
|
+ loadData();
|
|
|
+ } else {
|
|
|
+ modal.toast({
|
|
|
+ message: "删除失败: " + (result ? result.msg : "未知错误"),
|
|
|
+ duration: 2000,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ loading.value = false;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function exportData() {
|
|
|
+ loading.value = true;
|
|
|
+ loadingText.value = "导出中...";
|
|
|
+
|
|
|
+ await faceAIModule.exportFaceData({}, (result) => {
|
|
|
+ if (result && result.code === 1) {
|
|
|
+ modal.toast({
|
|
|
+ message: `已导出 ${faceList.value.length} 条数据`,
|
|
|
+ duration: 2000,
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ modal.toast({
|
|
|
+ message: "导出失败: " + (result ? result.msg : "未知错误"),
|
|
|
+ duration: 2000,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ loading.value = false;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function importData() {
|
|
|
+ modal.toast({ message: "导入功能开发中", duration: 1500 });
|
|
|
+}
|
|
|
+
|
|
|
+async function clearAll() {
|
|
|
+ const res = await new Promise((resolve) => {
|
|
|
+ uni.showModal({
|
|
|
+ title: "确认清空",
|
|
|
+ content: "确定要清空所有数据吗?此操作不可恢复!",
|
|
|
+ success: (r) => resolve(Boolean(r.confirm)),
|
|
|
+ });
|
|
|
+ });
|
|
|
+ if (!res) return;
|
|
|
+
|
|
|
+ loading.value = true;
|
|
|
+ loadingText.value = "清空中...";
|
|
|
+
|
|
|
+ await faceAIModule.clearAllFaceData({}, (result) => {
|
|
|
+ if (result && result.code === 1) {
|
|
|
+ modal.toast({ message: "清空成功", duration: 1500 });
|
|
|
+ loadData();
|
|
|
+ } else {
|
|
|
+ modal.toast({
|
|
|
+ message: "清空失败: " + (result ? result.msg : "未知错误"),
|
|
|
+ duration: 2000,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ loading.value = false;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function onSearch() {
|
|
|
+ // 搜索逻辑在computed中处理
|
|
|
+}
|
|
|
+
|
|
|
+function closeAddDialog() {
|
|
|
+ showAddDialog.value = false;
|
|
|
+ newFace.faceName = "";
|
|
|
+ newFace.imageBase64 = "";
|
|
|
+}
|
|
|
+
|
|
|
+function closeVerifyDialog() {
|
|
|
+ showVerifyDialog.value = false;
|
|
|
+ verifyImage.imageBase64 = "";
|
|
|
+ verifyResult.value = null;
|
|
|
+}
|
|
|
+
|
|
|
+function formatTime(timestamp) {
|
|
|
+ if (!timestamp) return "";
|
|
|
+ const date = new Date(timestamp);
|
|
|
+ return date.toLocaleDateString() + " " + date.toLocaleTimeString();
|
|
|
+}
|
|
|
+
|
|
|
+function getStatusText(status) {
|
|
|
+ const statusMap = { 0: "未同步", 1: "已同步", 2: "同步失败" };
|
|
|
+ return statusMap[status] || "未知";
|
|
|
+}
|
|
|
+
|
|
|
+function getStatusClass(status) {
|
|
|
+ const classMap = {
|
|
|
+ 0: "status-unsynced",
|
|
|
+ 1: "status-synced",
|
|
|
+ 2: "status-failed",
|
|
|
+ };
|
|
|
+ return classMap[status] || "";
|
|
|
+}
|
|
|
+
|
|
|
+function getSourceText(source) {
|
|
|
+ const sourceMap = { 0: "本地录入", 1: "服务器下发" };
|
|
|
+ return sourceMap[source] || "未知";
|
|
|
+}
|
|
|
+
|
|
|
+function faceImageSrc(face) {
|
|
|
+ if (!face) return "";
|
|
|
+ const p = String(face.imagePath || face.face_image_path || "").trim();
|
|
|
+ if (!p) return "";
|
|
|
+ if (p.startsWith("data:image/")) return p;
|
|
|
+ if (p.startsWith("/")) return "file://" + p;
|
|
|
+ if (p.includes("://")) return p;
|
|
|
+ return p;
|
|
|
+}
|
|
|
+
|
|
|
+onLoad(() => {
|
|
|
+ loadData();
|
|
|
+});
|
|
|
+
|
|
|
+onShow(() => {
|
|
|
+ loadData();
|
|
|
+});
|
|
|
+
|
|
|
+onPullDownRefresh(() => {
|
|
|
+ loadData().finally(() => {
|
|
|
+ uni.stopPullDownRefresh();
|
|
|
+ });
|
|
|
+});
|
|
|
+</script>
|
|
|
+<style lang="scss" scoped>
|
|
|
+@import "../setting/index.scss";
|
|
|
+</style>
|
|
|
+<style scoped>
|
|
|
+.faceStorage-page {
|
|
|
+ background: white;
|
|
|
+ min-height: 100vh;
|
|
|
+}
|
|
|
+
|
|
|
+.faceStorage-header {
|
|
|
+ height: 60px;
|
|
|
+ padding: 0 20rpx;
|
|
|
+ background: white;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ position: sticky;
|
|
|
+ top: 0;
|
|
|
+ z-index: 50;
|
|
|
+}
|
|
|
+
|
|
|
+.faceStorage-title {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 30rpx;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #111;
|
|
|
+ margin-right: 40rpx; /* 视觉居中,抵消左侧返回按钮 */
|
|
|
+}
|
|
|
+
|
|
|
+.faceStorage-container {
|
|
|
+ background: #f5f5f5 !important;
|
|
|
+}
|
|
|
+
|
|
|
+/* 统计栏 */
|
|
|
+.stats-bar {
|
|
|
+ display: flex;
|
|
|
+ background: white;
|
|
|
+ border-radius: 12rpx;
|
|
|
+ padding: 30rpx;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.stat-item {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-num {
|
|
|
+ display: block;
|
|
|
+ font-size: 32rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #007aff;
|
|
|
+ margin-bottom: 8rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-label {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #666;
|
|
|
+}
|
|
|
+
|
|
|
+/* 操作栏 */
|
|
|
+.action-bar {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 15rpx;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.btn {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 150rpx;
|
|
|
+ height: 70rpx;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ border: none;
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: white;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-primary {
|
|
|
+ background: #007aff;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-success {
|
|
|
+ background: #28a745;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-warning {
|
|
|
+ background: #ffc107;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-danger {
|
|
|
+ background: #dc3545;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-secondary {
|
|
|
+ background: #6c757d;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-info {
|
|
|
+ background: #17a2b8;
|
|
|
+}
|
|
|
+
|
|
|
+.btn:disabled {
|
|
|
+ opacity: 0.6;
|
|
|
+}
|
|
|
+
|
|
|
+/* 搜索框 */
|
|
|
+.search-box {
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.search-input {
|
|
|
+ width: 100%;
|
|
|
+ height: 70rpx;
|
|
|
+ padding: 0 20rpx;
|
|
|
+ border: 2rpx solid #ddd;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ background: white;
|
|
|
+ font-size: 26rpx;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.face-list {
|
|
|
+ width: 100%;
|
|
|
+ background: white;
|
|
|
+ border-radius: 12rpx;
|
|
|
+ padding: 20rpx;
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.face-item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 20rpx 0;
|
|
|
+ border-bottom: 1rpx solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.face-thumb {
|
|
|
+ width: 96rpx;
|
|
|
+ height: 96rpx;
|
|
|
+ border-radius: 10rpx;
|
|
|
+ background: #f0f0f0;
|
|
|
+ margin-right: 18rpx;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.face-item:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+}
|
|
|
+
|
|
|
+.face-info {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.face-id {
|
|
|
+ display: block;
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #333;
|
|
|
+ margin-bottom: 8rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.face-name {
|
|
|
+ display: block;
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #555;
|
|
|
+ margin-bottom: 6rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.face-time {
|
|
|
+ display: block;
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #666;
|
|
|
+ margin-bottom: 4rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.face-status {
|
|
|
+ font-size: 20rpx;
|
|
|
+ padding: 4rpx 8rpx;
|
|
|
+ border-radius: 4rpx;
|
|
|
+ color: white;
|
|
|
+}
|
|
|
+
|
|
|
+.status-unsynced {
|
|
|
+ background: #ffc107;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.status-synced {
|
|
|
+ background: #28a745;
|
|
|
+}
|
|
|
+
|
|
|
+.status-failed {
|
|
|
+ background: #dc3545;
|
|
|
+}
|
|
|
+
|
|
|
+.face-actions {
|
|
|
+ margin-left: 20rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-small {
|
|
|
+ padding: 8rpx 16rpx;
|
|
|
+ border-radius: 6rpx;
|
|
|
+ border: none;
|
|
|
+ font-size: 20rpx;
|
|
|
+ color: white;
|
|
|
+}
|
|
|
+
|
|
|
+/* 空状态 */
|
|
|
+.empty-state {
|
|
|
+ text-align: center;
|
|
|
+ padding: 100rpx 0;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-text {
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #999;
|
|
|
+}
|
|
|
+
|
|
|
+/* 弹窗 */
|
|
|
+.modal {
|
|
|
+ position: fixed;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ z-index: 1000;
|
|
|
+}
|
|
|
+
|
|
|
+.modal-content {
|
|
|
+ width: 90%;
|
|
|
+ max-width: 600rpx;
|
|
|
+ background: white;
|
|
|
+ border-radius: 12rpx;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.modal-content.large {
|
|
|
+ max-width: 800rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.modal-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 30rpx;
|
|
|
+ border-bottom: 1rpx solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.modal-title {
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.modal-close {
|
|
|
+ font-size: 40rpx;
|
|
|
+ color: #999;
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.modal-body {
|
|
|
+ padding: 30rpx;
|
|
|
+ max-height: 60vh;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.modal-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 20rpx;
|
|
|
+ padding: 30rpx;
|
|
|
+ border-top: 1rpx solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 表单 */
|
|
|
+.form-item {
|
|
|
+ margin-bottom: 30rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.form-label {
|
|
|
+ display: block;
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #333;
|
|
|
+ margin-bottom: 10rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.form-input {
|
|
|
+ width: 100%;
|
|
|
+ height: 70rpx;
|
|
|
+ padding: 0 20rpx;
|
|
|
+ border: 2rpx solid #ddd;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ font-size: 26rpx;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-img {
|
|
|
+ width: 200rpx;
|
|
|
+ height: 200rpx;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ margin-top: 20rpx;
|
|
|
+}
|
|
|
+
|
|
|
+/* 加载提示 */
|
|
|
+.loading {
|
|
|
+ position: fixed;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ z-index: 2000;
|
|
|
+}
|
|
|
+
|
|
|
+.loading-text {
|
|
|
+ background: white;
|
|
|
+ padding: 30rpx 40rpx;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+/* 校验结果样式 */
|
|
|
+.verify-result {
|
|
|
+ margin-top: 30rpx;
|
|
|
+ padding: 20rpx;
|
|
|
+ background: #f8f9fa;
|
|
|
+ border-radius: 8rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.result-header {
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.result-title {
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.result-summary {
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.result-text {
|
|
|
+ display: block;
|
|
|
+ font-size: 26rpx;
|
|
|
+ margin-bottom: 10rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.result-text.success {
|
|
|
+ color: #28a745;
|
|
|
+}
|
|
|
+
|
|
|
+.result-text.error {
|
|
|
+ color: #dc3545;
|
|
|
+}
|
|
|
+
|
|
|
+.result-text.warning {
|
|
|
+ color: #ffc107;
|
|
|
+}
|
|
|
+</style>
|