فهرست منبع

feat: 导出前的本地修改

ruhuxu 1 هفته پیش
والد
کامیت
6caef40ff1
8فایلهای تغییر یافته به همراه2065 افزوده شده و 198 حذف شده
  1. 401 0
      js/reader/app.js
  2. 180 0
      js/reader/ka-nfc-page.js
  3. 385 0
      js/reader/reader-client.js
  4. 364 0
      page/biz/ka_nfcAdd.ss.jsp
  5. 438 0
      page/ka_nfcAdd.jsp
  6. 0 130
      page/xyZjz_excelAdd copy.jsp
  7. 297 68
      page/xyZjz_excelAdd.jsp
  8. BIN
      skin/easy/image/xy-zjz-face-mask.png

+ 401 - 0
js/reader/app.js

@@ -0,0 +1,401 @@
+const reader = new ReaderClient();
+const MAX_LOG_LINES = 300;
+const logLines = [];
+
+// 状态
+let isPolling = false;
+let pollingTimer = null;
+let readCount = 0;
+
+// 去重相关
+let seenUids = new Map(); // uid -> { protocol, time }
+
+const elements = {
+  statusTag: document.getElementById("statusTag"),
+  wsUrlText: document.getElementById("wsUrlText"),
+  uidText: document.getElementById("nfch"),	// ("uidText")。Lin
+  readCountText: document.getElementById("readCountText"),
+  lastProtocolText: document.getElementById("lastProtocolText"),
+
+  // ISO14443A
+  keyTypeInput: document.getElementById("keyTypeInput"),
+  keyInput: document.getElementById("keyInput"),
+  blockAddressInput: document.getElementById("blockAddressInput"),
+  numberOfBlocksReadInput: document.getElementById("numberOfBlocksReadInput"),
+
+  // ISO15693
+  readDataTypeInput: document.getElementById("readDataTypeInput"),
+  readSecurityStatusInput: document.getElementById("readSecurityStatusInput"),
+  iso15693BlockAddressInput: document.getElementById("iso15693BlockAddressInput"),
+  iso15693NumberOfBlocksInput: document.getElementById("iso15693NumberOfBlocksInput"),
+
+  // 轮询
+  pollingInterval: document.getElementById("pollingInterval"),
+  dedupeMode: document.getElementById("dedupeMode"),
+  dedupeTime: document.getElementById("dedupeTime"),
+  dedupeTimeItem: document.getElementById("dedupeTimeItem"),
+
+  // 按钮
+  btnConnect: document.getElementById("btnConnect"),
+  btnReadUid: document.getElementById("btnReadUid"),
+  btnStartPolling: document.getElementById("btnStartPolling"),
+  btnStopPolling: document.getElementById("btnStopPolling"),
+  btnClearLog: document.getElementById("btnClearLog"),
+  loadingHint: document.getElementById("loadingHint"),
+  logArea: document.getElementById("logArea"),
+};
+
+function log(message, type = "info") {
+  const time = new Date().toLocaleTimeString();
+  const prefix = type === "success" ? "✓" : type === "error" ? "✗" : type === "warn" ? "⚠" : "•";
+  const line = `[${time}] ${prefix} ${message}`;
+  console.log(line);
+  logLines.unshift(line);
+  if (logLines.length > MAX_LOG_LINES) {
+    logLines.length = MAX_LOG_LINES;
+  }
+  elements.logArea.value = logLines.join("\n");
+}
+
+function getErrorMessage(err) {
+  if (err instanceof Error && err.message) {
+    return err.message;
+  }
+  if (typeof err === "string") {
+    return err;
+  }
+  try {
+    return JSON.stringify(err);
+  } catch {
+    return "未知错误";
+  }
+}
+
+function setLoading(text = "") {
+  elements.loadingHint.textContent = text;
+}
+
+function setStatus(text, ok) {
+  elements.statusTag.textContent = text;
+  elements.statusTag.classList.toggle("ok", Boolean(ok));
+  elements.statusTag.classList.toggle("bad", !ok);
+}
+
+function syncButtons() {
+  const connected = reader.connected;
+  elements.btnReadUid.disabled = !connected;
+  elements.btnStartPolling.disabled = !connected || isPolling;
+  elements.btnStopPolling.disabled = !connected || !isPolling;
+}
+
+function updateReadCount() {
+  readCount++;
+  elements.readCountText.textContent = readCount;
+}
+
+function normalizeHexKey(value) {
+  return String(value || "")
+    .trim()
+    .toUpperCase()
+    .replace(/[^0-9A-F]/g, "");
+}
+
+function applyReaderConfig() {
+  // ISO14443A 参数
+  const key = normalizeHexKey(elements.keyInput.value);
+  const keyType = String(elements.keyTypeInput.value || "0");
+  const blockAddress = String(elements.blockAddressInput.value || "1").trim();
+  const numberOfBlocksRead = String(
+    elements.numberOfBlocksReadInput.value || "1"
+  ).trim();
+
+  if (!/^[0-9A-F]{12}$/.test(key)) {
+    throw new Error("密钥必须是 12 位十六进制字符");
+  }
+  if (!["0", "1"].includes(keyType)) {
+    throw new Error("密钥类型必须是 Key A 或 Key B");
+  }
+  if (!/^\d+$/.test(blockAddress)) {
+    throw new Error("块地址必须是非负整数");
+  }
+  if (!/^\d+$/.test(numberOfBlocksRead) || Number(numberOfBlocksRead) <= 0) {
+    throw new Error("读取块数必须是大于 0 的整数");
+  }
+
+  reader.keyType = keyType;
+  reader.key = key;
+  reader.blockAddress = blockAddress;
+  reader.numberOfBlocksRead = numberOfBlocksRead;
+
+  elements.keyInput.value = key;
+
+  // ISO15693 参数
+  reader.readDataType = elements.readDataTypeInput.value;
+  reader.readSecurityStatus = elements.readSecurityStatusInput.value;
+  reader.iso15693BlockAddress = elements.iso15693BlockAddressInput.value;
+  reader.iso15693NumberOfBlocks = elements.iso15693NumberOfBlocksInput.value;
+}
+
+async function connectReader() {
+  applyReaderConfig();
+  setLoading("连接中...");
+  log("开始连接读卡器(双协议检测)");
+  await reader.selectDevice();
+  setStatus("已连接", true);
+  setLoading("");
+  syncButtons();
+  log("读卡器连接成功");
+}
+
+// 检测 UID 是否已存在(去重)
+function checkDuplicate(uid) {
+  const dedupeMode = elements.dedupeMode.value;
+  const dedupeTimeMs = parseInt(elements.dedupeTime.value, 10);
+
+  if (dedupeMode === "none") {
+    return { isDup: false };
+  }
+
+  if (dedupeMode === "session") {
+    if (seenUids.has(uid)) {
+      const info = seenUids.get(uid);
+      return { isDup: true, reason: `已在会话中读取过(${info.protocol})` };
+    }
+    return { isDup: false };
+  }
+
+  if (dedupeMode === "time") {
+    const lastTime = seenUids.has(uid) ? seenUids.get(uid).time : null;
+    if (lastTime && Date.now() - lastTime < dedupeTimeMs) {
+      const info = seenUids.get(uid);
+      const remain = Math.ceil((dedupeTimeMs - (Date.now() - lastTime)) / 1000);
+      return { isDup: true, reason: `时间窗口内已读取(${info.protocol}),${remain}秒后可再次读取` };
+    }
+    return { isDup: false };
+  }
+
+  return { isDup: false };
+}
+
+function recordUid(uid, protocol) {
+  seenUids.set(uid, { protocol, time: Date.now() });
+}
+
+function clearDedupeHistory() {
+  seenUids.clear();
+  log("去重历史已清空");
+}
+
+// 同步读取两种协议,返回成功读取的结果
+async function readUidBothProtocols() {
+  if (!reader.connected) {
+    throw new Error("读卡器未连接,请先连接读卡器");
+  }
+
+  applyReaderConfig();
+
+  const results = [];
+
+  // 先尝试 ISO14443A
+  try {
+    log("尝试读取 ISO14443A...");
+    const uid14443 = await reader.readUid("ISO14443A");
+    results.push({ protocol: "ISO14443A", uid: uid14443 });
+  } catch (err) {
+    if (!err.message?.includes("未读取到 UID")) {
+      log(`ISO14443A 读取异常: ${getErrorMessage(err)}`, "warn");
+    }
+  }
+
+  // 再尝试 ISO15693
+  try {
+    log("尝试读取 ISO15693...");
+    const uid15693 = await reader.readUid("ISO15693");
+    results.push({ protocol: "ISO15693", uid: uid15693 });
+  } catch (err) {
+    if (!err.message?.includes("未读取到 UID")) {
+      log(`ISO15693 读取异常: ${getErrorMessage(err)}`, "warn");
+    }
+  }
+
+  if (results.length === 0) {
+    throw new Error("未读取到 UID,请确认卡片/标签已到读卡位");
+  }
+
+  // 处理读取结果(去重逻辑)
+  const validResults = [];
+  for (const result of results) {
+    const dupCheck = checkDuplicate(result.uid);
+    if (dupCheck.isDup) {
+      log(`${result.protocol} 读取到 UID: ${result.uid}(${dupCheck.reason})`, "warn");
+    } else {
+      validResults.push(result);
+    }
+  }
+
+  if (validResults.length === 0) {
+    // 全都是重复的,取第一个显示但不记录
+    return results[0];
+  }
+
+  // 返回第一个有效结果
+  const firstResult = validResults[0];
+  recordUid(firstResult.uid, firstResult.protocol);
+  return firstResult;
+}
+
+async function readUid(showError = true) {
+  setLoading("读取 UID 中...");
+
+  const result = await readUidBothProtocols();
+
+  elements.uidText.textContent = result.uid;
+  elements.lastProtocolText.textContent = result.protocol;
+  updateReadCount();
+  setLoading("");
+  log(`读取成功 [${result.protocol}] UID=${result.uid}`, "success");
+
+  // Console 输出 UID
+  console.log("%c[RFID] 读取到 UID:", "color: #10b981; font-weight: bold; font-size: 14px;", result.uid);
+  console.log("%c[RFID] 协议:", "color: #64748b;", result.protocol);
+  console.log("%c[RFID] 时间:", "color: #64748b;", new Date().toISOString());
+
+  return result;
+}
+
+async function startPolling() {
+  if (isPolling) return;
+
+  if (!reader.connected) {
+    log("读卡器未连接,无法启动轮询", "error");
+    alert("请先连接读卡器");
+    return;
+  }
+
+  isPolling = true;
+  syncButtons();
+
+  const interval = parseInt(elements.pollingInterval.value, 10);
+  log(`开始定时轮询(双协议),间隔 ${interval}ms`);
+
+  // 立即执行一次
+  await doPoll();
+
+  // 定时轮询
+  pollingTimer = setInterval(async () => {
+    if (!isPolling) return;
+    await doPoll();
+  }, interval);
+}
+
+async function doPoll() {
+  try {
+    await readUid(false);
+  } catch (err) {
+    // 轮询模式下,读不到卡不报错
+    const msg = getErrorMessage(err);
+    if (!msg.includes("未读取到 UID")) {
+      log(`轮询错误: ${msg}`, "error");
+    }
+  }
+}
+
+function stopPolling() {
+  if (!isPolling) return;
+
+  isPolling = false;
+  if (pollingTimer) {
+    clearInterval(pollingTimer);
+    pollingTimer = null;
+  }
+
+  syncButtons();
+  setLoading("");
+  log("已停止定时轮询");
+}
+
+function clearLog() {
+  logLines.length = 0;
+  elements.logArea.value = "";
+  log("日志已清空");
+}
+
+function initView() {
+  elements.wsUrlText.textContent = reader.wsUrl;
+  elements.uidText.textContent = "-";
+  elements.readCountText.textContent = "0";
+  elements.lastProtocolText.textContent = "-";
+
+  // ISO14443A 默认值
+  elements.keyTypeInput.value = reader.keyType;
+  elements.keyInput.value = reader.key;
+  elements.blockAddressInput.value = reader.blockAddress;
+  elements.numberOfBlocksReadInput.value = reader.numberOfBlocksRead;
+
+  // ISO15693 默认值
+  elements.readDataTypeInput.value = reader.readDataType;
+  elements.readSecurityStatusInput.value = reader.readSecurityStatus;
+  elements.iso15693BlockAddressInput.value = reader.iso15693BlockAddress;
+  elements.iso15693NumberOfBlocksInput.value = reader.iso15693NumberOfBlocks;
+
+  setStatus("未连接", false);
+  syncButtons();
+  log("页面已初始化,支持双协议自动检测(ISO14443A + ISO15693)");
+}
+
+function bindEvents() {
+  // 连接读卡器
+  elements.btnConnect.addEventListener("click", async () => {
+    try {
+      await connectReader();
+    } catch (err) {
+      const msg = getErrorMessage(err);
+      setStatus("连接失败", false);
+      setLoading("");
+      syncButtons();
+      log(`连接失败: ${msg}`, "error");
+      alert(msg);
+    }
+  });
+
+  // 读取 UID
+  elements.btnReadUid.addEventListener("click", async () => {
+    try {
+      await readUid(true);
+    } catch (err) {
+      const msg = getErrorMessage(err);
+      setLoading("");
+      log(`读取失败: ${msg}`, "error");
+      alert(msg);
+    }
+  });
+
+  // 开始轮询
+  elements.btnStartPolling.addEventListener("click", () => {
+    startPolling();
+  });
+
+  // 停止轮询
+  elements.btnStopPolling.addEventListener("click", () => {
+    stopPolling();
+  });
+
+  // 清空日志
+  elements.btnClearLog.addEventListener("click", () => {
+    clearLog();
+  });
+
+  // 去重模式切换
+  elements.dedupeMode.addEventListener("change", (e) => {
+    if (e.target.value === "time") {
+      elements.dedupeTimeItem.style.display = "block";
+    } else {
+      elements.dedupeTimeItem.style.display = "none";
+    }
+    // 切换去重模式时清空历史
+    clearDedupeHistory();
+  });
+}
+
+bindEvents();
+initView();

+ 180 - 0
js/reader/ka-nfc-page.js

@@ -0,0 +1,180 @@
+(function () {
+  var reader = new ReaderClient();
+  var pollingTimer = null;
+  var isReading = false;
+  var isConnecting = false;
+  var lastUid = "";
+
+  function getEl(id) {
+    return document.getElementById(id);
+  }
+
+  function getValue(id, fallback) {
+    var el = getEl(id);
+    if (!el || el.value == null || el.value === "") {
+      return fallback;
+    }
+    return String(el.value).trim();
+  }
+
+  function setStatus(text, color) {
+    var statusEl = getEl("readerStatusTag");
+    if (!statusEl) return;
+    statusEl.textContent = text;
+    statusEl.style.color = color || "#64748b";
+  }
+
+  function showConnectButton(show) {
+    var button = getEl("btnConnect");
+    if (!button) return;
+    button.style.display = show ? "inline-flex" : "none";
+  }
+
+  function normalizeHexKey(value) {
+    return String(value || "")
+      .trim()
+      .toUpperCase()
+      .replace(/[^0-9A-F]/g, "");
+  }
+
+  function applyReaderConfig() {
+    reader.wsUrl = getValue("wsUrlText", "ws://127.0.0.1:6689");
+    reader.keyType = getValue("keyTypeInput", "0");
+    reader.key = normalizeHexKey(getValue("keyInput", "FFFFFFFFFFFF"));
+    reader.blockAddress = getValue("blockAddressInput", "1");
+    reader.numberOfBlocksRead = getValue("numberOfBlocksReadInput", "1");
+    reader.readDataType = getValue("readDataTypeInput", "0");
+    reader.readSecurityStatus = getValue("readSecurityStatusInput", "0");
+    reader.iso15693BlockAddress = getValue("iso15693BlockAddressInput", "0");
+    reader.iso15693NumberOfBlocks = getValue("iso15693NumberOfBlocksInput", "7");
+  }
+
+  function setNfcValue(uid) {
+    var input = getEl("nfch");
+    if (!input) return;
+    input.value = uid;
+    input.dispatchEvent(new Event("input", { bubbles: true }));
+    input.dispatchEvent(new Event("change", { bubbles: true }));
+  }
+
+  function stopPolling() {
+    if (pollingTimer) {
+      clearInterval(pollingTimer);
+      pollingTimer = null;
+    }
+    isReading = false;
+  }
+
+  function handleDisconnect(message) {
+    stopPolling();
+    reader.connected = false;
+    reader.handle = null;
+    setStatus(message || "读卡器未连接", "#ef4444");
+    showConnectButton(true);
+  }
+
+  async function readUidBothProtocols() {
+    var protocols = ["ISO14443A", "ISO15693"];
+    for (var i = 0; i < protocols.length; i++) {
+      try {
+        var uid = await reader.readUid(protocols[i]);
+        if (uid) {
+          return { uid: uid, protocol: protocols[i] };
+        }
+      } catch (err) {
+        var msg = err && err.message ? err.message : String(err || "");
+        if (
+          msg.indexOf("未读取到 UID") === -1 &&
+          msg.indexOf("标签已到读卡位") === -1 &&
+          msg.indexOf("卡片已到读卡位") === -1
+        ) {
+          throw err;
+        }
+      }
+    }
+    return null;
+  }
+
+  async function pollOnce() {
+    if (isReading || !reader.connected) {
+      return;
+    }
+
+    isReading = true;
+    try {
+      applyReaderConfig();
+      var result = await readUidBothProtocols();
+      if (result && result.uid) {
+        if (result.uid !== lastUid) {
+          lastUid = result.uid;
+        }
+        setNfcValue(result.uid);
+      }
+    } catch (err) {
+      var msg = err && err.message ? err.message : "读卡失败";
+      if (msg.indexOf("连接") !== -1 || msg.indexOf("断开") !== -1 || msg.indexOf("超时") !== -1) {
+        handleDisconnect("读卡器连接已断开");
+      }
+    } finally {
+      isReading = false;
+    }
+  }
+
+  function startPolling() {
+    if (pollingTimer || !reader.connected) {
+      return;
+    }
+    var interval = parseInt(getValue("pollingInterval", "1000"), 10);
+    if (!interval || interval < 100) {
+      interval = 1000;
+    }
+    pollOnce();
+    pollingTimer = setInterval(pollOnce, interval);
+  }
+
+  async function connectReader(isManual) {
+    if (isConnecting) {
+      return;
+    }
+
+    isConnecting = true;
+    setStatus("读卡器连接中...", "#64748b");
+    showConnectButton(false);
+
+    try {
+      applyReaderConfig();
+      await reader.selectDevice();
+      setStatus("读卡器连接成功", "#16a34a");
+      showConnectButton(false);
+      startPolling();
+    } catch (err) {
+      stopPolling();
+      var msg = err && err.message ? err.message : "读卡器连接失败";
+      setStatus(msg, "#ef4444");
+      showConnectButton(true);
+      if (isManual) {
+        alert(msg);
+      }
+    } finally {
+      isConnecting = false;
+    }
+  }
+
+  function bindEvents() {
+    var button = getEl("btnConnect");
+    if (button) {
+      button.addEventListener("click", function () {
+        connectReader(true);
+      });
+    }
+  }
+
+  function init() {
+    bindEvents();
+    setStatus("读卡器连接中...", "#64748b");
+    showConnectButton(false);
+    connectReader(false);
+  }
+
+  window.addEventListener("load", init);
+})();

+ 385 - 0
js/reader/reader-client.js

@@ -0,0 +1,385 @@
+class ReaderClient {
+  constructor(options = {}) {
+    this.wsUrl = options.wsUrl || "ws://127.0.0.1:6689";
+    this.driveType = options.driveType || "D5200";
+    this.portName = options.portName || "USB";
+    this.hidSN = options.hidSN || "";
+    this.driverIp = options.driverIp || "";
+    this.driverPort = options.driverPort || "6688";
+
+    // ISO14443A 参数
+    this.keyType = options.keyType || "0";
+    this.key = options.key || "FFFFFFFFFFFF";
+    this.blockAddress = options.blockAddress || "1";
+    this.numberOfBlocksRead = options.numberOfBlocksRead || "1";
+
+    // ISO15693 参数
+    this.readDataType = options.readDataType || "0";
+    this.readSecurityStatus = options.readSecurityStatus || "0";
+    this.iso15693BlockAddress = options.iso15693BlockAddress || "0";
+    this.iso15693NumberOfBlocks = options.iso15693NumberOfBlocks || "7";
+
+    this.ws = null;
+    this.connected = false;
+    this.handle = null;
+    this.requestId = 1;
+    this.pending = new Map();
+  }
+
+  async init() {
+    if (typeof WebSocket === "undefined") {
+      throw new Error("当前浏览器不支持 WebSocket");
+    }
+    return true;
+  }
+
+  async ensureWsConnected() {
+    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+      return;
+    }
+
+    if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
+      await new Promise((resolve, reject) => {
+        const timeoutRef = setTimeout(
+          () => reject(new Error("读卡器服务连接超时")),
+          5000
+        );
+        const onOpen = () => {
+          clearTimeout(timeoutRef);
+          this.ws.removeEventListener("open", onOpen);
+          this.ws.removeEventListener("error", onError);
+          resolve();
+        };
+        const onError = () => {
+          clearTimeout(timeoutRef);
+          this.ws.removeEventListener("open", onOpen);
+          this.ws.removeEventListener("error", onError);
+          reject(new Error("读卡器服务连接失败"));
+        };
+        this.ws.addEventListener("open", onOpen);
+        this.ws.addEventListener("error", onError);
+      });
+      return;
+    }
+
+    this.ws = new WebSocket(this.wsUrl);
+
+    this.ws.onmessage = (event) => {
+      this.handleMessage(event.data);
+    };
+
+    this.ws.onclose = () => {
+      this.connected = false;
+      this.handle = null;
+      for (const [, item] of this.pending) {
+        clearTimeout(item.timeoutRef);
+        item.reject(new Error("读卡器服务连接已断开"));
+      }
+      this.pending.clear();
+    };
+
+    this.ws.onerror = () => {
+      // 具体错误在请求超时/close时抛出
+    };
+
+    await new Promise((resolve, reject) => {
+      const timeoutRef = setTimeout(
+        () => reject(new Error("读卡器服务连接超时")),
+        5000
+      );
+      this.ws.onopen = () => {
+        clearTimeout(timeoutRef);
+        resolve();
+      };
+      this.ws.onerror = () => {
+        clearTimeout(timeoutRef);
+        reject(new Error("读卡器服务连接失败,请先启动厂家读卡器服务程序"));
+      };
+    });
+  }
+
+  handleMessage(rawData) {
+    let payload;
+    try {
+      payload = JSON.parse(rawData);
+    } catch {
+      for (const [, item] of this.pending) {
+        clearTimeout(item.timeoutRef);
+        item.resolve(rawData);
+      }
+      this.pending.clear();
+      return;
+    }
+
+    const traceId = payload.traceId;
+    if (traceId && this.pending.has(traceId)) {
+      const item = this.pending.get(traceId);
+      this.pending.delete(traceId);
+      clearTimeout(item.timeoutRef);
+      item.resolve(payload);
+      return;
+    }
+
+    if (this.pending.size === 1) {
+      const firstKey = this.pending.keys().next().value;
+      const item = this.pending.get(firstKey);
+      this.pending.delete(firstKey);
+      clearTimeout(item.timeoutRef);
+      item.resolve(payload);
+    }
+  }
+
+  async sendCommand(command, param = {}, handleOverride, timeoutMs = 6000) {
+    await this.ensureWsConnected();
+
+    const traceId = `r${Date.now()}_${this.requestId++}`;
+    const message = {
+      command,
+      param,
+      traceId,
+    };
+
+    const finalHandle = handleOverride ?? this.handle;
+    if (finalHandle !== null && finalHandle !== undefined) {
+      message.handle = String(finalHandle);
+    }
+
+    const promise = new Promise((resolve, reject) => {
+      const timeoutRef = setTimeout(() => {
+        this.pending.delete(traceId);
+        reject(new Error(`${command} 超时`));
+      }, timeoutMs);
+
+      this.pending.set(traceId, { resolve, reject, timeoutRef });
+    });
+
+    this.ws.send(JSON.stringify(message));
+    return promise;
+  }
+
+  getPayloadCode(payload) {
+    if (!payload || typeof payload !== "object") {
+      return null;
+    }
+    if (payload.code != null) {
+      return Number(payload.code);
+    }
+    if (payload.status != null) {
+      return Number(payload.status);
+    }
+    if (payload.result?.code != null) {
+      return Number(payload.result.code);
+    }
+    return null;
+  }
+
+  getPayloadMessage(payload) {
+    if (!payload || typeof payload !== "object") {
+      return "";
+    }
+    return String(
+      payload.msg ??
+        payload.message ??
+        payload.error ??
+        payload.result?.msg ??
+        payload.result?.message ??
+        ""
+    );
+  }
+
+  extractHandle(payload) {
+    if (!payload || typeof payload !== "object") {
+      return null;
+    }
+    const h = payload.handle ?? payload.result?.handle ?? payload.data?.handle;
+    if (h === null || h === undefined || h === "") {
+      return null;
+    }
+    return String(h);
+  }
+
+  extractTagDataList(payload) {
+    if (!payload || typeof payload !== "object") {
+      return [];
+    }
+
+    if (Array.isArray(payload.tagDataList)) {
+      return payload.tagDataList;
+    }
+    if (Array.isArray(payload.data)) {
+      return payload.data;
+    }
+    if (payload.data && Array.isArray(payload.data.tagDataList)) {
+      return payload.data.tagDataList;
+    }
+    if (payload.result && Array.isArray(payload.result.tagDataList)) {
+      return payload.result.tagDataList;
+    }
+    if (payload.result && Array.isArray(payload.result.data)) {
+      return payload.result.data;
+    }
+    return [];
+  }
+
+  parseUidFromTagRow(row) {
+    if (!row) {
+      return "";
+    }
+
+    if (Array.isArray(row)) {
+      return String(row[1] || "").trim();
+    }
+
+    if (typeof row === "object") {
+      if (Array.isArray(row.value)) {
+        return String(row.value[1] || "").trim();
+      }
+      if (Array.isArray(row.data)) {
+        return String(row.data[1] || "").trim();
+      }
+      if (Array.isArray(row.tag)) {
+        return String(row.tag[1] || "").trim();
+      }
+
+      for (const value of Object.values(row)) {
+        if (Array.isArray(value)) {
+          return String(value[1] || "").trim();
+        }
+      }
+    }
+
+    return "";
+  }
+
+  async selectDevice() {
+    await this.init();
+
+    try {
+      await this.sendCommand("CloseDriver", {}, -1, 3000);
+    } catch {
+      // 忽略
+    }
+
+    const openResp = await this.sendCommand(
+      "OpenDriver",
+      {
+        driveType: this.driveType,
+        portName: this.portName,
+        hidSN: this.hidSN,
+        driverIp: this.driverIp,
+        driverPort: this.driverPort,
+        driverId: "",
+        userName: "",
+        passWord: "",
+      },
+      undefined,
+      8000
+    );
+
+    const code = this.getPayloadCode(openResp);
+    if (code != null && code < 0) {
+      const msg = this.getPayloadMessage(openResp) || "OpenDriver 失败";
+      throw new Error(msg);
+    }
+
+    const openedHandle = this.extractHandle(openResp);
+    if (!openedHandle) {
+      throw new Error("OpenDriver 未返回 handle");
+    }
+
+    this.handle = openedHandle;
+    this.connected = true;
+    return true;
+  }
+
+  // ISO14443A 读取 UID
+  async readUidISO14443A() {
+    await this.init();
+    if (!this.connected || !this.handle) {
+      throw new Error("读卡器未连接,请先连接读卡器");
+    }
+
+    const readResp = await this.sendCommand(
+      "ISO14443A_BatchReadMultiBlocks",
+      {
+        antennaCollection: [1],
+        readDataType: "0",
+        keyType: this.keyType,
+        key: this.key,
+        blockAddress: this.blockAddress,
+        numberOfBlocksRead: this.numberOfBlocksRead,
+      },
+      this.handle,
+      6000
+    );
+
+    const code = this.getPayloadCode(readResp);
+    if (code != null && code < 0) {
+      const msg = this.getPayloadMessage(readResp) || "ISO14443A 读卡失败";
+      throw new Error(msg);
+    }
+
+    const tagDataList = this.extractTagDataList(readResp);
+    if (!Array.isArray(tagDataList) || tagDataList.length === 0) {
+      throw new Error("未读取到 UID,请确认 ISO14443A 卡片已到读卡位");
+    }
+
+    const firstRow = tagDataList[0];
+    const uid = this.parseUidFromTagRow(firstRow);
+    if (!uid) {
+      throw new Error("未读取到 UID,请确认 ISO14443A 卡片已到读卡位");
+    }
+
+    return uid.toUpperCase();
+  }
+
+  // ISO15693 读取 UID
+  async readUidISO15693() {
+    await this.init();
+    if (!this.connected || !this.handle) {
+      throw new Error("读卡器未连接,请先连接读卡器");
+    }
+
+    const readResp = await this.sendCommand(
+      "ISO15693_BatchReadMultiBlocks",
+      {
+        antennaCollection: [1],
+        readDataType: this.readDataType,
+        readSecurityStatus: this.readSecurityStatus,
+        blockAddress: this.iso15693BlockAddress,
+        numberOfBlocksRead: this.iso15693NumberOfBlocks,
+      },
+      this.handle,
+      6000
+    );
+
+    const code = this.getPayloadCode(readResp);
+    if (code != null && code < 0) {
+      const msg = this.getPayloadMessage(readResp) || "ISO15693 读卡失败";
+      throw new Error(msg);
+    }
+
+    const tagDataList = this.extractTagDataList(readResp);
+    if (!Array.isArray(tagDataList) || tagDataList.length === 0) {
+      throw new Error("未读取到 UID,请确认 ISO15693 标签已到读卡位");
+    }
+
+    const firstRow = tagDataList[0];
+    const uid = this.parseUidFromTagRow(firstRow);
+    if (!uid) {
+      throw new Error("未读取到 UID,请确认 ISO15693 标签已到读卡位");
+    }
+
+    return uid.toUpperCase();
+  }
+
+  // 根据协议类型读取 UID
+  async readUid(protocol = "ISO14443A") {
+    if (protocol === "ISO15693") {
+      return await this.readUidISO15693();
+    }
+    return await this.readUidISO14443A();
+  }
+}
+
+window.ReaderClient = ReaderClient;

+ 364 - 0
page/biz/ka_nfcAdd.ss.jsp

@@ -0,0 +1,364 @@
+<html>
+<head>
+
+	<style>
+	  .table-container>tr>th{
+		  width:130px !important;
+	  }
+	  /* 把content-box的高度限制 从公共css 抽到具体有需要的页面 by xu 20251215 */
+	  .form-container .content-box {
+		  height: calc(100% - 80px) !important;
+		      padding-top: 40px !important;
+	  }
+	  td{
+		display: flex;
+		align-items: center;
+		justify-content: flex-start;
+		}
+	</style>
+
+</head>
+<body class="env-input-body">
+<script src="/js/reader/reader-client.js"></script>
+<script type="text/javascript" src="/js/reader/ka-nfc-page.js"></script>
+<div style="position:fixed;top:0px;left:0px;z-index:9999;padding:8px 24px;display:flex;align-items:center;gap:12px;">
+	<button id="btnConnect" class="primary" type="button" style="display:none;">连接读卡器</button>
+	<span id="readerStatusTag" style="font-size:14px;color:#64748b;">读卡器连接中...</span>
+</div>
+<input id="wsUrlText" type="hidden" value="ws://127.0.0.1:6689"/> <%-- WebSocket。Lin --%>
+<input id="pollingInterval" type="hidden" value="1000" min="100" step="100" /> <%-- 轮询间隔 (毫秒)。Lin --%>
+<input id="dedupeMode" type="hidden" value="session"/> <%-- 去重模式:本次会话去重。Lin --%>
+<%-- ISO14443A 参数。Lin --%>
+<input id="keyTypeInput" type="hidden" value="0"/> <%-- 密钥类型:Key A。1 = Key B。Lin --%>
+<input id="keyInput" type="hidden" value="FFFFFFFFFFFF" maxlength="12"/> <%-- 密钥。Lin --%>
+<input id="blockAddressInput" type="hidden" value="1" min="0" /> <%-- 块地址。Lin --%>
+<input id="numberOfBlocksReadInput" type="hidden" value="1" min="1"/> <%-- 读取块数。Lin --%>
+<%-- --%>
+<%-- ISO15693 参数。Lin --%>
+<input id="readDataTypeInput" type="hidden" value="0"/> <%-- 读取数据类型:block。1 = afi,2 = eas,3 = block and afi,4 = block and eas,5 = afi and eas,6 = block and afi and eas。Lin --%>
+<input id="readSecurityStatusInput" type="hidden" value="0"/> <%-- 读取安全位:不读取。1 = 读取。Lin --%>
+<input id="iso15693BlockAddressInput" type="hidden" value="0" min="0"/> <%-- 块地址。Lin --%>
+<input id="iso15693NumberOfBlocksInput" type="hidden" value="7" min="1"/> <%-- 读取块数。Lin --%>
+<%-- --%>
+<form method="post" id="app" class="form-container">
+<div class="content-box fit-height-content">
+
+	<div class="content-div" ssFith="true">
+		<table class='form'>
+
+			<tr>
+				<th>部门/亲属</th>
+				<td style="display: flex;align-items: center;">
+					<objp.ss name="bmid" cb="bm" inp="true" width="200px"/>
+					<onoff.ss name="rylbm" mode="edit" rad="false" null="false" val="1100" />
+					<input name="rylbm" value="职工亲属" ssVal="1000" />
+				</td>
+			</tr>
+
+<%-- 先去掉,接入读卡器时再加。Lin
+			<tr>
+				<th>卡号</th>
+				<td >
+					<@input name="kah"/>
+				</td>
+			</tr>
+--%>
+
+			<tr>
+				<th>人员</th>
+				<td>
+					<input name="czryid" type="hidden" value='${sessionScope.ssUser.ryid}'/> <%-- 操作人员ID。Lin --%>
+
+					<objp.ss name="cyryid" cb="ryByBmOrRylb" inp="true" onChange="selBaseInfoByRyid" filterField="bmid,rylbm" />
+				</td>
+			
+				<th>部门</th>
+				<td id='bm'></td>
+			</tr>
+			<tr>
+				<th>姓名</th>
+				<td id='xm'></td>
+			
+				<th>人员号</th>
+				<td id='ryh'></td>
+			</tr>
+			<tr>
+				<th>NFC</th>
+				<td>
+					<input id="nfch" name="kah"/>
+				</td>
+			</tr>
+		</table>
+	</div>
+
+
+	<div class='bottom-div'>
+
+		<ss-bottom-button
+				id="saveAndCommit"
+				text="保存并提交"
+				onclick='submitGrtfForm();'<%-- 功能说明:个人退费页提交前先校验金额必须为负数 by xu 20260323 --%>
+				icon-class="bottom-div-save"
+		></ss-bottom-button>
+
+
+		<ss-bottom-button
+				text="关闭"
+				onclick='ss.display.closeDialog();'
+				icon-class="bottom-div-close"
+		></ss-bottom-button>
+
+	</div>
+</div>
+
+</form>
+</body>
+
+</html>
+
+<script>
+	function getFormAppVm(){
+		var appEl = document.getElementById("app");
+		if (!appEl || !appEl.__vue_app__ || !appEl.__vue_app__._instance) {
+			return null;
+		}
+		return appEl.__vue_app__._instance.proxy || null;
+	}
+
+	function normalizeRylbmValues(value){
+		if (Array.isArray(value)) {
+			return value.map(function(item){
+				return item == null ? "" : item.toString();
+			}).filter(Boolean);
+		}
+		if (value == null || value === "") {
+			return [];
+		}
+		var cleanValue = value.toString().replace(/^,+/, "");
+		if (!cleanValue) {
+			return [];
+		}
+		return cleanValue.split(/[,|]/).filter(Boolean);
+	}
+
+	function hasRelativeRylbmValue(value){
+		return normalizeRylbmValues(value).indexOf("1000") !== -1;
+	}
+
+	function clearRyDisplay(){
+		var bmbjEl = document.getElementById('bmbj');
+		var xmEl = document.getElementById('xm');
+		var ryhEl = document.getElementById('ryh');
+		if (bmbjEl) bmbjEl.innerHTML = "";
+		if (xmEl) xmEl.innerHTML = "";
+		if (ryhEl) ryhEl.innerHTML = "";
+	}
+
+	function clearRySelection(vm){
+		if (!vm) {
+			clearRyDisplay();
+			return;
+		}
+		vm.ryid = "";
+		clearRyDisplay();
+	}
+
+	function handleRylbmChange(groupValue){
+		if (!hasRelativeRylbmValue(groupValue)) {
+			return;
+		}
+		var vm = getFormAppVm();
+		if (!vm) {
+			clearRyDisplay();
+			return;
+		}
+		vm.bmid = "";
+		clearRySelection(vm);
+	}
+
+	function handleBjChange(value){
+		if (value == null || value === "") {
+			return;
+		}
+		var vm = getFormAppVm();
+		if (!vm) {
+			clearRyDisplay();
+			return;
+		}
+		if (hasRelativeRylbmValue(vm.rylbm)) {
+			vm.rylbm = "1100";
+		}
+		clearRySelection(vm);
+	}
+	
+	function selBaseInfoByRyid(value){
+		$.ajax({
+			url:"<serv.ss name='ry_selBaseInfoByRyid'/>",
+			type:"post",
+			data:{
+				ryid:value
+			},
+			dataType:"json",
+			success:function(data){
+				if (data.ssCode != 0) {
+					alert(data.ssMsg);
+					return;
+				}
+				var d = data.ssData;
+				document.getElementById('xm').innerHTML = d.xm;
+				document.getElementById('ryh').innerHTML = d.ryh;
+				if (d.rylbm != 1100) {	// 不是学员。Lin
+					if (d.bmmc)
+						document.getElementById('bmbj').innerHTML = d.bmmc;
+					else
+						document.getElementById('bmbj').innerHTML = "(无)";
+				} else {
+					if (d.bjmc)
+						document.getElementById('bmbj').innerHTML = d.bjmc;
+					else
+						document.getElementById('bmbj').innerHTML = "(无)";
+				}
+				document.getElementById('xfye').innerHTML = d.xfye;
+			}
+		});
+	}
+</script>
+<script type="text/javascript" src="/js/validate/validator-rules.js"></script><%-- 功能说明:个人退费页接入桌面端校验规则,支持 SsInp 红线提示 by xu 20260323 --%>
+<script type="text/javascript" src="/js/validate/validation-manager.js"></script><%-- 功能说明:个人退费页接入桌面端校验管理器,支持 SsInp 红线提示 by xu 20260323 --%>
+
+<script>
+	var GRTF_LABEL_MAP = window.GRTF_LABEL_MAP || {};
+
+	// 功能说明:记录当前退费类别的最新 value/label,避免 onoff 隐藏值切换时机导致金额校验判断失真 by xu 20260323
+	var CURRENT_GRTF_CATEGORY_STATE = {
+		value: "",
+		label: ""
+	};
+
+	// 功能说明:退费类别变化后延后一拍重跑金额校验,确保隐藏字段值更新后再清理红线状态 by xu 20260323
+	function handleGrczlbmChange(groupValue, value, label){
+		CURRENT_GRTF_CATEGORY_STATE.value = groupValue == null ? "" : groupValue.toString();
+		CURRENT_GRTF_CATEGORY_STATE.label = label == null ? "" : label.toString();
+		setTimeout(function(){
+			if (window.ssVm && typeof window.ssVm.validateField === "function") {
+				window.ssVm.validateField("je");
+				return;
+			}
+			validateNegativeAmountByCategory(false);
+		}, 0);
+	}
+
+	// 功能说明:初始化个人退费页金额负数校验规则,整页金额都必须输入负数 by xu 20260323
+	function initNegativeAmountValidation(){
+		if (!document.querySelector('[name="je"]') || !document.querySelector('[name="grczlbm"]')) {
+			return;
+		}
+		if (!window.ssVm || typeof window.ssVm.add !== "function" || window.ss.dom._grczGrtfNegativeAmountValidationInited) {
+			return;
+		}
+		window.ss.dom._grczGrtfNegativeAmountValidationInited = true;
+		window.ssVm.add("ss.commonValidator.custom", ["je"], {
+			msgPrfx: "金额",
+			relField: "grczlbm",
+			validate: function(value, categoryValue){
+				var currentLabel = getCurrentGrczlbmLabel(categoryValue);
+				if (value == null || value.toString().trim() === "") {
+					return true;
+				}
+				if (isValidNegativeAmountText(value)) {
+					return true;
+				}
+				return {
+					valid: false,
+					message: (currentLabel || "退费") + "金额只能输入负数"
+				};
+			}
+		}, {
+			je: window.ss.dom.formElemConfig.je ? window.ss.dom.formElemConfig.je.val : ""
+		});
+	}
+
+	// 功能说明:根据当前退费类别值读取显示文案,供联动校验和错误提示复用 by xu 20260323
+	function getCurrentGrczlbmLabel(categoryValue){
+		var currentValue = categoryValue == null ? getCurrentGrczlbmValue() : categoryValue.toString();
+		if (CURRENT_GRTF_CATEGORY_STATE.label && (!currentValue || CURRENT_GRTF_CATEGORY_STATE.value === currentValue)) {
+			return CURRENT_GRTF_CATEGORY_STATE.label;
+		}
+		return GRTF_LABEL_MAP[currentValue] || "";
+	}
+
+	// 功能说明:统一判断金额文本是否为合法负数,供提交校验和 ssVm 规则复用 by xu 20260323
+	function isValidNegativeAmountText(value){
+		var amountText = value == null ? "" : value.toString().trim();
+		if (!amountText) {
+			return false;
+		}
+		var amountNumber = Number(amountText);
+		return !isNaN(amountNumber) && amountNumber < 0;
+	}
+
+	// 功能说明:根据当前退费类别值读取隐藏字段值,供金额校验复用 by xu 20260323
+	function getCurrentGrczlbmValue(){
+		var vm = getFormAppVm();
+		if (vm && vm.grczlbm != null && vm.grczlbm !== "") {
+			return vm.grczlbm.toString();
+		}
+		var categoryElem = document.querySelector('[name="grczlbm"]');
+		return categoryElem && categoryElem.value != null ? categoryElem.value.toString() : "";
+	}
+
+	// 功能说明:统一校验个人退费金额必须为负数,不再区分具体退费类别 by xu 20260323
+	function validateNegativeAmountByCategory(showMsg){
+		var currentLabel = getCurrentGrczlbmLabel();
+		var jeElem = document.querySelector('[name="je"]');
+		if (!jeElem) {
+			return true;
+		}
+
+		if (isValidNegativeAmountText(jeElem.value)) {
+			return true;
+		}
+
+		if (showMsg !== false) {
+			alert((currentLabel || "退费") + "金额只能输入负数");
+			jeElem.focus();
+		}
+		return false;
+	}
+
+	// 功能说明:个人退费页提交前先走 ssVm 全量校验,确保显示 SsInp 左侧红线与底部提示 by xu 20260323
+	function submitGrtfForm(){
+/* 去掉,不是 个人退费页 了。Lin
+		if (window.ssVm && window.ssVm.validations && window.ssVm.validations.size > 0) {
+			var validateResult = window.ssVm.validateAll();
+			if (!validateResult.valid) {
+				return false;
+			}
+		} else if (!validateNegativeAmountByCategory(true)) {
+			return false;
+		}
+*/
+
+		var formElem = document.querySelector("form");
+		if (!formElem) {
+			alert("表单不存在");
+			return false;
+		}
+
+		formElem.action = "<ss:serv name='ka_lr_tj' dest='addSure' parm='{thisViewObject:\"ka\",dataType:\"update\"}'/>";
+		ss.display.resizeComponent(881,361,515,515);
+		formElem.submit();
+		return true;
+	}
+
+	if (window.SS && typeof SS.ready === "function") {
+		SS.ready(function(){
+			// 功能说明:页面初始化后同步当前退费类别状态,避免首次校验读取不到最新类别文案 by xu 20260323
+			CURRENT_GRTF_CATEGORY_STATE.value = getCurrentGrczlbmValue();
+			CURRENT_GRTF_CATEGORY_STATE.label = GRTF_LABEL_MAP[CURRENT_GRTF_CATEGORY_STATE.value] || "";
+			// 功能说明:页面初始化后注册个人退费金额负数校验规则,保证 SsInp 输入时直接出现红线提示 by xu 20260323
+			initNegativeAmountValidation();
+		});
+	}
+
+</script>

+ 438 - 0
page/ka_nfcAdd.jsp

@@ -0,0 +1,438 @@
+<%@ page language="java" pageEncoding="UTF-8" isELIgnored="false" %>
+<%@ taglib uri="/ssTag" prefix="ss"%>
+
+<% pageContext.setAttribute(ss.page.PageC.PAGE_objName,"ka");%>
+<%pageContext.setAttribute("wdpageinformation","{'hastab':'0'}");%>
+<!DOCTYPE html>
+<html>
+<head>
+<%@ include file="/page/clip/header.jsp" %>
+
+	<style>
+	  .table-container>tr>th{
+		  width:130px !important;
+	  }
+	  /* 把content-box的高度限制 从公共css 抽到具体有需要的页面 by xu 20251215 */
+	  .form-container .content-box {
+		  height: calc(100% - 80px) !important;
+		      padding-top: 40px !important;
+	  }
+	  td{
+		display: flex;
+		align-items: center;
+		justify-content: flex-start;
+		}
+	</style>
+
+</head>
+<body class="env-input-body">
+<script src="/js/reader/reader-client.js"></script>
+<div style="position:fixed;top:0px;left:0px;z-index:9999;padding:8px 24px;display:flex;align-items:center;gap:12px;">
+	<button id="btnConnect" class="primary" type="button" style="display:none;">连接读卡器</button>
+	<span id="readerStatusTag" style="font-size:14px;color:#64748b;">读卡器连接中...</span>
+</div>
+<input id="wsUrlText" type="hidden" value="ws://127.0.0.1:6689"/> <%-- WebSocket。Lin --%>
+<input id="pollingInterval" type="hidden" value="1000" min="100" step="100" /> <%-- 轮询间隔 (毫秒)。Lin --%>
+<input id="dedupeMode" type="hidden" value="session"/> <%-- 去重模式:本次会话去重。Lin --%>
+<%-- ISO14443A 参数。Lin --%>
+<input id="keyTypeInput" type="hidden" value="0"/> <%-- 密钥类型:Key A。1 = Key B。Lin --%>
+<input id="keyInput" type="hidden" value="FFFFFFFFFFFF" maxlength="12"/> <%-- 密钥。Lin --%>
+<input id="blockAddressInput" type="hidden" value="1" min="0" /> <%-- 块地址。Lin --%>
+<input id="numberOfBlocksReadInput" type="hidden" value="1" min="1"/> <%-- 读取块数。Lin --%>
+<%-- --%>
+<%-- ISO15693 参数。Lin --%>
+<input id="readDataTypeInput" type="hidden" value="0"/> <%-- 读取数据类型:block。1 = afi,2 = eas,3 = block and afi,4 = block and eas,5 = afi and eas,6 = block and afi and eas。Lin --%>
+<input id="readSecurityStatusInput" type="hidden" value="0"/> <%-- 读取安全位:不读取。1 = 读取。Lin --%>
+<input id="iso15693BlockAddressInput" type="hidden" value="0" min="0"/> <%-- 块地址。Lin --%>
+<input id="iso15693NumberOfBlocksInput" type="hidden" value="7" min="1"/> <%-- 读取块数。Lin --%>
+<%-- --%>
+<form method="post" id="app" class="form-container">
+<div class="content-box fit-height-content">
+
+	<div class="content-div" ssFith="true">
+		<table class='form'>
+
+			<tr>
+				<th>部门/亲属</th>
+				<td style="display: flex;align-items: center;">
+					
+<script>
+ss.dom.formElemConfig.bmid={val:null,type:window.ss.dom.TYPE.OBJP};
+</script>
+<ss-objp
+:opt="bmidOption"
+:inp="true"
+url="<ss:serv name='loadObjpOpt' parm='{"objectpickerdropdown":"1"}' />"
+cb="bm"
+v-model="bmid"
+name="bmid"
+:readonly="false"
+width="200px"
+></ss-objp>
+
+					
+<script>
+ss.dom.formElemConfig.rylbm={val:'1100',type:window.ss.dom.TYPE.ONOFFBTN};
+</script>
+
+					<ss-onoff
+v-model="rylbm"
+name="rylbm"
+label="职工亲属"
+value="1000"
+:multiple="true"
+:null="false"
+placeholder="职工亲属"
+v-model="rylbm"
+:readonly="false"
+></ss-onoff>
+
+				</td>
+			</tr>
+
+<%-- 先去掉,接入读卡器时再加。Lin
+			<tr>
+				<th>卡号</th>
+				<td >
+					<@input name="kah"/>
+				</td>
+			</tr>
+--%>
+
+			<tr>
+				<th>人员</th>
+				<td>
+					<input name="czryid" type="hidden" value='${sessionScope.ssUser.ryid}'/> <%-- 操作人员ID。Lin --%>
+
+					
+<script>
+ss.dom.formElemConfig.cyryid={val:null,type:window.ss.dom.TYPE.OBJP};
+</script>
+<ss-objp
+:opt="cyryidOption"
+:inp="true"
+url="<ss:serv name='loadObjpOpt' parm='{"objectpickerdropdown":"1","objectpickerfilterField":"bmid,rylbm"}' />"
+cb="ryByBmOrRylb"
+v-model="cyryid"
+name="cyryid"
+:readonly="false"
+filterField="bmid,rylbm"
+onChange="selBaseInfoByRyid"
+></ss-objp>
+
+				</td>
+			
+				<th>部门</th>
+				<td id='bm'></td>
+			</tr>
+			<tr>
+				<th>姓名</th>
+				<td id='xm'></td>
+			
+				<th>人员号</th>
+				<td id='ryh'></td>
+			</tr>
+			<tr>
+				<th>NFC</th>
+				<td>
+					<input id="nfch" name="kah"/>
+				</td>
+			</tr>
+		</table>
+	</div>
+
+
+	
+</div>
+<div class='bottom-div'>
+
+		<ss-bottom-button
+				id="saveAndCommit"
+				text="保存并提交"
+				onclick='submitGrtfForm();'<%-- 功能说明:个人退费页提交前先校验金额必须为负数 by xu 20260323 --%>
+				icon-class="bottom-div-save"
+		></ss-bottom-button>
+
+
+		<ss-bottom-button
+				text="关闭"
+				onclick='ss.display.closeDialog();'
+				icon-class="bottom-div-close"
+		></ss-bottom-button>
+
+	</div>
+<input name='wdComponentID' type='hidden' value='ka_nfcAdd'/></form>
+<script type="text/javascript">var wdRecordValue='${wdRecordValue}';</script>
+<script type="text/javascript" src="/ss/js/wdRecord.js"></script>
+<script type="text/javascript">(function(){wdRecord("ka_nfcAdd");})();</script>
+<script type="text/javascript" src="/ss/js/wdFitHeight.js"></script>
+<script type="text/javascript">initWdFitHeight(0)</script>
+<script type="text/javascript">initWdFitHeightFunction=function(){initWdFitHeight(0);};</script>
+<ss:equal val="${empty resizeComponent}" val2="false">
+<script>{var iframe=wd.display.getFrameOfWindow();
+if(iframe&&iframe.contentWindow==window)
+wd.display.resizeComponent(${resizeComponent.width}, ${resizeComponent.height}, ${empty resizeComponent.minHeight?'null':resizeComponent.minHeight}, ${empty resizeComponent.maxHeight?'null':resizeComponent.maxHeight});}</script>
+</ss:equal>
+<ss:help/>
+</body>
+<script type="text/javascript">
+try{wd.display.showMsgPopup('${msg}');
+}catch(err){console.error(err);}
+</script>
+<ss:equal val="${empty wdclosewindowparam}" val2="false">
+<script type="text/javascript">
+try{wd.display.setCloseWindowParam('${wdclosewindowparam}');
+}catch(err){console.error(err);}
+</script>
+</ss:equal>
+
+</html>
+<%@ include file="/page/clip/footer.jsp" %>
+
+<script>
+	function getFormAppVm(){
+		var appEl = document.getElementById("app");
+		if (!appEl || !appEl.__vue_app__ || !appEl.__vue_app__._instance) {
+			return null;
+		}
+		return appEl.__vue_app__._instance.proxy || null;
+	}
+
+	function normalizeRylbmValues(value){
+		if (Array.isArray(value)) {
+			return value.map(function(item){
+				return item == null ? "" : item.toString();
+			}).filter(Boolean);
+		}
+		if (value == null || value === "") {
+			return [];
+		}
+		var cleanValue = value.toString().replace(/^,+/, "");
+		if (!cleanValue) {
+			return [];
+		}
+		return cleanValue.split(/[,|]/).filter(Boolean);
+	}
+
+	function hasRelativeRylbmValue(value){
+		return normalizeRylbmValues(value).indexOf("1000") !== -1;
+	}
+
+	function clearRyDisplay(){
+		var bmbjEl = document.getElementById('bmbj');
+		var xmEl = document.getElementById('xm');
+		var ryhEl = document.getElementById('ryh');
+		if (bmbjEl) bmbjEl.innerHTML = "";
+		if (xmEl) xmEl.innerHTML = "";
+		if (ryhEl) ryhEl.innerHTML = "";
+	}
+
+	function clearRySelection(vm){
+		if (!vm) {
+			clearRyDisplay();
+			return;
+		}
+		vm.ryid = "";
+		clearRyDisplay();
+	}
+
+	function handleRylbmChange(groupValue){
+		if (!hasRelativeRylbmValue(groupValue)) {
+			return;
+		}
+		var vm = getFormAppVm();
+		if (!vm) {
+			clearRyDisplay();
+			return;
+		}
+		vm.bmid = "";
+		clearRySelection(vm);
+	}
+
+	function handleBjChange(value){
+		if (value == null || value === "") {
+			return;
+		}
+		var vm = getFormAppVm();
+		if (!vm) {
+			clearRyDisplay();
+			return;
+		}
+		if (hasRelativeRylbmValue(vm.rylbm)) {
+			vm.rylbm = "1100";
+		}
+		clearRySelection(vm);
+	}
+	
+	function selBaseInfoByRyid(value){
+		$.ajax({
+			url:"<ss:serv name='ry_selBaseInfoByRyid'/>",
+			type:"post",
+			data:{
+				ryid:value
+			},
+			dataType:"json",
+			success:function(data){
+				if (data.ssCode != 0) {
+					alert(data.ssMsg);
+					return;
+				}
+				var d = data.ssData;
+				document.getElementById('xm').innerHTML = d.xm;
+				document.getElementById('ryh').innerHTML = d.ryh;
+				if (d.rylbm != 1100) {	// 不是学员。Lin
+					if (d.bmmc)
+						document.getElementById('bmbj').innerHTML = d.bmmc;
+					else
+						document.getElementById('bmbj').innerHTML = "(无)";
+				} else {
+					if (d.bjmc)
+						document.getElementById('bmbj').innerHTML = d.bjmc;
+					else
+						document.getElementById('bmbj').innerHTML = "(无)";
+				}
+				document.getElementById('xfye').innerHTML = d.xfye;
+			}
+		});
+	}
+</script>
+<script type="text/javascript" src="/js/validate/validator-rules.js"></script><%-- 功能说明:个人退费页接入桌面端校验规则,支持 SsInp 红线提示 by xu 20260323 --%>
+<script type="text/javascript" src="/js/validate/validation-manager.js"></script><%-- 功能说明:个人退费页接入桌面端校验管理器,支持 SsInp 红线提示 by xu 20260323 --%>
+
+<script>
+	var GRTF_LABEL_MAP = window.GRTF_LABEL_MAP || {};
+
+	// 功能说明:记录当前退费类别的最新 value/label,避免 onoff 隐藏值切换时机导致金额校验判断失真 by xu 20260323
+	var CURRENT_GRTF_CATEGORY_STATE = {
+		value: "",
+		label: ""
+	};
+
+	// 功能说明:退费类别变化后延后一拍重跑金额校验,确保隐藏字段值更新后再清理红线状态 by xu 20260323
+	function handleGrczlbmChange(groupValue, value, label){
+		CURRENT_GRTF_CATEGORY_STATE.value = groupValue == null ? "" : groupValue.toString();
+		CURRENT_GRTF_CATEGORY_STATE.label = label == null ? "" : label.toString();
+		setTimeout(function(){
+			if (window.ssVm && typeof window.ssVm.validateField === "function") {
+				window.ssVm.validateField("je");
+				return;
+			}
+			validateNegativeAmountByCategory(false);
+		}, 0);
+	}
+
+	// 功能说明:初始化个人退费页金额负数校验规则,整页金额都必须输入负数 by xu 20260323
+	function initNegativeAmountValidation(){
+		if (!document.querySelector('[name="je"]') || !document.querySelector('[name="grczlbm"]')) {
+			return;
+		}
+		if (!window.ssVm || typeof window.ssVm.add !== "function" || window.ss.dom._grczGrtfNegativeAmountValidationInited) {
+			return;
+		}
+		window.ss.dom._grczGrtfNegativeAmountValidationInited = true;
+		window.ssVm.add("ss.commonValidator.custom", ["je"], {
+			msgPrfx: "金额",
+			relField: "grczlbm",
+			validate: function(value, categoryValue){
+				var currentLabel = getCurrentGrczlbmLabel(categoryValue);
+				if (value == null || value.toString().trim() === "") {
+					return true;
+				}
+				if (isValidNegativeAmountText(value)) {
+					return true;
+				}
+				return {
+					valid: false,
+					message: (currentLabel || "退费") + "金额只能输入负数"
+				};
+			}
+		}, {
+			je: window.ss.dom.formElemConfig.je ? window.ss.dom.formElemConfig.je.val : ""
+		});
+	}
+
+	// 功能说明:根据当前退费类别值读取显示文案,供联动校验和错误提示复用 by xu 20260323
+	function getCurrentGrczlbmLabel(categoryValue){
+		var currentValue = categoryValue == null ? getCurrentGrczlbmValue() : categoryValue.toString();
+		if (CURRENT_GRTF_CATEGORY_STATE.label && (!currentValue || CURRENT_GRTF_CATEGORY_STATE.value === currentValue)) {
+			return CURRENT_GRTF_CATEGORY_STATE.label;
+		}
+		return GRTF_LABEL_MAP[currentValue] || "";
+	}
+
+	// 功能说明:统一判断金额文本是否为合法负数,供提交校验和 ssVm 规则复用 by xu 20260323
+	function isValidNegativeAmountText(value){
+		var amountText = value == null ? "" : value.toString().trim();
+		if (!amountText) {
+			return false;
+		}
+		var amountNumber = Number(amountText);
+		return !isNaN(amountNumber) && amountNumber < 0;
+	}
+
+	// 功能说明:根据当前退费类别值读取隐藏字段值,供金额校验复用 by xu 20260323
+	function getCurrentGrczlbmValue(){
+		var vm = getFormAppVm();
+		if (vm && vm.grczlbm != null && vm.grczlbm !== "") {
+			return vm.grczlbm.toString();
+		}
+		var categoryElem = document.querySelector('[name="grczlbm"]');
+		return categoryElem && categoryElem.value != null ? categoryElem.value.toString() : "";
+	}
+
+	// 功能说明:统一校验个人退费金额必须为负数,不再区分具体退费类别 by xu 20260323
+	function validateNegativeAmountByCategory(showMsg){
+		var currentLabel = getCurrentGrczlbmLabel();
+		var jeElem = document.querySelector('[name="je"]');
+		if (!jeElem) {
+			return true;
+		}
+
+		if (isValidNegativeAmountText(jeElem.value)) {
+			return true;
+		}
+
+		if (showMsg !== false) {
+			alert((currentLabel || "退费") + "金额只能输入负数");
+			jeElem.focus();
+		}
+		return false;
+	}
+
+	// 功能说明:个人退费页提交前先走 ssVm 全量校验,确保显示 SsInp 左侧红线与底部提示 by xu 20260323
+	function submitGrtfForm(){
+/* 去掉,不是 个人退费页 了。Lin
+		if (window.ssVm && window.ssVm.validations && window.ssVm.validations.size > 0) {
+			var validateResult = window.ssVm.validateAll();
+			if (!validateResult.valid) {
+				return false;
+			}
+		} else if (!validateNegativeAmountByCategory(true)) {
+			return false;
+		}
+*/
+
+		var formElem = document.querySelector("form");
+		if (!formElem) {
+			alert("表单不存在");
+			return false;
+		}
+
+		formElem.action = "<ss:serv name='ka_lr_tj' dest='addSure' parm='{thisViewObject:\"ka\",dataType:\"update\"}'/>";
+		ss.display.resizeComponent(881,361,515,515);
+		formElem.submit();
+		return true;
+	}
+
+	if (window.SS && typeof SS.ready === "function") {
+		SS.ready(function(){
+			// 功能说明:页面初始化后同步当前退费类别状态,避免首次校验读取不到最新类别文案 by xu 20260323
+			CURRENT_GRTF_CATEGORY_STATE.value = getCurrentGrczlbmValue();
+			CURRENT_GRTF_CATEGORY_STATE.label = GRTF_LABEL_MAP[CURRENT_GRTF_CATEGORY_STATE.value] || "";
+			// 功能说明:页面初始化后注册个人退费金额负数校验规则,保证 SsInp 输入时直接出现红线提示 by xu 20260323
+			initNegativeAmountValidation();
+		});
+	}
+
+</script>
+<script type="text/javascript" src="/js/reader/ka-nfc-page.js"></script>

+ 0 - 130
page/xyZjz_excelAdd copy.jsp

@@ -1,130 +0,0 @@
-<!DOCTYPE html>
-<%@ page language="java" pageEncoding="UTF-8" isELIgnored="false" %>
-<%@ taglib uri="/ssTag" prefix="ss"%>
-
-<% pageContext.setAttribute(ss.page.PageC.PAGE_objName,"xy");%>
-<%pageContext.setAttribute("wdpageinformation","{'hastab':'0'}");%>
-<!DOCTYPE html>
-<html>
-<head>
-<meta http-equiv="pragma" content="no-cache">
-<meta http-equiv="cache-control" content="no-cache">
-<meta http-equiv="expires" content="0">
-<script>window.loginStatus="${empty sessionScope['ssUser']?'0':'1'}"</script>
-<ss:skin file='main.css'/>
-<script type="text/javascript" src="/ss/jquery/jquery.js"></script>
-<script type="text/javascript" src="/ss/artdialog/artDialogUtil.js"></script>
-<script type="text/javascript" src="/ss/js/base.js"></script>
-<script> if(!window.wd) var wd={}; if(!wd.display) wd.display={}; wd.display.wdDialogId="xyZjz_excelAdd"; if(!wd.app) wd.app={};  wd.app.name='pms';</script>
-<script type="text/javascript" src="/ss/js/masklayer.js"></script>
-<script type="text/javascript" src="/ss/js/wdDialogInit.js"></script>
-<script type="text/javascript" src="/ss/js/common.js"></script>
-<script type="text/javascript" src="/ss/js/display.js"></script>
-<script type="text/javascript" src="/ss/js/edit.js"></script>
-<script type="text/javascript" src="/ss/nicescroll/jquery.nicescroll.js"></script>
-<script type="text/javascript" src="/ss/nicescroll/jquery.nicescroll.iframehelper.min.js"></script>
-<link rel="stylesheet" type="text/css" href="/ss/window/theme/dhtmlxwindows.css">
-<link rel="stylesheet" type="text/css" href="/ss/window/theme/dhx_blue/dhtmlxwindows_dhx_blue.css">
-<script type="text/javascript" src="/ss/window/dhtmlxcommon.js"></script>
-<script type="text/javascript" src="/ss/window/dhtmlxwindows.js"></script>
-<script type="text/javascript" src="/ss/window/dhtmlxcontainer.js"></script>
-<script type="text/javascript" src="/ss/js/common.js"></script>
-<script type="text/javascript" src="/ss/js/display.js"></script>
-<%-- 改。Lin
-<@script type="text/javascript" src="/${sessionScope['XMMC']}/js/yx/yx_zjz.js"></script> --%>
-<script type="text/javascript" src="/ss/yx/yx_zjz.js"></script>
-<%-- 改,去掉 /wd/js/ueditor/dialogs/wdimage/upload.js,改用 /wd/js/upload.js。Lin
-<script type="text/javascript" src="/wd/js/ueditor/dialogs/wdimage/upload.js"></script>
---%><script type="text/javascript" src="/ss/js/upload.js"></script>
-<style type="text/css">
-	#sfzImg{
-		margin: 0 auto;
-		width: 381px;
-		height: 491.6px !important;
-		border:1px solid #999;
-	}
-/*	#video{width:100% !important;height:100% !important}*/
-	#video{width:800px!important;height:719px!important}
-</style>
-</head>
-<body>
-<div class="content-div" ssFith="true">
-	<table class='form' style="table-layout: fixed;">
-		<tr>
-			<th width="130px">姓名</th>
-<%-- 改,暂时指定为 bjcyList[0] -- 等小许。Lin
-			<td>${ry.xm}</td> --%>
-			<td>${bjcyList[0].xm}</td>
-			<th width="130px">身份证号码</th>
-			<td >${ry.sfzh}</td>
-		</tr>
-		<tr>
-			<th width="130px">班级</th>
-			<td colspan="3">
-				<%--<wd:translate name="bmxy_bjcx" value="${bmxy.bjid}" filter="bmxyid:'${bmxy.bmxyid}'" />--%>
-				<ss:cbTrans cb='bj' val='${bjid}'/>
-			</td>
-		</tr>
-	</table>
-<%-- 改,去掉部件 yx_info,改用 info -- 不是摊位,没有摊位的 "下一步" 操作。Lin
-	<@form action="@service{name:'yx_zjz_bc',dest:'yx_info'}@"  method="post"> --%>
-	<form action="<ss:serv name='xyZjz_excelSureAdd' parm='{"wdConfirmationCaptchaService":"0"}' dest='info'/>" method="post">
-<%-- 改,暂时指定为 bjcyList[0] -- 等小许。Lin
-		<@input name="ryid" value="$@{ry.ryid}" type="hidden"/>
-		<input name="zjzwj" value="${ry.zjzwj}" type="hidden">
---%>
-		<input name="ryid" value="${bjcyList[0].ryid}" type="hidden"/>
-		<input name="zjzwj" value="${bjcyList[0].zjzwj}" type="hidden">
-
-		<label  style="display:none;" for="videoSource">视频来源: </label>
-		<select style="display:none;" id="videoSource"></select>
-		<div style="width:100%;display: flex;height: 413px;">
-			<div id="sfzImg" class="photo">
-				<input type="file" style="display:none;" id="file">
-				<img style="max-height: 100%;max-width: 100%; width:100%;height:100%;"  id="image"
-<%-- 改,暂时指定为 bjcyList[0] -- 等小许。Lin
-						src="@service{name:'getData',param:{path:'${ry.zjzwj}'}}@"
---%>				src="<ss:serv name='dlByHttp' parm='{"wdConfirmationCaptchaService":"0","path":"${bjcyList[0].zpwj}","type":"img"}'/>"
-						onerror="this.src='/image/files.png'"/> <%-- ='${sessionScope['XMMC']}/image/files.png'"。Lin --%>
-				<video autoplay muted playsinline style="display:none" id="video"></video>
-				<canvas id="canvas" style="display:none"></canvas>
-			</div>
-		</div>
-		<div style="text-align: center; margin-top: 88px;margin-bottom: 46px;">
-			<input class="content-button" type="button" value="拍照" id="btn">
-		</div>
-		<div class='bottom-div'>
-			<div class="bottom-down-div border-top">
-				<input class="bottom-button" type="submit" value="提交" >
-				<input class="bottom-button" type="button" onclick="wd.display.closeDialog();" value="取消">
-			</div>
-		</div>
-	<input name='wdComponentID' type='hidden' value='xyZjz_excelAdd'/></form>
-</div>
-<script type="text/javascript">var wdRecordValue='${wdRecordValue}';</script>
-<script type="text/javascript" src="/ss/js/wdRecord.js"></script>
-<script type="text/javascript">(function(){wdRecord("xyZjz_excelAdd");})();</script>
-<script type="text/javascript" src="/ss/js/wdFitHeight.js"></script>
-<script type="text/javascript">initWdFitHeight(0)</script>
-<script type="text/javascript">initWdFitHeightFunction=function(){initWdFitHeight(0);};</script>
-<ss:equal val="${empty resizeComponent}" val2="false">
-<script>{var iframe=wd.display.getFrameOfWindow();
-if(iframe&&iframe.contentWindow==window)
-wd.display.resizeComponent(${resizeComponent.width}, ${resizeComponent.height}, ${empty resizeComponent.minHeight?'null':resizeComponent.minHeight}, ${empty resizeComponent.maxHeight?'null':resizeComponent.maxHeight});}</script>
-</ss:equal>
-<ss:help/>
-</body>
-<script type="text/javascript">
-try{wd.display.showMsgPopup('${msg}');
-}catch(err){console.error(err);}
-</script>
-<ss:equal val="${empty wdclosewindowparam}" val2="false">
-<script type="text/javascript">
-try{wd.display.setCloseWindowParam('${wdclosewindowparam}');
-}catch(err){console.error(err);}
-</script>
-</ss:equal>
-</html>
-<script type="text/javascript">
-tokenCleanser("<ss:serv name='ss.clearPageToken'/>", {tokenList:"<%= pageContext.getAttribute(ss.page.PageC.PAGE_tokenList)%>"});
-</script>

+ 297 - 68
page/xyZjz_excelAdd.jsp

@@ -1,4 +1,4 @@
-<%@ page import="java.util.List" %>
+<%@ page import="java.util.List,java.util.Map" %>
 <%@ page language="java" pageEncoding="UTF-8" isELIgnored="false" %>
 <%@ taglib uri="/ssTag" prefix="ss"%>
 
@@ -22,26 +22,44 @@
 <script type="text/javascript" src="/wd/js/ueditor/dialogs/wdimage/upload.js"></script>
 --%><script type="text/javascript" src="/ss/js/upload.js"></script>
 <style type="text/css">
-	body{
+	html,
+	body,
+	body.env-input-body{
+		/* 功能说明:锁定页面基础高度链,避免右侧长列表因父级高度不确定而外溢 by xu 20260410 */
+		height: 100%;
+		margin: 0;
+		overflow: hidden;
 		background: #f5f7fb;
 	}
 	
 	#app.form-container{
+		/* 功能说明:锁定表单容器高度,给右侧列表滚动提供确定高度基准 by xu 20260410 */
 		height: 100%;
+		min-height: 0;
+		overflow: hidden;
 	}
 	.form-container .content-box{
+		/* 功能说明:锁定内容容器高度,避免内部 flex/grid 按内容撑开 by xu 20260410 */
 		height: 100% !important;
+		min-height: 0;
 		padding: 0 !important;
+		overflow: hidden;
 	}
 	.xy-zjz-page{
+		/* 功能说明:锁定页面主区域高度,避免学生列表卡片继续向外撑出 by xu 20260410 */
 		height: 100%;
+		min-height: 0;
 		padding: 0;
 		box-sizing: border-box;
+		overflow: hidden;
 	}
 	.xy-zjz-layout{
+		/* 功能说明:锁定左右布局高度,确保右侧列表只能在剩余空间内滚动 by xu 20260410 */
 		height: 100%;
+		min-height: 0;
 		display: flex;
 		gap: 0;
+		overflow: hidden;
 	}
 	.xy-zjz-left{
 		flex: 0 0 61%;
@@ -56,16 +74,21 @@
 		border-right: 1px solid #e2e4ec;
 	}
 	.xy-zjz-right{
+		/* 功能说明:右侧改为固定信息区 + 分隔线 + 自适应列表区,彻底避免长名单撑出 by xu 20260410 */
 		flex: 0 0 39%;
 		min-width: 320px;
-		display: flex;
-		flex-direction: column;
+		min-height: 0;
+		height: 100%;
+		display: grid;
+		grid-template-rows: 234px 1px minmax(0, 1fr);
+		overflow: hidden;
 		background: #f7f7f7;
 	}
 	.xy-zjz-info-wrapper{
 		height: 234px;
 		padding: 30px 35px 35px;
 		box-sizing: border-box;
+		overflow: hidden;
 	}
 	.xy-zjz-camera-toolbar{
 		display: none;
@@ -91,14 +114,51 @@
 	#sfzImg.xy-zjz-preview.is-live{
 		border: 1px solid #3f3f3f;
 	}
-	#sfzImg.xy-zjz-preview img,
-	#sfzImg.xy-zjz-preview video{
+	/* 功能说明:照片回显层独立控制显示状态,避免误伤蒙版层 by xu 20260410 */
+	#sfzImg.xy-zjz-preview #image{
+		position: absolute;
+		inset: 0;
+		width: 100% !important;
+		height: 100% !important;
+		object-fit: cover;
+		display: none;
+		z-index: 1;
+	}
+	/* 功能说明:视频预览层独立控制显示状态,避免误伤蒙版层 by xu 20260410 */
+	#sfzImg.xy-zjz-preview #video{
 		position: absolute;
 		inset: 0;
 		width: 100% !important;
 		height: 100% !important;
 		object-fit: cover;
 		display: none;
+		z-index: 1;
+	}
+	/* 功能说明:给视频预览叠加独立人脸定位蒙版,始终显示在最上层 by xu 20260410 */
+	#sfzImg.xy-zjz-preview .xy-zjz-face-mask{
+		/* 功能说明:蒙版直接铺满整个预览框,消除上下留白 by xu 20260410 */
+		position: absolute;
+		inset: 0;
+		width: 100%;
+		height: 100%;
+		object-fit: fill;
+		pointer-events: none;
+		z-index: 4;
+		display: none;
+		opacity: 0.5;
+	}
+	/* 功能说明:未连接状态下占位层放在蒙版下方,仅保留头像引导层级 by xu 20260410 */
+	#sfzImg.xy-zjz-preview .xy-zjz-camera-placeholder{
+		z-index: 2;
+		background: transparent;
+	}
+	/* 功能说明:引导头像位于蒙版下方、背景上方,符合校准视觉层级 by xu 20260410 */
+	#sfzImg.xy-zjz-preview .xy-zjz-camera-placeholder::before{
+		z-index: 3;
+	}
+	/* 功能说明:提示文案层级低于蒙版,避免影响人脸框观察 by xu 20260410 */
+	#sfzImg.xy-zjz-preview .xy-zjz-camera-placeholder{
+		color: rgba(255,255,255,0.72);
 	}
 	#canvas,
 	#file{
@@ -252,12 +312,15 @@
 		flex-shrink: 0;
 	}
 	.xy-zjz-list-card{
-		flex: 1;
+		/* 功能说明:学生列表卡片占满右侧剩余高度,超长时仅列表内部滚动 by xu 20260410 */
+		height: 100%;
 		padding: 16px 14px 12px;
 		display: flex;
 		flex-direction: column;
 		min-height: 0;
-		max-height: none;
+		max-height: 100%;
+		overflow: hidden;
+		box-sizing: border-box;
 		background: #f7f7f7;
 	}
 	.xy-zjz-filter-row{
@@ -381,6 +444,29 @@
 	pageContext.setAttribute("bjcyCount", Integer.valueOf(bjcyCount));
 	pageContext.setAttribute("firstStudent", firstStudent);
 %>
+	<script>
+		// 功能说明:将 JSP 侧真实班级成员数据输出给 Vue 使用,替换前端 mock 数据 by xu 20260410
+		window.xyZjzRealStudentList = [
+		<%
+			for (int i = 0; i < bjcyCount; i++) {
+				Object rowObj = bjcyList.get(i);
+				if (!(rowObj instanceof Map)) {
+					continue;
+				}
+				Map row = (Map) rowObj;
+				String ryid = row.get("ryid") == null ? "" : row.get("ryid").toString().replace("\\", "\\\\").replace("'", "\\'");
+				String xm = row.get("xm") == null ? "" : row.get("xm").toString().replace("\\", "\\\\").replace("'", "\\'");
+				String ryh = row.get("ryh") == null ? "" : row.get("ryh").toString().replace("\\", "\\\\").replace("'", "\\'");
+				String zjzwj = row.get("zjzwj") == null ? "" : row.get("zjzwj").toString().replace("\\", "\\\\").replace("'", "\\'");
+		%>
+			{ ryid: '<%= ryid %>', xm: '<%= xm %>', ryh: '<%= ryh %>', zjzwj: '<%= zjzwj %>' }<%= i < bjcyCount - 1 ? "," : "" %>
+		<%
+			}
+		%>
+		];
+		// 功能说明:同步输出班级名称给 Vue 显示 by xu 20260410
+		window.xyZjzRealBjmc = "<ss:cbTrans cb='bj' val='${bjid}'/>";
+	</script>
 	<input name="ryid" value="${firstStudent.ryid}" type="hidden"/>
 	<input name="zjzwj" value="${firstStudent.zjzwj}" type="hidden">
 	<input name='wdComponentID' type='hidden' value='xyZjz_excelAdd'/>
@@ -392,9 +478,13 @@
 			<div class="xy-zjz-preview-shell">
 				<div id="sfzImg" class="photo xy-zjz-preview">
 					<div class="xy-zjz-camera-placeholder" id="cameraPlaceholder">点击“连接摄像头”后由浏览器弹出设备/权限选择</div>
+					<%-- 功能说明:叠加人脸蒙版图,辅助拍摄时对齐位置 by xu 20260410 --%>
+					<img class="xy-zjz-face-mask" src="/skin/easy/image/xy-zjz-face-mask.png" alt="人脸定位蒙版"/>
+					<%-- 功能说明:修复图片标签未闭合导致 video/canvas DOM 丢失,摄像头初始化取不到元素 by xu 20260410 --%>
 					<img id="image"
-						src="<ss:serv name='dlByHttp' parm='{"wdConfirmationCaptchaService":"0","path":"${firstStudent.zpwj}","type":"img"}'/>"
+						src="<ss:serv name='dlByHttp' parm='{"wdConfirmationCaptchaService":"0","path":"${firstStudent.zjzwj}","type":"img"}'/>"
 						onerror="this.src='data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'100\' height=\'100\'%3E%3Crect width=\'100\' height=\'100\' fill=\'%23f0f0f0\'/%3E%3Ctext x=\'50\' y=\'55\' font-size=\'12\' fill=\'%23999\' text-anchor=\'middle\'%3E无照片%3C/text%3E%3C/svg%3E'"
+					/>
 					<video autoplay muted playsinline id="video"></video>
 					<canvas id="canvas"></canvas>
 				</div>
@@ -405,7 +495,7 @@
 					<ss-bottom-button
 						id="photoConnectBtn"
 						text="连接摄像头"
-						@click='xyZjzCameraPage.connectCamera()'
+						@click='handleConnectCamera()'
 						icon-class="bottom-div-save"
 					></ss-bottom-button>
 				</div>
@@ -413,7 +503,7 @@
 					<ss-bottom-button
 						id="photoCaptureBtn"
 						text="拍照"
-						@click='xyZjzCameraPage.capturePhoto()'
+						@click='handleCapturePhoto()'
 						icon-class="bottom-div-save"
 					></ss-bottom-button>
 				</div>
@@ -421,13 +511,13 @@
 					<ss-bottom-button
 						id="photoRetakeBtn"
 						text="重拍"
-						@click='xyZjzCameraPage.retakePhoto()'
-						icon-class="bottom-div-close"
+						@click='handleRetakePhoto()'
+						icon-class="bottom-div-save"
 					></ss-bottom-button>
 					<ss-bottom-button
 						id="photoSubmitBtn"
 						text="保存并提交"
-						@click='xyZjzCameraPage.submitPhotoForm()'
+						@click='handleSubmitPhotoForm()'
 						icon-class="bottom-div-save"
 					></ss-bottom-button>
 				</div>
@@ -440,7 +530,7 @@
 						<div class="xy-zjz-info-name">{{currentStudent.xm}}</div>
 						<div class="xy-zjz-info-body">
 							<div class="xy-zjz-info-avatar">
-								<img :src="currentStudent.zpwj ? '/service?ssServ=dlByHttp&type=img&path=' + currentStudent.zpwj : 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'68\' height=\'100\'%3E%3Crect width=\'68\' height=\'100\' fill=\'%23f2f3f4\'/%3E%3Ctext x=\'34\' y=\'55\' font-size=\'11\' fill=\'%23999\' text-anchor=\'middle\'%3E无照片%3C/text%3E%3C/svg%3E'"/>
+								<img :src="currentStudent.zjzwj ? '/service?ssServ=dlByHttp&type=img&path=' + currentStudent.zjzwj : 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'68\' height=\'100\'%3E%3Crect width=\'68\' height=\'100\' fill=\'%23f2f3f4\'/%3E%3Ctext x=\'34\' y=\'55\' font-size=\'11\' fill=\'%23999\' text-anchor=\'middle\'%3E无照片%3C/text%3E%3C/svg%3E'"/>
 							</div>
 							<div class="xy-zjz-info-lines">
 								<div v-if="currentStudent.xbmc" class="xy-zjz-info-line"><strong>性别:</strong>{{currentStudent.xbmc}}</div>
@@ -456,7 +546,7 @@
 				</div>
 			</div>
 			<div class="xy-zjz-right-divider"></div>
-			<div class="xy-zjz-list-card">
+			<div class="xy-zjz-list-card" id="studentListCard">
 				<div class="xy-zjz-filter-row">
 					<select class="xy-zjz-filter-select">
 						<option>年级</option>
@@ -468,13 +558,13 @@
 					<span class="xy-zjz-filter-progress">{{currentIndex + 1}}/{{bjcyCount}}</span>
 					<input class="xy-zjz-filter-input" type="text" placeholder="姓名"/>
 				</div>
-				<div class="xy-zjz-student-list">
+				<div class="xy-zjz-student-list" id="studentListBody">
 				<div v-for="(item, index) in studentList" :key="index" 
 					 :class="['xy-zjz-student-row', currentIndex === index ? 'is-active' : '']"
 					 @click="selectStudent(index, item)">
 					<div class="xy-zjz-student-index">{{index + 1}}</div>
 					<div class="xy-zjz-student-name">{{item.xm}}</div>
-					<div class="xy-zjz-student-id">{{item.sfzh}}</div>
+					<div class="xy-zjz-student-id">{{item.ryh}}</div>
 				</div>
 			</div>
 			</div>
@@ -493,7 +583,9 @@
 	var state = {
 		stream: null,
 		mode: "disconnected",
-		enumerated: false
+		enumerated: false,
+		capturedBlob: null,
+		capturedDataUrl: ""
 	};
 	var elements = {};
 
@@ -509,10 +601,11 @@
 		elements.file = $("file");
 		elements.videoSource = $("videoSource");
 		elements.placeholder = $("cameraPlaceholder");
+		elements.faceMask = document.querySelector("#sfzImg .xy-zjz-face-mask");
 		elements.connectActions = $("cameraConnectActions");
 		elements.captureActions = $("cameraCaptureActions");
 		elements.reviewActions = $("cameraReviewActions");
-		return !!(elements.preview && elements.video && elements.image && elements.canvas && elements.file && elements.videoSource && elements.placeholder && elements.connectActions && elements.captureActions && elements.reviewActions);
+		return !!(elements.preview && elements.video && elements.image && elements.canvas && elements.file && elements.videoSource && elements.placeholder && elements.faceMask && elements.connectActions && elements.captureActions && elements.reviewActions);
 	}
 
 	function stopTracks(stream){
@@ -643,13 +736,17 @@
 			return;
 		}
 		state.mode = mode;
-		elements.connectActions.style.display = mode === "disconnected" ? "flex" : "none";
+		// 功能说明:连接按钮仅在首次未建立预览前显示,后续重拍不再回退到首次连接步骤 by xu 20260410
+		var shouldShowConnect = mode === "disconnected" && !state.enumerated;
+		elements.connectActions.style.display = shouldShowConnect ? "flex" : "none";
 		elements.captureActions.style.display = mode === "live" ? "flex" : "none";
 		elements.reviewActions.style.display = mode === "captured" ? "flex" : "none";
 		if (mode === "live") {
 			elements.preview.classList.add("is-live");
+			elements.faceMask.style.display = "block";
 		} else {
 			elements.preview.classList.remove("is-live");
+			elements.faceMask.style.display = "none";
 		}
 		if (mode === "disconnected") {
 			clearStream();
@@ -685,6 +782,7 @@
 		return connected;
 	}
 
+	// 功能说明:拍照后仅截取当前画面做本地预览,不立即上传,等待保存并提交时再上传 by xu 20260410
 	function capturePhoto(){
 		if (!ensureElements()) {
 			return;
@@ -700,8 +798,51 @@
 		var context = elements.canvas.getContext("2d");
 		context.clearRect(0, 0, width, height);
 		context.drawImage(elements.video, 0, 0, width, height);
-		var imgdataBase64 = elements.canvas.toDataURL("image/png");
-		initCropper1(elements.file, settings, cropConfirm, convertBase64UrlToBlob(imgdataBase64));
+		var imgdataBase64 = elements.canvas.toDataURL("image/jpeg", 0.92);
+		state.capturedBlob = convertBase64UrlToBlob(imgdataBase64);
+		state.capturedDataUrl = imgdataBase64;
+		clearStream();
+		elements.image.src = imgdataBase64;
+		elements.image.style.display = "block";
+		elements.video.style.display = "none";
+		hidePlaceholder();
+		document.querySelector("input[name='zjzwj']").value = "";
+		setActionMode("captured");
+	}
+
+	// 功能说明:将拍照截图直接上传到图片服务,返回文件路径 by xu 20260410
+	function uploadCapturedPhoto(blob){
+		return new Promise(function(resolve, reject){
+			var fileName = new Date().getTime() + ".jpg";
+			var formData = new FormData();
+			formData.append("application", "");
+			formData.append("fileEdit", new File([blob], fileName, { type: "image/jpeg" }));
+			$.ajax({
+				url: settings.action,
+				type: "POST",
+				data: formData,
+				processData: false,
+				contentType: false,
+				success: function(result){
+					try {
+						if (typeof result === "string") {
+							result = eval("(" + result + ")");
+						}
+						var path = result && result.fileList && result.fileList[0] && result.fileList[0].path;
+						if (path) {
+							resolve(path);
+						} else {
+							reject(new Error("上传返回路径为空"));
+						}
+					} catch (error) {
+						reject(error);
+					}
+				},
+				error: function(xhr){
+					reject(xhr);
+				}
+			});
+		});
 	}
 
 	function cropConfirm(result){
@@ -719,21 +860,45 @@
 		setActionMode("captured");
 	}
 
-	function retakePhoto(){
+	// 功能说明:重拍时直接回到预览态,不再回到首次连接步骤 by xu 20260410
+	async function retakePhoto(){
 		if (!ensureElements()) {
 			return;
 		}
+		state.capturedBlob = null;
+		state.capturedDataUrl = "";
+		document.querySelector("input[name='zjzwj']").value = "";
 		elements.image.style.display = "none";
-		connectCamera();
+		var connected = await startPreview(elements.videoSource.value);
+		if (connected) {
+			setActionMode("live");
+		}
 	}
 
-	function submitPhotoForm(){
+	// 功能说明:保存并提交时若存在本地截图则先上传,再回填路径并提交表单 by xu 20260410
+	async function submitPhotoForm(){
 		var formEl = document.getElementById("app") || document.getElementById("xyZjzPhotoForm");
 		if (!formEl) {
 			alert("表单不存在");
 			return false;
 		}
-		if (!document.querySelector("input[name='zjzwj']").value) {
+		var zjzwjInput = document.querySelector("input[name='zjzwj']");
+		if (!zjzwjInput) {
+			alert("缺少照片字段");
+			return false;
+		}
+		if (!zjzwjInput.value && state.capturedBlob) {
+			try {
+				var uploadedPath = await uploadCapturedPhoto(state.capturedBlob);
+				zjzwjInput.value = uploadedPath;
+				state.capturedBlob = null;
+			} catch (error) {
+				console.error(error);
+				alert("拍照上传失败,请重试");
+				return false;
+			}
+		}
+		if (!zjzwjInput.value) {
 			alert("请先拍照");
 			return false;
 		}
@@ -741,6 +906,30 @@
 		return true;
 	}
 
+	// 功能说明:支持 Enter 快捷键,未连接时连摄像头、预览时截图、已截图时直接保存并提交 by xu 20260410
+	function handleEnterShortcut(event){
+		var target = event.target || {};
+		var tagName = (target.tagName || "").toLowerCase();
+		if (tagName === "input" || tagName === "textarea" || tagName === "select" || target.isContentEditable) {
+			return;
+		}
+		if (event.key !== "Enter") {
+			return;
+		}
+		event.preventDefault();
+		if (state.mode === "disconnected") {
+			connectCamera();
+			return;
+		}
+		if (state.mode === "live") {
+			capturePhoto();
+			return;
+		}
+		if (state.mode === "captured") {
+			submitPhotoForm();
+		}
+	}
+
 	function convertBase64UrlToBlob(urlData) {
 		var bytes = window.atob(urlData.split(",")[1]);
 		var ab = new ArrayBuffer(bytes.length);
@@ -760,13 +949,50 @@
 		submitPhotoForm: submitPhotoForm
 	};
 
+
 	function initPage(){
 		if (!ensureElements()) {
 			return;
 		}
 		setActionMode("disconnected");
+		// 功能说明:初始化时绑定 Enter 快捷键,仅绑定一次 by xu 20260410
+		if (!window.__xyZjzEnterShortcutBound) {
+			document.addEventListener("keydown", handleEnterShortcut);
+			window.__xyZjzEnterShortcutBound = true;
+		}
+		syncStudentListHeight();
+	}
+
+	// 功能说明:按右侧真实可用高度动态限制学生列表区域,避免长名单把卡片继续撑出 by xu 20260410
+	function syncStudentListHeight(){
+		var rightPanel = document.querySelector(".xy-zjz-right");
+		var listCard = document.getElementById("studentListCard");
+		var filterRow = document.querySelector(".xy-zjz-filter-row");
+		var studentList = document.getElementById("studentListBody");
+		if (!rightPanel || !listCard || !studentList) {
+			return;
+		}
+		var rightRect = rightPanel.getBoundingClientRect();
+		var listCardRect = listCard.getBoundingClientRect();
+		var filterRect = filterRow ? filterRow.getBoundingClientRect() : {height: 0};
+		var listCardStyle = window.getComputedStyle(listCard);
+		var extraHeight = parseFloat(listCardStyle.paddingTop || 0)
+			+ parseFloat(listCardStyle.paddingBottom || 0)
+			+ filterRect.height
+			+ 12;
+		var availableHeight = Math.floor(rightRect.bottom - listCardRect.top);
+		if (availableHeight > 0) {
+			listCard.style.height = availableHeight + "px";
+			listCard.style.maxHeight = availableHeight + "px";
+			studentList.style.maxHeight = Math.max(80, Math.floor(availableHeight - extraHeight)) + "px";
+		}
 	}
 
+	window.addEventListener("resize", syncStudentListHeight);
+	window.addEventListener("load", syncStudentListHeight);
+	setTimeout(syncStudentListHeight, 0);
+	setTimeout(syncStudentListHeight, 300);
+
 	if (document.readyState === "loading") {
 		document.addEventListener("DOMContentLoaded", initPage);
 	} else {
@@ -828,80 +1054,83 @@ try{wd.display.setCloseWindowParam('${wdclosewindowparam}');
 
 </script>
 <script>
-	// 写死测试数据,先调UI
-	// 策略:劫持 SS.dom.initializeFormApp,在创建 Vue 时自动注入数据
+	// 功能说明:Vue 初始化改为读取 JSP 注入的真实数据,移除前端 mock 数据 by xu 20260410
 	(function() {
-		const studentList = [
-			{xm: '张三', xbmc: '男', jyfsmc: '走读', ryh: '2024001', zpwj: '', zjzwj: '', ryid: '1001', sfzh: '110101200001011234'},
-			{xm: '李四', xbmc: '女', jyfsmc: '寄宿', ryh: '2024002', zpwj: '', zjzwj: '', ryid: '1002', sfzh: '110101200002021245'},
-			{xm: '王五', xbmc: '男', jyfsmc: '走读', ryh: '2024003', zpwj: '', zjzwj: '', ryid: '1003', sfzh: '110101200003031256'},
-			{xm: '赵六', xbmc: '女', jyfsmc: '寄宿', ryh: '2024004', zpwj: '', zjzwj: '', ryid: '1004', sfzh: '110101200004041267'},
-			{xm: '钱七', xbmc: '男', jyfsmc: '走读', ryh: '2024005', zpwj: '', zjzwj: '', ryid: '1005', sfzh: '110101200005051278'},
-		];
-		
-		const firstStudent = studentList[0];
-		
-		// 等待 SS.ready 后劫持 initializeFormApp
+		var studentList = Array.isArray(window.xyZjzRealStudentList) ? window.xyZjzRealStudentList : [];
+		var firstStudent = studentList.length ? studentList[0] : null;
+		var emptyImage = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f0f0f0'/%3E%3Ctext x='50' y='55' font-size='12' fill='%23999' text-anchor='middle'%3E无照片%3C/text%3E%3C/svg%3E";
+
 		function setupHijack() {
 			if (!window.SS || !window.SS.dom || !window.SS.dom.initializeFormApp) {
 				setTimeout(setupHijack, 50);
 				return;
 			}
-			
-			const originalInit = window.SS.dom.initializeFormApp;
-			
+
+			var originalInit = window.SS.dom.initializeFormApp;
+
 			window.SS.dom.initializeFormApp = function(options) {
-				// 合并数据到 options
 				if (!options.data) {
 					options.data = function() { return {}; };
 				}
-				
-				const originalDataFn = options.data;
+
+				var originalDataFn = options.data;
 				options.data = function() {
-					const originalData = originalDataFn.call(this);
-					return {
-						...originalData,
-						currentStudent: { ...firstStudent },
-						bjmc: '一年级1班',
+					var originalData = originalDataFn.call(this);
+					return Object.assign({}, originalData, {
+						currentStudent: firstStudent ? Object.assign({}, firstStudent) : null,
+						bjmc: window.xyZjzRealBjmc || '',
 						bjcyCount: studentList.length,
 						currentIndex: 0,
 						studentList: studentList,
 						stream: null,
 						mode: 'disconnected'
-					};
+					});
 				};
-				
-				// 合并方法
+
 				if (!options.methods) {
 					options.methods = {};
 				}
+				// 功能说明:为 Vue 模板提供拍照按钮桥接方法,统一通过全局拍照对象调用 by xu 20260410
+				options.methods.handleConnectCamera = function() {
+					return window.xyZjzCameraPage && window.xyZjzCameraPage.connectCamera ? window.xyZjzCameraPage.connectCamera() : false;
+				};
+				// 功能说明:为 Vue 模板提供拍照按钮桥接方法,统一通过全局拍照对象调用 by xu 20260410
+				options.methods.handleCapturePhoto = function() {
+					return window.xyZjzCameraPage && window.xyZjzCameraPage.capturePhoto ? window.xyZjzCameraPage.capturePhoto() : false;
+				};
+				// 功能说明:为 Vue 模板提供拍照按钮桥接方法,统一通过全局拍照对象调用 by xu 20260410
+				options.methods.handleRetakePhoto = function() {
+					return window.xyZjzCameraPage && window.xyZjzCameraPage.retakePhoto ? window.xyZjzCameraPage.retakePhoto() : false;
+				};
+				// 功能说明:为 Vue 模板提供拍照按钮桥接方法,统一通过全局拍照对象调用 by xu 20260410
+				options.methods.handleSubmitPhotoForm = function() {
+					return window.xyZjzCameraPage && window.xyZjzCameraPage.submitPhotoForm ? window.xyZjzCameraPage.submitPhotoForm() : false;
+				};
 				options.methods.selectStudent = function(index, student) {
 					this.currentIndex = index;
-					this.currentStudent = { ...student };
-					const ryidInput = document.querySelector("input[name='ryid']");
-					const zjzwjInput = document.querySelector("input[name='zjzwj']");
+					this.currentStudent = Object.assign({}, student);
+					var ryidInput = document.querySelector("input[name='ryid']");
+					var zjzwjInput = document.querySelector("input[name='zjzwj']");
 					if (ryidInput) ryidInput.value = student.ryid || '';
 					if (zjzwjInput) zjzwjInput.value = student.zjzwj || '';
-					const img = document.getElementById("image");
-					if (img && student.zpwj) {
-						img.src = "/service?ssServ=dlByHttp&type=img&path=" + student.zpwj;
+					var img = document.getElementById("image");
+					if (img && student.zjzwj) {
+						img.src = "/service?ssServ=dlByHttp&type=img&path=" + student.zjzwj;
 					} else if (img) {
-						img.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f0f0f0'/%3E%3Ctext x='50' y='55' font-size='12' fill='%23999' text-anchor='middle'%3E无照片%3C/text%3E%3C/svg%3E";
+						img.src = emptyImage;
 					}
-				};
 				
-				console.log('[Vue] 数据已合并到初始化选项');
+					setTimeout(syncStudentListHeight, 0);
+				};
+
 				return originalInit.call(this, options);
 			};
-			
-			console.log('[Vue] initializeFormApp 已劫持');
 		}
-		
-		// 如果 SS 还没准备好,等它
+
 		if (typeof SS !== 'undefined' && SS.ready) {
 			SS.ready(setupHijack);
 		} else {
 			setTimeout(setupHijack, 100);
 		}
 	})();
-</script>
+</script>

BIN
skin/easy/image/xy-zjz-face-mask.png