b37e141c00a123cf362fae00c1e63175d41c4bbe..bd6b518aae61608ddc2d82b43ccc283dc95b9c54
4 天以前 Junjie
#
bd6b51 对比 | 目录
4 天以前 Junjie
#
ab6dd7 对比 | 目录
4 天以前 Junjie
#
d9411a 对比 | 目录
4 天以前 Junjie
#
be1cd9 对比 | 目录
4 天以前 Junjie
#
3eab44 对比 | 目录
4 天以前 Junjie
#
dbc7bc 对比 | 目录
4 天以前 Junjie
#
a8cef4 对比 | 目录
36个文件已修改
10个文件已添加
9832 ■■■■ 已修改文件
src/main/java/com/zy/common/auth/MfaLoginTicketManager.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/auth/PasskeyChallengeManager.java 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/utils/MfaTotpUtil.java 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/utils/PasskeyWebAuthnUtil.java 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/utils/QrCode.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/web/AuthController.java 512 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/config/UserMfaSchemaInitializer.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/config/UserPasskeySchemaInitializer.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/controller/UserController.java 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/entity/User.java 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/mapper/UserMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/UserService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/impl/UserServiceImpl.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/en-US/legacy.properties 370 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/en-US/messages.properties 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/zh-CN/messages.properties 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/UserMapper.xml 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260311_add_mfa_columns_to_sys_user.sql 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260311_add_passkey_columns_to_sys_user.sql 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/MapCanvas.js 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/config/config.js 218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/detail/detail.js 382 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/login/login.js 275 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/user/user.js 3721 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/webauthn-utils.js 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/wrkMast/wrkMast.js 319 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/diagnosis.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/llm_config.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/basOutStationArea/basOutStationArea.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/basStationDevice/basStationDevice.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/config/config.html 43 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/debugParam/debugParam.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/detail.html 294 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/deviceLogs/deviceLogs.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/index.html 313 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/locMap/locMap.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/login.html 150 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/notifyReport/notifyReport.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/password.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/role/role_power_detail.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/user/user.html 783 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/console.html 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/console_html.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/console_pixijs.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/stationColorConfig.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/wrkMast/wrkMast.html 830 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/auth/MfaLoginTicketManager.java
New file
@@ -0,0 +1,73 @@
package com.zy.common.auth;
import com.core.common.Cools;
import org.springframework.stereotype.Component;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class MfaLoginTicketManager {
    private static final long EXPIRE_MILLIS = 5 * 60 * 1000L;
    private final SecureRandom secureRandom = new SecureRandom();
    private final ConcurrentHashMap<String, TicketHolder> holders = new ConcurrentHashMap<>();
    public String create(Long userId) {
        cleanup();
        String ticket;
        do {
            ticket = randomTicket();
        } while (holders.putIfAbsent(ticket, new TicketHolder(userId, System.currentTimeMillis() + EXPIRE_MILLIS)) != null);
        return ticket;
    }
    public Long getUserId(String ticket) {
        if (Cools.isEmpty(ticket)) {
            return null;
        }
        TicketHolder holder = holders.get(ticket);
        if (holder == null) {
            return null;
        }
        if (holder.expireAt < System.currentTimeMillis()) {
            holders.remove(ticket);
            return null;
        }
        return holder.userId;
    }
    public void remove(String ticket) {
        if (!Cools.isEmpty(ticket)) {
            holders.remove(ticket);
        }
    }
    private void cleanup() {
        long now = System.currentTimeMillis();
        for (Map.Entry<String, TicketHolder> entry : holders.entrySet()) {
            if (entry.getValue().expireAt < now) {
                holders.remove(entry.getKey());
            }
        }
    }
    private String randomTicket() {
        byte[] bytes = new byte[24];
        secureRandom.nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }
    private static final class TicketHolder {
        private final Long userId;
        private final long expireAt;
        private TicketHolder(Long userId, long expireAt) {
            this.userId = userId;
            this.expireAt = expireAt;
        }
    }
}
src/main/java/com/zy/common/auth/PasskeyChallengeManager.java
New file
@@ -0,0 +1,138 @@
package com.zy.common.auth;
import com.core.common.Cools;
import org.springframework.stereotype.Component;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class PasskeyChallengeManager {
    private static final long EXPIRE_MILLIS = 5 * 60 * 1000L;
    private final SecureRandom secureRandom = new SecureRandom();
    private final ConcurrentHashMap<String, ChallengeState> holders = new ConcurrentHashMap<>();
    public ChallengeState createRegistration(Long userId, String origin, String rpId) {
        return create(Purpose.REGISTRATION, userId, origin, rpId);
    }
    public ChallengeState createAuthentication(Long userId, String origin, String rpId) {
        return create(Purpose.AUTHENTICATION, userId, origin, rpId);
    }
    public ChallengeState get(String ticket, Purpose purpose) {
        if (Cools.isEmpty(ticket) || purpose == null) {
            return null;
        }
        ChallengeState state = holders.get(ticket);
        if (state == null) {
            return null;
        }
        if (state.expireAt < System.currentTimeMillis() || state.purpose != purpose) {
            holders.remove(ticket);
            return null;
        }
        return state;
    }
    public void remove(String ticket) {
        if (!Cools.isEmpty(ticket)) {
            holders.remove(ticket);
        }
    }
    private ChallengeState create(Purpose purpose, Long userId, String origin, String rpId) {
        cleanup();
        ChallengeState state;
        do {
            state = new ChallengeState(
                    randomTicket(),
                    randomChallenge(),
                    purpose,
                    userId,
                    origin,
                    rpId,
                    System.currentTimeMillis() + EXPIRE_MILLIS
            );
        } while (holders.putIfAbsent(state.ticket, state) != null);
        return state;
    }
    private void cleanup() {
        long now = System.currentTimeMillis();
        for (Map.Entry<String, ChallengeState> entry : holders.entrySet()) {
            if (entry.getValue().expireAt < now) {
                holders.remove(entry.getKey());
            }
        }
    }
    private String randomTicket() {
        byte[] bytes = new byte[24];
        secureRandom.nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }
    private String randomChallenge() {
        byte[] bytes = new byte[32];
        secureRandom.nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }
    public enum Purpose {
        REGISTRATION,
        AUTHENTICATION
    }
    public static final class ChallengeState {
        private final String ticket;
        private final String challenge;
        private final Purpose purpose;
        private final Long userId;
        private final String origin;
        private final String rpId;
        private final long expireAt;
        private ChallengeState(String ticket, String challenge, Purpose purpose, Long userId, String origin, String rpId, long expireAt) {
            this.ticket = ticket;
            this.challenge = challenge;
            this.purpose = purpose;
            this.userId = userId;
            this.origin = origin;
            this.rpId = rpId;
            this.expireAt = expireAt;
        }
        public String getTicket() {
            return ticket;
        }
        public String getChallenge() {
            return challenge;
        }
        public Purpose getPurpose() {
            return purpose;
        }
        public Long getUserId() {
            return userId;
        }
        public String getOrigin() {
            return origin;
        }
        public String getRpId() {
            return rpId;
        }
        public long getExpireAt() {
            return expireAt;
        }
    }
}
src/main/java/com/zy/common/utils/MfaTotpUtil.java
New file
@@ -0,0 +1,148 @@
package com.zy.common.utils;
import com.core.common.Cools;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Locale;
public final class MfaTotpUtil {
    private static final char[] BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray();
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
    private static final int SECRET_SIZE = 20;
    private static final int OTP_DIGITS = 6;
    private static final int OTP_PERIOD_SECONDS = 30;
    private MfaTotpUtil() {
    }
    public static String generateSecret() {
        byte[] buffer = new byte[SECRET_SIZE];
        SECURE_RANDOM.nextBytes(buffer);
        return encodeBase32(buffer);
    }
    public static boolean verifyCode(String secret, String code, int window) {
        if (Cools.isEmpty(secret, code)) {
            return false;
        }
        String normalizedCode = String.valueOf(code).replaceAll("\\s+", "");
        if (!normalizedCode.matches("\\d{" + OTP_DIGITS + "}")) {
            return false;
        }
        try {
            long currentStep = System.currentTimeMillis() / 1000L / OTP_PERIOD_SECONDS;
            for (int offset = -window; offset <= window; offset++) {
                if (normalizedCode.equals(generateCode(secret, currentStep + offset))) {
                    return true;
                }
            }
        } catch (Exception ignored) {
        }
        return false;
    }
    public static String buildOtpAuthUri(String issuer, String account, String secret) {
        String safeIssuer = Cools.isEmpty(issuer) ? "WCS" : issuer.trim();
        String safeAccount = Cools.isEmpty(account) ? "user" : account.trim();
        String label = urlEncode(safeIssuer + ":" + safeAccount);
        return "otpauth://totp/" + label
                + "?secret=" + secret
                + "&issuer=" + urlEncode(safeIssuer)
                + "&algorithm=SHA1&digits=" + OTP_DIGITS
                + "&period=" + OTP_PERIOD_SECONDS;
    }
    public static String maskSecret(String secret) {
        if (Cools.isEmpty(secret)) {
            return "";
        }
        String value = String.valueOf(secret).trim();
        if (value.length() <= 8) {
            return value;
        }
        return value.substring(0, 4) + "****" + value.substring(value.length() - 4);
    }
    private static String generateCode(String secret, long step) {
        try {
            byte[] key = decodeBase32(secret);
            byte[] data = ByteBuffer.allocate(8).putLong(step).array();
            Mac mac = Mac.getInstance("HmacSHA1");
            mac.init(new SecretKeySpec(key, "HmacSHA1"));
            byte[] hash = mac.doFinal(data);
            int offset = hash[hash.length - 1] & 0x0F;
            int binary = ((hash[offset] & 0x7F) << 24)
                    | ((hash[offset + 1] & 0xFF) << 16)
                    | ((hash[offset + 2] & 0xFF) << 8)
                    | (hash[offset + 3] & 0xFF);
            int otp = binary % (int) Math.pow(10, OTP_DIGITS);
            return String.format(Locale.ROOT, "%0" + OTP_DIGITS + "d", otp);
        } catch (Exception e) {
            throw new IllegalStateException("generate totp code failed", e);
        }
    }
    private static String encodeBase32(byte[] data) {
        StringBuilder builder = new StringBuilder((data.length * 8 + 4) / 5);
        int buffer = 0;
        int bitsLeft = 0;
        for (byte datum : data) {
            buffer = (buffer << 8) | (datum & 0xFF);
            bitsLeft += 8;
            while (bitsLeft >= 5) {
                builder.append(BASE32_ALPHABET[(buffer >> (bitsLeft - 5)) & 0x1F]);
                bitsLeft -= 5;
            }
        }
        if (bitsLeft > 0) {
            builder.append(BASE32_ALPHABET[(buffer << (5 - bitsLeft)) & 0x1F]);
        }
        return builder.toString();
    }
    private static byte[] decodeBase32(String value) {
        String normalized = String.valueOf(value)
                .trim()
                .replace("=", "")
                .replace(" ", "")
                .replace("-", "")
                .toUpperCase(Locale.ROOT);
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        int buffer = 0;
        int bitsLeft = 0;
        for (int i = 0; i < normalized.length(); i++) {
            char current = normalized.charAt(i);
            int index = indexOfBase32(current);
            if (index < 0) {
                throw new IllegalArgumentException("invalid base32 secret");
            }
            buffer = (buffer << 5) | index;
            bitsLeft += 5;
            if (bitsLeft >= 8) {
                output.write((buffer >> (bitsLeft - 8)) & 0xFF);
                bitsLeft -= 8;
            }
        }
        return output.toByteArray();
    }
    private static int indexOfBase32(char value) {
        for (int i = 0; i < BASE32_ALPHABET.length; i++) {
            if (BASE32_ALPHABET[i] == value) {
                return i;
            }
        }
        return -1;
    }
    private static String urlEncode(String value) {
        return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
    }
}
src/main/java/com/zy/common/utils/PasskeyWebAuthnUtil.java
New file
@@ -0,0 +1,268 @@
package com.zy.common.utils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.core.common.Cools;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
public final class PasskeyWebAuthnUtil {
    private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
    private PasskeyWebAuthnUtil() {
    }
    public static JSONObject parseClientData(String clientDataJsonBase64Url) {
        if (Cools.isEmpty(clientDataJsonBase64Url)) {
            throw new IllegalArgumentException("Missing clientDataJSON");
        }
        String json = new String(decodeBase64Url(clientDataJsonBase64Url), StandardCharsets.UTF_8);
        JSONObject clientData = JSON.parseObject(json);
        if (clientData == null) {
            throw new IllegalArgumentException("Invalid clientDataJSON");
        }
        return clientData;
    }
    public static void validateClientData(JSONObject clientData, String expectedType, String expectedChallenge, String expectedOrigin) {
        if (clientData == null) {
            throw new IllegalArgumentException("Missing clientData");
        }
        if (!Cools.eq(expectedType, clientData.getString("type"))) {
            throw new IllegalArgumentException("Unexpected WebAuthn type");
        }
        if (!Cools.eq(expectedChallenge, clientData.getString("challenge"))) {
            throw new IllegalArgumentException("Challenge mismatch");
        }
        if (!Cools.eq(expectedOrigin, clientData.getString("origin"))) {
            throw new IllegalArgumentException("Origin mismatch");
        }
    }
    public static AuthenticatorData validateAuthenticatorData(String authenticatorDataBase64Url, String rpId, boolean requireUserVerification) throws GeneralSecurityException {
        byte[] authenticatorData = decodeBase64Url(authenticatorDataBase64Url);
        if (authenticatorData.length < 37) {
            throw new GeneralSecurityException("Invalid authenticator data");
        }
        byte[] expectedRpIdHash = sha256(rpId.getBytes(StandardCharsets.UTF_8));
        for (int i = 0; i < expectedRpIdHash.length; i++) {
            if (authenticatorData[i] != expectedRpIdHash[i]) {
                throw new GeneralSecurityException("RP ID hash mismatch");
            }
        }
        int flags = authenticatorData[32] & 0xFF;
        if ((flags & 0x01) == 0) {
            throw new GeneralSecurityException("User presence required");
        }
        if (requireUserVerification && (flags & 0x04) == 0) {
            throw new GeneralSecurityException("User verification required");
        }
        long signCount = ByteBuffer.wrap(authenticatorData, 33, 4).getInt() & 0xFFFFFFFFL;
        return new AuthenticatorData(authenticatorData, flags, signCount);
    }
    public static void verifyAssertionSignature(String publicKeyBase64Url,
                                                Integer algorithm,
                                                String authenticatorDataBase64Url,
                                                String clientDataJsonBase64Url,
                                                String signatureBase64Url) throws GeneralSecurityException {
        PublicKey publicKey = readPublicKey(publicKeyBase64Url, algorithm);
        Signature verifier = createSignatureVerifier(publicKey, algorithm);
        verifier.initVerify(publicKey);
        verifier.update(decodeBase64Url(authenticatorDataBase64Url));
        verifier.update(sha256(decodeBase64Url(clientDataJsonBase64Url)));
        if (!verifier.verify(decodeBase64Url(signatureBase64Url))) {
            throw new GeneralSecurityException("Invalid passkey signature");
        }
    }
    public static void ensurePublicKeyMaterial(String publicKeyBase64Url, Integer algorithm) throws GeneralSecurityException {
        readPublicKey(publicKeyBase64Url, algorithm);
    }
    public static byte[] decodeBase64Url(String value) {
        if (Cools.isEmpty(value)) {
            throw new IllegalArgumentException("Missing base64Url value");
        }
        return URL_DECODER.decode(String.valueOf(value).trim());
    }
    public static String buildOrigin(HttpServletRequest request) {
        String scheme = normalizeForwardedValue(request.getHeader("X-Forwarded-Proto"));
        if (Cools.isEmpty(scheme)) {
            scheme = request.getScheme();
        }
        String host = resolveHost(request);
        return scheme.toLowerCase(Locale.ROOT) + "://" + host;
    }
    public static String buildRpId(HttpServletRequest request) {
        String host = resolveHost(request);
        if (host.startsWith("[")) {
            int bracket = host.indexOf(']');
            return bracket > 0 ? host.substring(1, bracket).toLowerCase(Locale.ROOT) : host.toLowerCase(Locale.ROOT);
        }
        int colonIndex = host.indexOf(':');
        if (colonIndex >= 0) {
            host = host.substring(0, colonIndex);
        }
        return host.toLowerCase(Locale.ROOT);
    }
    public static boolean isSecureOriginAllowed(String origin, String rpId) {
        if (Cools.isEmpty(origin) || Cools.isEmpty(rpId)) {
            return false;
        }
        String lowerOrigin = origin.toLowerCase(Locale.ROOT);
        String lowerRpId = rpId.toLowerCase(Locale.ROOT);
        if ("localhost".equals(lowerRpId)
                || "127.0.0.1".equals(lowerRpId)
                || "::1".equals(lowerRpId)
                || lowerRpId.endsWith(".localhost")) {
            return true;
        }
        return lowerOrigin.startsWith("https://");
    }
    public static byte[] buildUserHandle(Long userId) {
        return String.valueOf(userId).getBytes(StandardCharsets.UTF_8);
    }
    private static PublicKey readPublicKey(String publicKeyBase64Url, Integer algorithm) throws GeneralSecurityException {
        byte[] encoded = decodeBase64Url(publicKeyBase64Url);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded);
        List<String> keyFactories = keyFactoriesForAlgorithm(algorithm);
        GeneralSecurityException failure = null;
        for (String keyFactoryName : keyFactories) {
            try {
                return KeyFactory.getInstance(keyFactoryName).generatePublic(keySpec);
            } catch (GeneralSecurityException ex) {
                failure = ex;
            }
        }
        throw failure == null ? new GeneralSecurityException("Unsupported passkey algorithm") : failure;
    }
    private static Signature createSignatureVerifier(PublicKey publicKey, Integer algorithm) throws GeneralSecurityException {
        int value = algorithm == null ? Integer.MIN_VALUE : algorithm;
        switch (value) {
            case -7:
                return Signature.getInstance("SHA256withECDSA");
            case -257:
                return Signature.getInstance("SHA256withRSA");
            case -37:
                Signature pss = Signature.getInstance("RSASSA-PSS");
                pss.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
                return pss;
            case -8:
                return Signature.getInstance("Ed25519");
            default:
                if ("EC".equalsIgnoreCase(publicKey.getAlgorithm())) {
                    return Signature.getInstance("SHA256withECDSA");
                }
                if ("RSA".equalsIgnoreCase(publicKey.getAlgorithm())) {
                    return Signature.getInstance("SHA256withRSA");
                }
                if ("Ed25519".equalsIgnoreCase(publicKey.getAlgorithm()) || "EdDSA".equalsIgnoreCase(publicKey.getAlgorithm())) {
                    return Signature.getInstance("Ed25519");
                }
                throw new GeneralSecurityException("Unsupported passkey signature algorithm");
        }
    }
    private static List<String> keyFactoriesForAlgorithm(Integer algorithm) {
        List<String> result = new ArrayList<>();
        int value = algorithm == null ? Integer.MIN_VALUE : algorithm;
        switch (value) {
            case -7:
                result.add("EC");
                break;
            case -257:
            case -37:
                result.add("RSA");
                break;
            case -8:
                result.add("Ed25519");
                result.add("EdDSA");
                break;
            default:
                result.add("EC");
                result.add("RSA");
                result.add("Ed25519");
                result.add("EdDSA");
                break;
        }
        return result;
    }
    private static String resolveHost(HttpServletRequest request) {
        String host = normalizeForwardedValue(request.getHeader("X-Forwarded-Host"));
        if (Cools.isEmpty(host)) {
            host = request.getServerName();
            int port = request.getServerPort();
            if (port > 0 && port != 80 && port != 443) {
                host = host + ":" + port;
            }
        }
        String port = normalizeForwardedValue(request.getHeader("X-Forwarded-Port"));
        if (!Cools.isEmpty(port) && host.indexOf(':') < 0 && !host.startsWith("[")) {
            host = host + ":" + port;
        }
        return host;
    }
    private static String normalizeForwardedValue(String value) {
        if (Cools.isEmpty(value)) {
            return null;
        }
        String normalized = String.valueOf(value).trim();
        int commaIndex = normalized.indexOf(',');
        if (commaIndex >= 0) {
            normalized = normalized.substring(0, commaIndex).trim();
        }
        return normalized;
    }
    private static byte[] sha256(byte[] data) throws GeneralSecurityException {
        return MessageDigest.getInstance("SHA-256").digest(data);
    }
    public static final class AuthenticatorData {
        private final byte[] raw;
        private final int flags;
        private final long signCount;
        private AuthenticatorData(byte[] raw, int flags, long signCount) {
            this.raw = raw;
            this.flags = flags;
            this.signCount = signCount;
        }
        public byte[] getRaw() {
            return raw;
        }
        public int getFlags() {
            return flags;
        }
        public long getSignCount() {
            return signCount;
        }
    }
}
src/main/java/com/zy/common/utils/QrCode.java
@@ -24,11 +24,15 @@
    public static BufferedImage createImg(String content) throws WriterException {
        return createImg(content, QRCODE_SIZE);
    }
    public static BufferedImage createImg(String content, int size) throws WriterException {
        ConcurrentHashMap hints = new ConcurrentHashMap();
        hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
        hints.put(EncodeHintType.CHARACTER_SET, CHARSET);
        hints.put(EncodeHintType.MARGIN, 1);
        BitMatrix bitMatrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, QRCODE_SIZE, QRCODE_SIZE, hints);
        BitMatrix bitMatrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints);
        int width = bitMatrix.getWidth();
        int height = bitMatrix.getHeight();
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
src/main/java/com/zy/common/web/AuthController.java
@@ -2,16 +2,20 @@
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.annotations.ManagerAuth;
import com.core.common.Cools;
import com.core.common.R;
import com.core.exception.CoolException;
import com.zy.common.CodeRes;
import com.zy.common.auth.MfaLoginTicketManager;
import com.zy.common.auth.PasskeyChallengeManager;
import com.zy.common.i18n.I18nMessageService;
import com.zy.common.entity.Parameter;
import com.zy.common.model.PowerDto;
import com.zy.common.model.enums.HtmlNavIconType;
import com.zy.common.utils.MfaTotpUtil;
import com.zy.common.utils.PasskeyWebAuthnUtil;
import com.zy.common.utils.QrCode;
import com.zy.common.utils.RandomValidateCodeUtil;
import com.zy.system.entity.*;
import com.zy.system.service.*;
@@ -25,6 +29,10 @@
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletResponse;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.*;
/**
@@ -53,6 +61,10 @@
    private LicenseTimer licenseTimer;
    @Autowired
    private I18nMessageService i18nMessageService;
    @Autowired
    private MfaLoginTicketManager mfaLoginTicketManager;
    @Autowired
    private PasskeyChallengeManager passkeyChallengeManager;
    @RequestMapping("/login.action")
    @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "登录")
@@ -61,15 +73,16 @@
        if (!licenseTimer.getSystemSupport()){
            return new R(20001, i18nMessageService.getMessage("response.system.licenseExpired"));
        }
        if (Cools.isEmpty(mobile, password)) {
            return new R(10003, i18nMessageService.getMessage("response.user.passwordMismatch"));
        }
        if (mobile.equals("super") && password.equals(Cools.md5(superPwd))) {
            Map<String, Object> res = new HashMap<>();
            res.put("username", mobile);
            res.put("token", Cools.enToken(System.currentTimeMillis() + mobile, superPwd));
            return R.ok(res);
        }
        QueryWrapper<User> userWrapper = new QueryWrapper<>();
        userWrapper.eq("mobile", mobile);
        User user = userService.getOne(userWrapper);
        User user = userService.getByMobileWithSecurity(mobile);
        if (Cools.isEmpty(user)){
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
@@ -79,17 +92,123 @@
        if (!user.getPassword().equals(password)){
            return new R(10003, i18nMessageService.getMessage("response.user.passwordMismatch"));
        }
        String token = Cools.enToken(System.currentTimeMillis() + mobile, user.getPassword());
        userLoginService.remove(new QueryWrapper<UserLogin>().eq("user_id", user.getId()).eq("system_type", "WCS"));
        UserLogin userLogin = new UserLogin();
        userLogin.setUserId(user.getId());
        userLogin.setToken(token);
        userLogin.setSystemType("WCS");
        userLoginService.save(userLogin);
        Map<String, Object> res = new HashMap<>();
        res.put("username", user.getUsername());
        res.put("token", token);
        return R.ok(res);
        if (requiresMfa(user)) {
            Map<String, Object> res = new HashMap<>();
            res.put("username", user.getUsername());
            res.put("mfaRequired", true);
            res.put("mfaTicket", mfaLoginTicketManager.create(user.getId()));
            return R.ok(res);
        }
        return R.ok(buildLoginSuccess(user));
    }
    @RequestMapping("/login/mfa.action")
    @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "MFA登录")
    public R loginMfaAction(String ticket, String code) {
        Long userId = mfaLoginTicketManager.getUserId(ticket);
        if (userId == null) {
            return new R(10004, i18nMessageService.getMessage("response.user.mfaTicketExpired"));
        }
        User user = userService.getByIdWithSecurity(userId);
        if (Cools.isEmpty(user)) {
            mfaLoginTicketManager.remove(ticket);
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        if (user.getStatus() != 1) {
            mfaLoginTicketManager.remove(ticket);
            return new R(10002, i18nMessageService.getMessage("response.user.disabled"));
        }
        if (!requiresMfa(user)) {
            mfaLoginTicketManager.remove(ticket);
            return new R(10005, i18nMessageService.getMessage("response.user.mfaNotEnabled"));
        }
        if (!MfaTotpUtil.verifyCode(user.getMfaSecret(), code, 1)) {
            return new R(10006, i18nMessageService.getMessage("response.user.mfaCodeMismatch"));
        }
        mfaLoginTicketManager.remove(ticket);
        return R.ok(buildLoginSuccess(user));
    }
    @RequestMapping("/login/passkey/options.action")
    @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "通行密钥登录参数")
    public R loginPasskeyOptions(String mobile) {
        if (!licenseTimer.getSystemSupport()) {
            return new R(20001, i18nMessageService.getMessage("response.system.licenseExpired"));
        }
        String origin = PasskeyWebAuthnUtil.buildOrigin(request);
        String rpId = PasskeyWebAuthnUtil.buildRpId(request);
        if (!PasskeyWebAuthnUtil.isSecureOriginAllowed(origin, rpId)) {
            return new R(10009, i18nMessageService.getMessage("response.user.passkeySecureContextRequired"));
        }
        User user = null;
        if (!Cools.isEmpty(mobile)) {
            user = userService.getByMobileWithSecurity(mobile);
            if (Cools.isEmpty(user)) {
                return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
            }
            if (user.getStatus() != 1) {
                return new R(10002, i18nMessageService.getMessage("response.user.disabled"));
            }
            if (!hasPasskeyBound(user)) {
                return new R(10010, i18nMessageService.getMessage("response.user.passkeyNotBound"));
            }
        }
        PasskeyChallengeManager.ChallengeState state = passkeyChallengeManager.createAuthentication(user == null ? null : user.getId(), origin, rpId);
        return R.ok(buildPasskeyAuthenticationOptions(state, user));
    }
    @RequestMapping("/login/passkey/verify.action")
    @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "通行密钥登录")
    public R loginPasskeyVerify(String ticket,
                                String credentialId,
                                String clientDataJSON,
                                String authenticatorData,
                                String signature) {
        if (!licenseTimer.getSystemSupport()) {
            return new R(20001, i18nMessageService.getMessage("response.system.licenseExpired"));
        }
        PasskeyChallengeManager.ChallengeState state = passkeyChallengeManager.get(ticket, PasskeyChallengeManager.Purpose.AUTHENTICATION);
        if (state == null) {
            return new R(10011, i18nMessageService.getMessage("response.user.passkeyTicketExpired"));
        }
        try {
            com.alibaba.fastjson.JSONObject clientData = PasskeyWebAuthnUtil.parseClientData(clientDataJSON);
            PasskeyWebAuthnUtil.validateClientData(clientData, "webauthn.get", state.getChallenge(), state.getOrigin());
            PasskeyWebAuthnUtil.AuthenticatorData authData = PasskeyWebAuthnUtil.validateAuthenticatorData(authenticatorData, state.getRpId(), true);
            User user = state.getUserId() == null
                    ? userService.getByPasskeyCredentialId(credentialId)
                    : userService.getByIdWithSecurity(state.getUserId());
            if (Cools.isEmpty(user)) {
                return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
            }
            if (user.getStatus() != 1) {
                return new R(10002, i18nMessageService.getMessage("response.user.disabled"));
            }
            if (!hasPasskeyBound(user) || !Cools.eq(user.getPasskeyCredentialId(), credentialId)) {
                return new R(10010, i18nMessageService.getMessage("response.user.passkeyNotBound"));
            }
            PasskeyWebAuthnUtil.verifyAssertionSignature(
                    user.getPasskeyPublicKey(),
                    user.getPasskeyAlgorithm(),
                    authenticatorData,
                    clientDataJSON,
                    signature
            );
            long nextSignCount = authData.getSignCount();
            Long currentSignCount = user.getPasskeySignCount();
            if (currentSignCount != null && currentSignCount > 0 && nextSignCount > 0 && nextSignCount <= currentSignCount) {
                return new R(10012, i18nMessageService.getMessage("response.user.passkeyCounterMismatch"));
            }
            userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>()
                    .eq("id", user.getId())
                    .set("passkey_sign_count", nextSignCount)
                    .set("passkey_last_used_time", new Date()));
            return R.ok(buildLoginSuccess(user));
        } catch (Exception ex) {
            return new R(10013, i18nMessageService.getMessage("response.user.passkeyVerifyFailed"));
        } finally {
            passkeyChallengeManager.remove(ticket);
        }
    }
    @RequestMapping("/code/switch.action")
@@ -122,7 +241,187 @@
    @RequestMapping("/user/detail/auth")
    @ManagerAuth
    public R userDetail(){
        return R.ok(userService.getById(getUserId()));
        User user = userService.getByIdWithSecurity(getUserId());
        if (Cools.isEmpty(user)) {
            return R.ok();
        }
        return R.ok(buildSafeUserDetail(user));
    }
    @RequestMapping("/user/mfa/setup/auth")
    @ManagerAuth
    public R userMfaSetup() {
        User user = userService.getByIdWithSecurity(getUserId());
        if (Cools.isEmpty(user)) {
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        if (!Integer.valueOf(1).equals(user.getMfaAllow())) {
            return new R(10007, i18nMessageService.getMessage("response.user.mfaNotAllowed"));
        }
        String secret = MfaTotpUtil.generateSecret();
        String account = !Cools.isEmpty(user.getMobile())
                ? user.getMobile()
                : (!Cools.isEmpty(user.getUsername()) ? user.getUsername() : String.valueOf(user.getId()));
        String otpAuth = MfaTotpUtil.buildOtpAuthUri("WCS", account, secret);
        Map<String, Object> data = new HashMap<>();
        data.put("secret", secret);
        data.put("otpAuth", otpAuth);
        data.put("qrCode", renderQrCodeDataUri(otpAuth));
        return R.ok(data);
    }
    @RequestMapping("/user/mfa/enable/auth")
    @ManagerAuth
    @Transactional
    public R userMfaEnable(String currentPassword, String secret, String code) {
        User user = userService.getByIdWithSecurity(getUserId());
        if (Cools.isEmpty(user)) {
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        if (!Integer.valueOf(1).equals(user.getMfaAllow())) {
            return new R(10007, i18nMessageService.getMessage("response.user.mfaNotAllowed"));
        }
        if (!Cools.eq(user.getPassword(), currentPassword)) {
            return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch"));
        }
        String normalizedSecret = normalizeSecret(secret);
        if (Cools.isEmpty(normalizedSecret) || !MfaTotpUtil.verifyCode(normalizedSecret, code, 1)) {
            return new R(10006, i18nMessageService.getMessage("response.user.mfaCodeMismatch"));
        }
        userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>()
                .eq("id", user.getId())
                .set("mfa_allow", 1)
                .set("mfa_enabled", 1)
                .set("mfa_secret", normalizedSecret)
                .set("mfa_bound_time", new Date()));
        return R.ok();
    }
    @RequestMapping("/user/mfa/disable/auth")
    @ManagerAuth
    @Transactional
    public R userMfaDisable(String currentPassword, String code) {
        User user = userService.getByIdWithSecurity(getUserId());
        if (Cools.isEmpty(user)) {
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        if (!requiresMfa(user)) {
            return new R(10005, i18nMessageService.getMessage("response.user.mfaNotEnabled"));
        }
        if (!Cools.eq(user.getPassword(), currentPassword)) {
            return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch"));
        }
        if (!MfaTotpUtil.verifyCode(user.getMfaSecret(), code, 1)) {
            return new R(10006, i18nMessageService.getMessage("response.user.mfaCodeMismatch"));
        }
        userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>()
                .eq("id", user.getId())
                .set("mfa_enabled", 0)
                .set("mfa_secret", null)
                .set("mfa_bound_time", null));
        return R.ok();
    }
    @RequestMapping("/user/passkey/register/options/auth")
    @ManagerAuth
    public R userPasskeyRegisterOptions() {
        User user = userService.getByIdWithSecurity(getUserId());
        if (Cools.isEmpty(user)) {
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        if (hasPasskeyBound(user)) {
            return new R(10014, i18nMessageService.getMessage("response.user.passkeyAlreadyBound"));
        }
        String origin = PasskeyWebAuthnUtil.buildOrigin(request);
        String rpId = PasskeyWebAuthnUtil.buildRpId(request);
        if (!PasskeyWebAuthnUtil.isSecureOriginAllowed(origin, rpId)) {
            return new R(10009, i18nMessageService.getMessage("response.user.passkeySecureContextRequired"));
        }
        PasskeyChallengeManager.ChallengeState state = passkeyChallengeManager.createRegistration(user.getId(), origin, rpId);
        return R.ok(buildPasskeyRegistrationOptions(state, user));
    }
    @RequestMapping("/user/passkey/register/finish/auth")
    @ManagerAuth
    @Transactional
    public R userPasskeyRegisterFinish(String ticket,
                                       String currentPassword,
                                       String name,
                                       String credentialId,
                                       String clientDataJSON,
                                       String authenticatorData,
                                       String publicKey,
                                       Integer publicKeyAlgorithm,
                                       String transports) {
        User user = userService.getByIdWithSecurity(getUserId());
        if (Cools.isEmpty(user)) {
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        if (!Cools.eq(user.getPassword(), currentPassword)) {
            return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch"));
        }
        if (hasPasskeyBound(user)) {
            return new R(10014, i18nMessageService.getMessage("response.user.passkeyAlreadyBound"));
        }
        PasskeyChallengeManager.ChallengeState state = passkeyChallengeManager.get(ticket, PasskeyChallengeManager.Purpose.REGISTRATION);
        if (state == null || !Objects.equals(state.getUserId(), user.getId())) {
            return new R(10011, i18nMessageService.getMessage("response.user.passkeyTicketExpired"));
        }
        try {
            com.alibaba.fastjson.JSONObject clientData = PasskeyWebAuthnUtil.parseClientData(clientDataJSON);
            PasskeyWebAuthnUtil.validateClientData(clientData, "webauthn.create", state.getChallenge(), state.getOrigin());
            PasskeyWebAuthnUtil.AuthenticatorData authData = PasskeyWebAuthnUtil.validateAuthenticatorData(authenticatorData, state.getRpId(), true);
            PasskeyWebAuthnUtil.ensurePublicKeyMaterial(publicKey, publicKeyAlgorithm);
            if (Cools.isEmpty(credentialId)) {
                return new R(10013, i18nMessageService.getMessage("response.user.passkeyRegisterFailed"));
            }
            User exist = userService.getByPasskeyCredentialId(credentialId);
            if (!Cools.isEmpty(exist) && !Objects.equals(exist.getId(), user.getId())) {
                return new R(10015, i18nMessageService.getMessage("response.user.passkeyCredentialExists"));
            }
            userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>()
                    .eq("id", user.getId())
                    .set("passkey_name", normalizePasskeyName(name))
                    .set("passkey_credential_id", credentialId)
                    .set("passkey_public_key", publicKey)
                    .set("passkey_algorithm", publicKeyAlgorithm)
                    .set("passkey_sign_count", authData.getSignCount())
                    .set("passkey_transports", normalizePasskeyTransports(transports))
                    .set("passkey_bound_time", new Date())
                    .set("passkey_last_used_time", null));
            return R.ok();
        } catch (Exception ex) {
            return new R(10016, i18nMessageService.getMessage("response.user.passkeyRegisterFailed"));
        } finally {
            passkeyChallengeManager.remove(ticket);
        }
    }
    @RequestMapping("/user/passkey/remove/auth")
    @ManagerAuth
    @Transactional
    public R userPasskeyRemove(String currentPassword) {
        User user = userService.getByIdWithSecurity(getUserId());
        if (Cools.isEmpty(user)) {
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        if (!hasPasskeyBound(user)) {
            return new R(10010, i18nMessageService.getMessage("response.user.passkeyNotBound"));
        }
        if (!Cools.eq(user.getPassword(), currentPassword)) {
            return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch"));
        }
        userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>()
                .eq("id", user.getId())
                .set("passkey_name", null)
                .set("passkey_credential_id", null)
                .set("passkey_public_key", null)
                .set("passkey_algorithm", null)
                .set("passkey_sign_count", 0)
                .set("passkey_transports", null)
                .set("passkey_bound_time", null)
                .set("passkey_last_used_time", null));
        return R.ok();
    }
    @RequestMapping("/menu/auth")
@@ -246,6 +545,187 @@
        return R.ok(result);
    }
    private Map<String, Object> buildLoginSuccess(User user) {
        String token = Cools.enToken(System.currentTimeMillis() + user.getMobile(), user.getPassword());
        userLoginService.remove(new QueryWrapper<UserLogin>().eq("user_id", user.getId()).eq("system_type", "WCS"));
        UserLogin userLogin = new UserLogin();
        userLogin.setUserId(user.getId());
        userLogin.setToken(token);
        userLogin.setSystemType("WCS");
        userLoginService.save(userLogin);
        Map<String, Object> result = new HashMap<>();
        result.put("username", user.getUsername());
        result.put("token", token);
        result.put("mfaRequired", false);
        return result;
    }
    private boolean requiresMfa(User user) {
        return user != null
                && Integer.valueOf(1).equals(user.getMfaAllow())
                && Integer.valueOf(1).equals(user.getMfaEnabled())
                && !Cools.isEmpty(user.getMfaSecret());
    }
    private boolean hasPasskeyBound(User user) {
        return user != null
                && !Cools.isEmpty(user.getPasskeyCredentialId())
                && !Cools.isEmpty(user.getPasskeyPublicKey());
    }
    private Map<String, Object> buildSafeUserDetail(User user) {
        Map<String, Object> result = new HashMap<>();
        result.put("id", user.getId());
        result.put("roleName", user.getRoleName());
        result.put("username", user.getUsername());
        result.put("mobile", user.getMobile());
        result.put("createTime$", user.getCreateTime$());
        result.put("mfaAllow", user.getMfaAllow());
        result.put("mfaAllow$", user.getMfaAllow$());
        result.put("mfaEnabled", user.getMfaEnabled());
        result.put("mfaEnabled$", user.getMfaEnabled$());
        result.put("mfaBoundTime$", user.getMfaBoundTime$());
        result.put("mfaMaskedSecret", MfaTotpUtil.maskSecret(user.getMfaSecret()));
        result.put("passkeyBound", hasPasskeyBound(user));
        result.put("passkeyName", user.getPasskeyName());
        result.put("passkeyBoundTime$", user.getPasskeyBoundTime$());
        result.put("passkeyLastUsedTime$", user.getPasskeyLastUsedTime$());
        result.put("passkeyTransports", user.getPasskeyTransports());
        return result;
    }
    private Map<String, Object> buildPasskeyRegistrationOptions(PasskeyChallengeManager.ChallengeState state, User user) {
        Map<String, Object> data = new HashMap<>();
        data.put("ticket", state.getTicket());
        data.put("challenge", state.getChallenge());
        data.put("rpId", state.getRpId());
        data.put("rpName", "WCS");
        data.put("userId", Base64.getUrlEncoder().withoutPadding().encodeToString(PasskeyWebAuthnUtil.buildUserHandle(user.getId())));
        data.put("userName", resolvePasskeyUserName(user));
        data.put("userDisplayName", resolvePasskeyDisplayName(user));
        data.put("timeout", 60000);
        data.put("attestation", "none");
        data.put("pubKeyCredParams", buildPasskeyAlgorithms());
        Map<String, Object> authenticatorSelection = new HashMap<>();
        authenticatorSelection.put("residentKey", "preferred");
        authenticatorSelection.put("userVerification", "required");
        data.put("authenticatorSelection", authenticatorSelection);
        data.put("excludeCredentials", Collections.emptyList());
        return data;
    }
    private Map<String, Object> buildPasskeyAuthenticationOptions(PasskeyChallengeManager.ChallengeState state, User user) {
        Map<String, Object> data = new HashMap<>();
        data.put("ticket", state.getTicket());
        data.put("challenge", state.getChallenge());
        data.put("rpId", state.getRpId());
        data.put("timeout", 60000);
        data.put("userVerification", "required");
        if (user == null) {
            data.put("allowCredentials", Collections.emptyList());
        } else {
            data.put("allowCredentials", Collections.singletonList(buildPasskeyDescriptor(user.getPasskeyCredentialId(), user.getPasskeyTransports())));
        }
        return data;
    }
    private List<Map<String, Object>> buildPasskeyAlgorithms() {
        List<Map<String, Object>> result = new ArrayList<>();
        result.add(buildPasskeyAlgorithm(-7));
        result.add(buildPasskeyAlgorithm(-257));
        result.add(buildPasskeyAlgorithm(-8));
        return result;
    }
    private Map<String, Object> buildPasskeyAlgorithm(int alg) {
        Map<String, Object> item = new HashMap<>();
        item.put("type", "public-key");
        item.put("alg", alg);
        return item;
    }
    private Map<String, Object> buildPasskeyDescriptor(String credentialId, String transports) {
        Map<String, Object> item = new HashMap<>();
        item.put("type", "public-key");
        item.put("id", credentialId);
        item.put("transports", parsePasskeyTransports(transports));
        return item;
    }
    private String renderQrCodeDataUri(String content) {
        try {
            BufferedImage image = QrCode.createImg(content, 220);
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            ImageIO.write(image, "jpg", outputStream);
            return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(outputStream.toByteArray());
        } catch (Exception e) {
            return "";
        }
    }
    private String normalizeSecret(String secret) {
        if (Cools.isEmpty(secret)) {
            return "";
        }
        return String.valueOf(secret)
                .trim()
                .replace(" ", "")
                .replace("-", "")
                .toUpperCase(Locale.ROOT);
    }
    private String resolvePasskeyUserName(User user) {
        if (!Cools.isEmpty(user.getMobile())) {
            return user.getMobile();
        }
        if (!Cools.isEmpty(user.getUsername())) {
            return user.getUsername();
        }
        return String.valueOf(user.getId());
    }
    private String resolvePasskeyDisplayName(User user) {
        if (!Cools.isEmpty(user.getUsername())) {
            return user.getUsername();
        }
        return resolvePasskeyUserName(user);
    }
    private String normalizePasskeyName(String name) {
        String value = Cools.isEmpty(name) ? "" : String.valueOf(name).trim();
        if (value.length() > 100) {
            value = value.substring(0, 100);
        }
        if (!Cools.isEmpty(value)) {
            return value;
        }
        return "通行密钥-" + new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
    }
    private String normalizePasskeyTransports(String transports) {
        List<String> values = parsePasskeyTransports(transports);
        return values.isEmpty() ? null : JSON.toJSONString(values);
    }
    private List<String> parsePasskeyTransports(String transports) {
        if (Cools.isEmpty(transports)) {
            return Collections.emptyList();
        }
        try {
            List<String> values = JSON.parseArray(transports, String.class);
            return values == null ? Collections.emptyList() : values;
        } catch (Exception ignored) {
        }
        List<String> result = new ArrayList<>();
        for (String value : String.valueOf(transports).split(",")) {
            String item = value == null ? "" : value.trim();
            if (!item.isEmpty()) {
                result.add(item);
            }
        }
        return result;
    }
    private String localizeResourceName(Resource resource) {
        return i18nMessageService.resolveResourceText(resource.getName(), resource.getCode(), resource.getId());
    }
src/main/java/com/zy/system/config/UserMfaSchemaInitializer.java
New file
@@ -0,0 +1,52 @@
package com.zy.system.config;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.Statement;
@Component
public class UserMfaSchemaInitializer {
    private final DataSource dataSource;
    public UserMfaSchemaInitializer(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    @PostConstruct
    public void init() {
        ensureColumn("sys_user", "mfa_allow", "INT DEFAULT 0");
        ensureColumn("sys_user", "mfa_enabled", "INT DEFAULT 0");
        ensureColumn("sys_user", "mfa_secret", "VARCHAR(128)");
        ensureColumn("sys_user", "mfa_bound_time", "DATETIME NULL");
    }
    private void ensureColumn(String tableName, String columnName, String columnDefinition) {
        try (Connection connection = dataSource.getConnection()) {
            if (hasColumn(connection, tableName, columnName)) {
                return;
            }
            try (Statement statement = connection.createStatement()) {
                statement.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnDefinition);
            }
        } catch (Exception ignored) {
        }
    }
    private boolean hasColumn(Connection connection, String tableName, String columnName) throws Exception {
        DatabaseMetaData metaData = connection.getMetaData();
        try (ResultSet resultSet = metaData.getColumns(connection.getCatalog(), null, tableName, null)) {
            while (resultSet.next()) {
                if (columnName.equalsIgnoreCase(resultSet.getString("COLUMN_NAME"))) {
                    return true;
                }
            }
        }
        return false;
    }
}
src/main/java/com/zy/system/config/UserPasskeySchemaInitializer.java
New file
@@ -0,0 +1,56 @@
package com.zy.system.config;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.Statement;
@Component
public class UserPasskeySchemaInitializer {
    private final DataSource dataSource;
    public UserPasskeySchemaInitializer(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    @PostConstruct
    public void init() {
        ensureColumn("sys_user", "passkey_name", "VARCHAR(100)");
        ensureColumn("sys_user", "passkey_credential_id", "VARCHAR(255)");
        ensureColumn("sys_user", "passkey_public_key", "TEXT");
        ensureColumn("sys_user", "passkey_algorithm", "INT");
        ensureColumn("sys_user", "passkey_sign_count", "BIGINT DEFAULT 0");
        ensureColumn("sys_user", "passkey_transports", "VARCHAR(255)");
        ensureColumn("sys_user", "passkey_bound_time", "DATETIME NULL");
        ensureColumn("sys_user", "passkey_last_used_time", "DATETIME NULL");
    }
    private void ensureColumn(String tableName, String columnName, String columnDefinition) {
        try (Connection connection = dataSource.getConnection()) {
            if (hasColumn(connection, tableName, columnName)) {
                return;
            }
            try (Statement statement = connection.createStatement()) {
                statement.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnDefinition);
            }
        } catch (Exception ignored) {
        }
    }
    private boolean hasColumn(Connection connection, String tableName, String columnName) throws Exception {
        DatabaseMetaData metaData = connection.getMetaData();
        try (ResultSet resultSet = metaData.getColumns(connection.getCatalog(), null, tableName, null)) {
            while (resultSet.next()) {
                if (columnName.equalsIgnoreCase(resultSet.getString("COLUMN_NAME"))) {
                    return true;
                }
            }
        }
        return false;
    }
}
src/main/java/com/zy/system/controller/UserController.java
@@ -2,15 +2,19 @@
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.core.annotations.ManagerAuth;
import com.core.common.Cools;
import com.core.common.DateUtils;
import com.core.common.R;
import com.zy.common.i18n.I18nMessageService;
import com.zy.common.web.BaseController;
import com.zy.system.entity.Role;
import com.zy.system.entity.User;
import com.zy.system.entity.UserLogin;
import com.zy.system.service.RoleService;
import com.zy.system.service.UserLoginService;
import com.zy.system.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -24,11 +28,17 @@
    private UserService userService;
    @Autowired
    private RoleService roleService;
    @Autowired
    private UserLoginService userLoginService;
    @Autowired
    private I18nMessageService i18nMessageService;
    @RequestMapping(value = "/user/{id}/auth")
    @ManagerAuth
    public R get(@PathVariable("id") Long id) {
        return R.ok(userService.getById(String.valueOf(id)));
        User user = userService.getById(String.valueOf(id));
        sanitizeUser(user);
        return R.ok(user);
    }
    @RequestMapping(value = "/user/list/auth")
@@ -42,8 +52,11 @@
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        convert(param, wrapper);
        wrapper.orderBy(true, false, "id");
        Page<User> page;
        if (9527 == getUserId()) {
            return R.ok(userService.page(new Page<>(curr, limit), wrapper));
            page = userService.page(new Page<>(curr, limit), wrapper);
            sanitizeUsers(page.getRecords());
            return R.ok(page);
        }
        Long roleId = getUser().getRoleId();
@@ -60,7 +73,9 @@
            wrapper.notIn("role_id", leaderIds);
        }
        return R.ok(userService.page(new Page<>(curr, limit), wrapper));
        page = userService.page(new Page<>(curr, limit), wrapper);
        sanitizeUsers(page.getRecords());
        return R.ok(page);
    }
    private void convert(Map<String, Object> map, QueryWrapper wrapper){
@@ -82,16 +97,17 @@
            return R.error();
        }
        if (null == user.getId()){
            normalizeNewUser(user);
            userService.save(user);
        } else {
            userService.updateById(user);
            return R.ok();
        }
        return R.ok();
        return update(user);
    }
    @RequestMapping(value = "/user/add/auth")
    @ManagerAuth(memo = "系统用户添加")
    public R add(User user) {
        normalizeNewUser(user);
        userService.save(user);
        return R.ok();
    }
@@ -103,19 +119,83 @@
            return R.error();
        }
        User entity = userService.getById(user.getId());
        if (Cools.isEmpty(entity)) {
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        UpdateWrapper<User> wrapper = new UpdateWrapper<>();
        wrapper.eq("id", entity.getId());
        boolean needUpdate = false;
        if (user.getPassword()!=null) {
            entity.setPassword(user.getPassword());
            wrapper.set("password", user.getPassword());
            needUpdate = true;
        }
        if (user.getUsername()!=null) {
            entity.setUsername(user.getUsername());
            wrapper.set("username", user.getUsername());
            needUpdate = true;
        }
        if (user.getMobile()!=null) {
            entity.setMobile(user.getMobile());
            wrapper.set("mobile", user.getMobile());
            needUpdate = true;
        }
        if (user.getRoleId() !=null) {
            entity.setRoleId(user.getRoleId());
            wrapper.set("role_id", user.getRoleId());
            needUpdate = true;
        }
        userService.updateById(entity);
        if (user.getStatus() != null) {
            wrapper.set("status", user.getStatus());
            needUpdate = true;
        }
        if (user.getMfaAllow() != null) {
            int mfaAllow = normalizeMfaAllow(user.getMfaAllow());
            wrapper.set("mfa_allow", mfaAllow);
            if (mfaAllow != 1) {
                wrapper.set("mfa_enabled", 0);
                wrapper.set("mfa_secret", null);
                wrapper.set("mfa_bound_time", null);
            }
            needUpdate = true;
        }
        if (!needUpdate) {
            return R.ok();
        }
        userService.update(wrapper);
        return R.ok();
    }
    @RequestMapping(value = "/user/password/update/auth")
    @ManagerAuth(memo = "系统用户修改密码")
    public R updatePassword(String oldPassword, String password) {
        if (Cools.isEmpty(oldPassword, password)) {
            return R.error();
        }
        User user = userService.getById(getUserId());
        if (Cools.isEmpty(user)) {
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        if (!Cools.eq(user.getPassword(), oldPassword)) {
            return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch"));
        }
        userService.update(new UpdateWrapper<User>()
                .eq("id", user.getId())
                .set("password", password));
        userLoginService.remove(new QueryWrapper<UserLogin>().eq("user_id", user.getId()).eq("system_type", "WCS"));
        return R.ok();
    }
    @RequestMapping(value = "/user/resetPassword/auth")
    @ManagerAuth(memo = "系统用户重置密码")
    public R resetPassword(Long id, String password) {
        if (id == null || Cools.isEmpty(password)) {
            return R.error();
        }
        User user = userService.getById(id);
        if (Cools.isEmpty(user)) {
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        userService.update(new UpdateWrapper<User>()
                .eq("id", id)
                .set("password", password));
        userLoginService.remove(new QueryWrapper<UserLogin>().eq("user_id", id).eq("system_type", "WCS"));
        return R.ok();
    }
@@ -155,4 +235,45 @@
        return R.ok(result);
    }
    private void normalizeNewUser(User user) {
        if (Cools.isEmpty(user)) {
            return;
        }
        int mfaAllow = normalizeMfaAllow(user.getMfaAllow());
        user.setMfaAllow(mfaAllow);
        if (mfaAllow != 1) {
            user.setMfaEnabled(0);
            user.setMfaSecret(null);
            user.setMfaBoundTime(null);
        } else if (user.getMfaEnabled() == null) {
            user.setMfaEnabled(0);
        }
    }
    private int normalizeMfaAllow(Integer mfaAllow) {
        return Integer.valueOf(1).equals(mfaAllow) ? 1 : 0;
    }
    private void sanitizeUsers(List<User> users) {
        if (users == null) {
            return;
        }
        for (User user : users) {
            sanitizeUser(user);
        }
    }
    private void sanitizeUser(User user) {
        if (user == null) {
            return;
        }
        user.setPassword(null);
        user.setMfaSecret(null);
        user.setPasskeyCredentialId(null);
        user.setPasskeyPublicKey(null);
        user.setPasskeyAlgorithm(null);
        user.setPasskeySignCount(null);
        user.setPasskeyTransports(null);
    }
}
src/main/java/com/zy/system/entity/User.java
@@ -49,6 +49,81 @@
    private String password;
    /**
     * 是否允许使用 MFA
     */
    @TableField("mfa_allow")
    private Integer mfaAllow;
    /**
     * 是否已启用 MFA
     */
    @TableField("mfa_enabled")
    private Integer mfaEnabled;
    /**
     * MFA 密钥
     */
    @TableField(value = "mfa_secret", select = false)
    private String mfaSecret;
    /**
     * MFA 绑定时间
     */
    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
    @TableField("mfa_bound_time")
    private Date mfaBoundTime;
    /**
     * 通行密钥名称
     */
    @TableField("passkey_name")
    private String passkeyName;
    /**
     * 通行密钥凭证ID
     */
    @TableField(value = "passkey_credential_id", select = false)
    private String passkeyCredentialId;
    /**
     * 通行密钥公钥(SPKI Base64Url)
     */
    @TableField(value = "passkey_public_key", select = false)
    private String passkeyPublicKey;
    /**
     * 通行密钥算法
     */
    @TableField(value = "passkey_algorithm", select = false)
    private Integer passkeyAlgorithm;
    /**
     * 通行密钥签名计数器
     */
    @TableField(value = "passkey_sign_count", select = false)
    private Long passkeySignCount;
    /**
     * 通行密钥传输方式
     */
    @TableField(value = "passkey_transports", select = false)
    private String passkeyTransports;
    /**
     * 通行密钥绑定时间
     */
    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
    @TableField("passkey_bound_time")
    private Date passkeyBoundTime;
    /**
     * 通行密钥最近使用时间
     */
    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
    @TableField("passkey_last_used_time")
    private Date passkeyLastUsedTime;
    /**
     * 角色
     */
    @TableField("role_id")
@@ -115,6 +190,137 @@
        this.password = password;
    }
    public Integer getMfaAllow() {
        return mfaAllow;
    }
    public String getMfaAllow$() {
        if (null == this.mfaAllow) {
            return null;
        }
        return Integer.valueOf(1).equals(this.mfaAllow) ? "是" : "否";
    }
    public void setMfaAllow(Integer mfaAllow) {
        this.mfaAllow = mfaAllow;
    }
    public Integer getMfaEnabled() {
        return mfaEnabled;
    }
    public String getMfaEnabled$() {
        if (null == this.mfaEnabled) {
            return null;
        }
        return Integer.valueOf(1).equals(this.mfaEnabled) ? "是" : "否";
    }
    public void setMfaEnabled(Integer mfaEnabled) {
        this.mfaEnabled = mfaEnabled;
    }
    public String getMfaSecret() {
        return mfaSecret;
    }
    public void setMfaSecret(String mfaSecret) {
        this.mfaSecret = mfaSecret;
    }
    public Date getMfaBoundTime() {
        return mfaBoundTime;
    }
    public String getMfaBoundTime$() {
        if (Cools.isEmpty(this.mfaBoundTime)) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.mfaBoundTime);
    }
    public void setMfaBoundTime(Date mfaBoundTime) {
        this.mfaBoundTime = mfaBoundTime;
    }
    public String getPasskeyName() {
        return passkeyName;
    }
    public void setPasskeyName(String passkeyName) {
        this.passkeyName = passkeyName;
    }
    public String getPasskeyCredentialId() {
        return passkeyCredentialId;
    }
    public void setPasskeyCredentialId(String passkeyCredentialId) {
        this.passkeyCredentialId = passkeyCredentialId;
    }
    public String getPasskeyPublicKey() {
        return passkeyPublicKey;
    }
    public void setPasskeyPublicKey(String passkeyPublicKey) {
        this.passkeyPublicKey = passkeyPublicKey;
    }
    public Integer getPasskeyAlgorithm() {
        return passkeyAlgorithm;
    }
    public void setPasskeyAlgorithm(Integer passkeyAlgorithm) {
        this.passkeyAlgorithm = passkeyAlgorithm;
    }
    public Long getPasskeySignCount() {
        return passkeySignCount;
    }
    public void setPasskeySignCount(Long passkeySignCount) {
        this.passkeySignCount = passkeySignCount;
    }
    public String getPasskeyTransports() {
        return passkeyTransports;
    }
    public void setPasskeyTransports(String passkeyTransports) {
        this.passkeyTransports = passkeyTransports;
    }
    public Date getPasskeyBoundTime() {
        return passkeyBoundTime;
    }
    public String getPasskeyBoundTime$() {
        if (Cools.isEmpty(this.passkeyBoundTime)) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.passkeyBoundTime);
    }
    public void setPasskeyBoundTime(Date passkeyBoundTime) {
        this.passkeyBoundTime = passkeyBoundTime;
    }
    public Date getPasskeyLastUsedTime() {
        return passkeyLastUsedTime;
    }
    public String getPasskeyLastUsedTime$() {
        if (Cools.isEmpty(this.passkeyLastUsedTime)) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.passkeyLastUsedTime);
    }
    public void setPasskeyLastUsedTime(Date passkeyLastUsedTime) {
        this.passkeyLastUsedTime = passkeyLastUsedTime;
    }
    public Long getRoleId() {
        return roleId;
    }
src/main/java/com/zy/system/mapper/UserMapper.java
@@ -3,10 +3,16 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zy.system.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface UserMapper extends BaseMapper<User> {
    User selectByMobileWithSecurity(@Param("mobile") String mobile);
    User selectByIdWithSecurity(@Param("id") Long id);
    User selectByPasskeyCredentialId(@Param("credentialId") String credentialId);
}
src/main/java/com/zy/system/service/UserService.java
@@ -5,4 +5,9 @@
public interface UserService extends IService<User> {
    User getByMobileWithSecurity(String mobile);
    User getByIdWithSecurity(Long id);
    User getByPasskeyCredentialId(String credentialId);
}
src/main/java/com/zy/system/service/impl/UserServiceImpl.java
@@ -9,4 +9,18 @@
@Service("userService")
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    @Override
    public User getByMobileWithSecurity(String mobile) {
        return baseMapper.selectByMobileWithSecurity(mobile);
    }
    @Override
    public User getByIdWithSecurity(Long id) {
        return baseMapper.selectByIdWithSecurity(id);
    }
    @Override
    public User getByPasskeyCredentialId(String credentialId) {
        return baseMapper.selectByPasskeyCredentialId(credentialId);
    }
}
src/main/resources/i18n/en-US/legacy.properties
@@ -181,8 +181,8 @@
不可修改=Not Editable
重要!一般用于后台登入=Important: usually used for admin login
当前角色不可更改为其它角色=Current role cannot be changed
手机号:=Phone:
用户名:=Username:
手机号\:=Phone:
用户名\:=Username:
输入手机号=Enter phone number
输入用户名=Enter username
重置密码=Reset Password
@@ -525,8 +525,8 @@
ConveyorCommand分段长度=Conveyor Command Segment Length
站点点最大任务数量上限=Max Tasks Per Station
Stations点最大Task数量上限=Max Tasks Per Station
冷却到: -=Cooldown Until: -
最近错误: -=Latest Error: -
冷却到\:-=Cooldown Until: -
最近错误\:-=Latest Error: -
故障切换开启=Failover Enabled
Failover开启=Failover Enabled
额度切换开启=Quota Switch Enabled
@@ -539,10 +539,10 @@
Success 0 / Failed 0 / 连续Failed 0=Success 0 / Failed 0 / Consecutive Failures 0
Success 7 / Failed 2 / 连续Failed 0=Success 7 / Failed 2 / Consecutive Failures 0
Success 8 / Failed 0 / 连续Failed 0=Success 8 / Failed 0 / Consecutive Failures 0
必填,例如: https://dashscope.aliyuncs.com/compatible-mode/v1=Required, for example: https://dashscope.aliyuncs.com/compatible-mode/v1
Required,例如: https://dashscope.aliyuncs.com/compatible-mode/v1=Required, for example: https://dashscope.aliyuncs.com/compatible-mode/v1
最近错误:=Latest Error:
冷却到:=Cooldown Until:
必填,例如\: https\://dashscope.aliyuncs.com/compatible-mode/v1=Required, for example: https://dashscope.aliyuncs.com/compatible-mode/v1
Required,例如\: https\://dashscope.aliyuncs.com/compatible-mode/v1=Required, for example: https://dashscope.aliyuncs.com/compatible-mode/v1
最近错误\:=Latest Error:
冷却到\:=Cooldown Until:
D.空桶/空栈板=D. Empty Tote/Pallet
E.出入专用轨道=E. Dedicated IO Rail
W.穿梭车母轨道=W. Shuttle Main Rail
@@ -705,3 +705,357 @@
阿里百炼-kimi-k2.5=Alibaba Bailian - kimi-k2.5
阿里百炼-glm-5=Alibaba Bailian - glm-5
硅基流动=SiliconFlow
提示=Prompt
尝试=Attempt
错误=Error
空闲=Idle
无物=Unloaded
工作类型=Work Type
能出=Outbound Enabled
能入=Inbound Enabled
文件数=File Count
类型\:=Type:
设备编号\:=Device No.:
文件数\:=File Count:
日志详情 -=Log Details -
日志可视化 -=Device Logs -
已删除=Deleted
老项目仍可继续使用这份硬件信息 JSON 申请许可证。=Legacy projects can still use this hardware JSON to apply for a license.
请求码中已包含项目名称,直接发给许可证服务端即可。=The request code already contains the project name and can be sent directly to the license service.
暂无路由配置=No route configuration available
点击右上角“新增路由”创建第一条配置=Click "Add Route" in the upper-right corner to create the first route.
保存成功=Saved successfully
保存失败=Save failed
测试成功=Test succeeded
测试失败=Test failed
测试中...=Testing...
导出成功=Export succeeded
导出失败=Export failed
导入确认=Import Confirmation
导入失败=Import failed
覆盖导入=Overwrite Import
合并导入=Merge Import
读取文件失败=Failed to read file
JSON 格式不正确=Invalid JSON format
未找到可导入的 routes=No importable routes found
请选择导入方式:覆盖导入会先清空现有路由;点击“合并导入”则按ID更新或新增。=Choose an import mode: overwrite import clears existing routes first; merge import updates by ID or adds new routes.
当前是未保存配置,测试通过后仍需先保存才会生效=This configuration is not saved yet. It still needs to be saved after a successful test to take effect.
复制失败,请手动复制=Copy failed. Please copy it manually.
没有可复制内容=Nothing to copy
已复制=Copied
API Key 为空=API key is empty
API Key 已复制=API key copied
日志加载失败=Failed to load logs
删除成功=Deleted successfully
删除失败=Delete failed
已清除冷却=Cooldown cleared
已清空=Cleared
清空日志=Clear Logs
清空失败=Clear failed
确定清空全部LLM调用日志吗?=Are you sure you want to clear all LLM call logs?
确定删除该路由吗?=Are you sure you want to delete this route?
确定删除该日志吗?=Are you sure you want to delete this log?
导入完成:新增=Import completed: added
导入异常明细(最多20条)=Import exception details (up to 20 items)
状态码=Status Code
场景=Scene
模型=Model
路由=Route
结果=Result
耗时(ms)=Latency (ms)
耗时\:=Latency:
结果\:=Result:
模型\:=Model:
路由\:=Route:
场景\:=Scene:
时间\:=Time:
状态码\:=Status Code:
请求\:=Request:
响应\:=Response:
返回片段\:=Response Snippet:
最近错误\:=Latest Error:
冷却到\:=Cooldown Until:
regex\:^成功\s*(\d+)\s*\/\s*失败\s*(\d+)\s*\/\s*连续失败\s*(\d+)$=Success $1 / Failed $2 / Consecutive Failures $3
regex\:^时间\:\s*(.+)$=Time: $1
regex\:^状态码\:\s*(.+)$=Status Code: $1
regex\:^耗时\:\s*(.+)$=Latency: $1
regex\:^结果\:\s*(.+)$=Result: $1
regex\:^路由\:\s*(.+)$=Route: $1
regex\:^模型\:\s*(.+)$=Model: $1
regex\:^返回片段\:\s*(.+)$=Response Snippet: $1
regex\:^请求\:\s*(.+)$=Request: $1
regex\:^响应\:\s*(.+)$=Response: $1
regex\:^最近错误\:\s*(.+)$=Latest Error: $1
regex\:^冷却到\:\s*(.+)$=Cooldown Until: $1
WCS AI 助手=WCS AI Assistant
AI 深度思考=AI Deep Thinking
系统巡检、异常问答、历史会话=System inspection, anomaly Q&A, and session history
连接中=Connecting
一键巡检=One-click Inspection
巡检当前系统=Inspect Current System
新会话=New Session
选择历史会话=Select Session History
向 AI 助手提问=Ask the AI Assistant
支持连续追问、历史会话切换,以及 AI 思考过程折叠展示。=Supports follow-up questions, session switching, and collapsible AI reasoning.
未绑定历史会话=No history session bound
新建会话,等待首条消息=Create a new session and wait for the first message
输入问题,或先执行一次巡检=Enter a question, or run an inspection first
AI 正在生成回复...=AI is generating a response...
AI 助手=AI Assistant
用户=User
WCS 诊断回复=WCS Diagnostic Reply
问题输入=Question Input
定位堆垛机异常=Locate Crane Anomalies
分析堵塞与积压=Analyze Blockages and Backlogs
追问最近告警=Ask About Recent Alarms
让 AI 主动梳理设备、任务和日志,给出一轮完整巡检。=Let AI review devices, tasks, and logs proactively and provide a full inspection.
让 AI 优先关注工位堵塞、任务堆积和节拍异常。=Let AI focus on station blockages, task backlogs, and rhythm anomalies first.
把最近异常事件压缩成可执行排查建议。=Condense recent anomalies into actionable troubleshooting steps.
帮我定位当前堆垛机相关的异常风险,按可能性从高到低列出。=Identify current crane-related risks and list them by likelihood.
帮我总结最近最值得关注的异常,并给出下一步排查动作。=Summarize the most important recent anomalies and suggest next troubleshooting steps.
结合近期日志与任务状态,判断是否存在堆垛机链路异常。=Determine whether there is any crane workflow anomaly based on recent logs and task status.
例如:最近哪个设备最值得优先排查?异常是否和堆垛机任务、工位堵塞或日志波动有关?=For example: which device should be checked first? Are the anomalies related to crane tasks, station blockage, or log fluctuation?
请重点分析当前是否存在工位堵塞、任务积压或节拍异常。=Please focus on whether there are station blockages, task backlogs, or rhythm anomalies.
删除会话失败=Failed to delete session
加载 AI 会话列表失败=Failed to load AI session list
加载会话历史失败=Failed to load session history
诊断中=Diagnosing
未命名会话=Untitled Session
最近更新=Updated Recently
刚刚创建=Just created
刚刚更新=Just updated
会话已绑定=Session Bound
临时会话=Temporary Session
Enter 发送,Shift+Enter 换行=Press Enter to send, Shift+Enter for a new line
regex\:^(\d+)\s*个会话$=$1 sessions
regex\:^(\d+)\s*分钟前$=$1 minute(s) ago
regex\:^(\d+)\s*小时前$=$1 hour(s) ago
补发结果=Retry Result
补发失败=Retry failed
当前筛选条件下没有通知日志=No notification logs match the current filters
获取通知队列失败=Failed to load notification queue
获取通知概览失败=Failed to load notification overview
获取通知日志失败=Failed to load notification logs
请求失败=Request failed
请选择要补发的队列通知=Please select queue notifications to retry
请选择要补发的通知日志=Please select notification logs to retry
确定按该日志重新补发通知吗?=Retry this notification based on the selected log?
确定补发该队列通知吗?=Retry this queued notification?
确定批量补发选中的队列通知吗?=Retry the selected queued notifications in batch?
确定批量补发选中的通知日志吗?=Retry the selected notification logs in batch?
确定执行手动补发吗?=Are you sure you want to retry manually?
上级任务号=Parent Task No.
接口响应=API Response
通知报文=Notification Payload
待发送=Pending
发送日志=Send Logs
regex\:^已选\s*(\d+)\s*条$=Selected $1 item(s)
regex\:^当前页签:\s*(.+)$=Current Tab: $1
调色盘=Palette
恢复默认=Restore Default
保存后,新打开的监控地图会直接读取 Redis 配置;已打开页面刷新后即可生效。=After saving, newly opened monitoring maps will read the Redis configuration directly. Refresh already opened pages to apply it.
regex\:^默认值:\s*(.+)$=Default: $1
操作区域=Action Area
regex\:^层:\s*(.+)$=Level: $1
regex\:^排:\s*(.+)$=Row: $1
regex\:^列:\s*(.+)$=Bay: $1
regex\:^库位号:\s*(.+)$=Location No.: $1
regex\:^库位状态:\s*(.+)$=Location Status: $1
regex\:^站点:\s*(.+)$=Station: $1
regex\:^工作号:\s*(.+)$=Work No.: $1
regex\:^工作类型:\s*(.+)$=Work Type: $1
regex\:^工作状态:\s*(.+)$=Work Status: $1
regex\:^源站:\s*(.+)$=Source Station: $1
regex\:^目标站:\s*(.+)$=Target Station: $1
regex\:^源库位:\s*(.+)$=Source Location: $1
regex\:^目标库位:\s*(.+)$=Target Location: $1
regex\:^自动:\s*(.+)$=Auto: $1
regex\:^有物:\s*(.+)$=Loaded: $1
regex\:^能入:\s*(.+)$=Inbound Enabled: $1
regex\:^能出:\s*(.+)$=Outbound Enabled: $1
地图加载失败=Failed to load map
楼层信息加载失败=Failed to load floor information
站点详情加载失败=Failed to load station details
双工位=Dual Station
regex\:^设备\s*(.+)$=Device $1
regex\:^区域\s*(.+)$=Area $1
regex\:^出库站点\s*\((\d+)\)$=Outbound Stations ($1)
共=Total
跳转时间=Jump Time
选择时间=Select Time
压缩生成进度=Compression Progress
下载接收进度=Download Receive Progress
日期选择=Date Selection
选中日期=Selected Date
设备类型=Device Type
起始序号=Start Offset
最大文件=Max Files
设备列表=Device List
暂无数据,请先选择日期=No data. Please select a date first.
播放=Play
暂停=Pause
重置=Reset
跳转=Jump
倍速=Speed
regex\:^共\s*(\d+)\s*个设备$=Total $1 devices
regex\:^下载\((.+)\)$=Download ($1)
regex\:^可视化\((.+)\)$=Visualize ($1)
regex\:^未知设备类型\:\s*(.+)$=Unknown device type: $1
不在定位=Off Position
在定位=At Position
初始化失败=Initialization failed
加载日期失败=Failed to load dates
加载设备失败=Failed to load devices
加载数据中...=Loading data...
没有找到日志数据=No log data found
目标时间超出日志范围,已跳转至结束时间=Target time exceeds the log range. Moved to the end time.
请输入设备编号=Please enter the device number
请选择设备类型=Please select a device type
数据已全部加载=All data loaded
无任务=No Task
下载失败或未找到日志=Download failed or logs not found
已到达日志末尾,无法到达目标时间=Reached the end of logs and cannot jump to the target time
已跳转至目标时间=Jumped to the target time
正在跳转至目标时间 (加载中)...=Jumping to the target time (loading)...
保存请求异常=Save request exception
加载请求异常=Load request exception
regex\:^保存失败\:\s*(.+)$=Save failed: $1
regex\:^加载数据失败\:\s*(.+)$=Failed to load data: $1
该关联已存在=This mapping already exists
确定删除该关联吗?=Are you sure you want to delete this mapping?
regex\:^已建立关联\:\s*站点\s*(.+)$=Mapping created: Station $1
该绑定已存在=This binding already exists
请输入区域编码和名称=Please enter the area code and name
区域已存在=Area already exists
regex\:^已建立绑定\:\s*站点\s*(.+)$=Binding created: Station $1
保存站点颜色配置失败=Failed to save station color configuration
加载站点颜色配置失败=Failed to load station color configuration
颜色格式已自动修正为十六进制=Color format has been normalized to hexadecimal automatically
已恢复默认颜色=Default colors restored
站点颜色配置已保存=Station color configuration saved
登录失败=Login failed
确定执行一键激活吗?=Are you sure you want to activate now?
系统登录=System Login
系统配置信息=System Configuration
对接WMS、设备与业务规则=Integrates WMS, devices, and business rules
统一编排现场执行任务=Unified orchestration of on-site execution tasks
请输入账号和密码进入系统。=Please enter your account and password to access the system.
将许可证服务端返回的 license 字段完整粘贴到这里。=Paste the full license field returned by the license service here.
许可证 Base64=License Base64
自动化立体仓库与智能物流系统解决方案=Automated AS/RS and intelligent logistics system solutions
作业、设备、日志全链路留痕=End-to-end traceability across jobs, devices, and logs
WCS系统让设备调度、任务执行与现场监控保持在同一套业务链路中。=The WCS keeps device scheduling, task execution, and on-site monitoring within the same business workflow.
角色管理=Role Management
权限管理=Permission Management
菜单列表=Menu List
菜单等级=Menu Level
父级菜单=Parent Menu
请输入编码=Please enter code
请输入名称=Please enter name
请选择上级=Please select parent
请输入菜单编码=Please enter menu code
请输入菜单名称=Please enter menu name
请输入排序=Please enter sort order
请选择类型=Please select type
请选择上级菜单=Please select parent menu
请选择状态=Please select status
请输入接口地址=Please enter API URL
请输入权限名称=Please enter permission name
请选择所属菜单=Please select menu
菜单查询失败=Menu query failed
菜单详情加载失败=Failed to load menu details
权限加载失败=Failed to load permissions
权限树加载失败=Failed to load permission tree
权限回显失败=Failed to load assigned permissions
权限保存失败=Failed to save permissions
角色查询失败=Role query failed
角色加载失败=Failed to load roles
确定删除选中角色吗?=Delete selected roles?
确定删除选中凭证吗?=Delete selected credentials?
# Playwright audit supplement
列设置=Column Settings
创建者=Creator
主要=Primary
编 号=ID
*编 号=*ID
异 常 码=Error Code
异 常=Error
状 态=Status
工 作 号=Work No.
备 注=Remarks
作 业=Operation
命 令=Command
目 标 站=Target Station
源 站=Source Station
源 站 点=Source Station
源 库 位=Source Location
条 码=Barcode
基准排=Base Row
基准排-code=Base Row Code
基准列=Base Bay
基准列-code=Base Bay Code
实 现 类=Implementation Class
日志ID=Log ID
平台密钥=Platform Key
Time戳=Timestamp
Exception内容=Exception Content
Menu列表=Menu List
展开All=Expand All
Permissions管理=Permission Management
凭证记录=Credential Records
标 识=Identifier
字 典 值=Dictionary Value
字典文本=Dictionary Text
是否Delete=Deleted
租 户=Tenant
账 号=Account
密 码=Password
昵 称=Nickname
头 像=Avatar
工 号=Employee No.
性 别=Gender
手 机 号=Mobile No.
邮 箱=Email
Email验证=Email Verified
所属部门=Department
真实姓名=Real Name
身份证号=ID Card No.
出生日期=Birth Date
个人简介=Biography
所属机构=Organization
*角 色=*Role
已设置Password=Password Set
Virtual Device初始化Status=Virtual Device Initialization Status
Please enter电梯中转点=Please enter the elevator transfer point
Role管理=Role Management
展开面板=Expand Panel
收起Actions=Collapse Actions
信息=Information
OKExportExcel吗=Export to Excel?
全选=Select All
至=to
regex\:^Success\\s*(\\d+)\\s*/\\s*Failed\\s*(\\d+)\\s*/\\s*连续Failed\\s*(\\d+)$=Success $1 / Failed $2 / Consecutive Failures $3
regex\:^(\\d{1,2})日$=Day $1
regex\:^\\*[\\s ]*编[\\s ]*号$=*ID
regex\:^编[\\s ]*号$=ID
regex\:^异[\\s ]*常$=Error
regex\:^状[\\s ]*态$=Status
regex\:^工[\\s ]*作[\\s ]*号$=Work No.
regex\:^备[\\s ]*注$=Remarks
regex\:^作[\\s ]*业$=Operation
regex\:^命[\\s ]*令$=Command
regex\:^目[\\s ]*标[\\s ]*站$=Target Station
regex\:^源[\\s ]*站[\\s ]*点$=Source Station
regex\:^源[\\s ]*站$=Source Station
regex\:^源[\\s ]*库[\\s ]*位$=Source Location
regex\:^条[\\s ]*码$=Barcode
regex\:^账[\\s ]*号$=Account
regex\:^密[\\s ]*码$=Password
regex\:^昵[\\s ]*称$=Nickname
regex\:^头[\\s ]*像$=Avatar
regex\:^工[\\s ]*号$=Employee No.
regex\:^性[\\s ]*别$=Gender
regex\:^邮[\\s ]*箱$=Email
regex\:^标[\\s ]*识$=Identifier
regex\:^租[\\s ]*户$=Tenant
regex\:^\\*[\\s ]*角[\\s ]*色$=*Role
src/main/resources/i18n/en-US/messages.properties
@@ -16,9 +16,24 @@
common.workPage=Work Page
common.businessPage=Business Page
login.title=WCS System V3.0
login.subtitle=Please enter your account and password to access the system.
login.hero.title=The WCS keeps device scheduling, task execution, and on-site monitoring within the same business workflow.
login.hero.subtitle=The Warehouse Control System is built for automated AS/RS execution, coordinating cranes, RGVs, conveyor stations, task commands, location status, and log tracing through unified dispatch and visualization. It helps warehouse operations stay stable, traceable, and collaborative. Zhejiang Zhongyang Warehouse Technology Co., Ltd. focuses on automated AS/RS and intelligent intralogistics systems, covering solution design, software control, equipment integration, and project delivery.
login.hero.metric.dispatch.title=Dispatch
login.hero.metric.dispatch.desc=Unified orchestration of on-site execution tasks
login.hero.metric.trace.title=Traceability
login.hero.metric.trace.desc=End-to-end traceability across jobs, devices, and logs
login.hero.metric.integration.title=Integration
login.hero.metric.integration.desc=Integrates WMS, devices, and business rules
login.hero.company.name=Zhejiang Zhongyang Warehouse Technology Co., Ltd.
login.hero.company.solution=Automated AS/RS and intelligent logistics system solutions
login.username=Account
login.password=Password
login.submit=Sign In
login.passkey.submit=Sign In with Passkey
login.passkey.tip=Use device biometrics or a security key to sign in. Entering the account narrows the credential scope; leaving it blank tries discoverable credentials.
login.passkey.browserUnsupported=This browser does not support passkeys. Use a recent Chrome, Edge, or Safari build
login.passkey.secureContext=Passkeys require HTTPS or localhost
login.tools.title=System Tools
login.tools.recommended=Recommended Actions
login.tools.recommendedDesc=Use "Get Request Code" and "Activate" first to complete license application and activation.
@@ -37,6 +52,37 @@
login.serverInfo.title=System Configuration
login.serverInfo.label=System Configuration
login.serverInfo.tip=Legacy projects can still use this hardware JSON to apply for a license.
login.dialog.close=Close
login.mfa.title=MFA Verification
login.mfa.tip=Account and password verified. Enter the 6-digit code from your authenticator app to continue signing in.
login.mfa.currentAccount=Current account:
login.mfa.codeLabel=Verification Code
login.mfa.codePlaceholder=Please enter the 6-digit code
login.mfa.cancel=Cancel
login.mfa.submit=Verify and Sign In
login.license.label=License Base64
login.license.tip=Paste the full license field returned by the license service here.
login.license.submit=Submit
login.license.success=License updated successfully
login.validation.usernameRequired=Please enter the account
login.validation.passwordRequired=Please enter the password
login.validation.mfaRequired=Please enter the 6-digit verification code
login.validation.mfaInvalid=Please enter a 6-digit numeric verification code
login.error.loginFailed=Login failed
login.error.passkeyOptionsFailed=Failed to load passkey sign-in options
login.error.passkeyVerifyFailed=Passkey verification failed
login.error.mfaTicketExpired=The login ticket has expired. Please sign in again
login.error.mfaFailed=Verification failed
login.error.requestCodeFailed=Failed to get request code
login.error.serverInfoFailed=Failed to get system configuration
login.error.licenseEmpty=License content cannot be empty
login.error.licenseUpdateFailed=Failed to update license
login.error.licenseImportFailed=Failed to import license
login.activate.confirm=Are you sure you want to activate now?
login.activate.success=Activation successful
login.activate.failed=Activation failed
login.projectName.title=Project Name
login.error.projectNameFailed=Failed to get project name
index.searchMenu=Search menu
index.noMatchedMenu=No matching menus
index.noAvailableMenu=No available menus for current account
@@ -59,6 +105,19 @@
response.user.notFound=Account does not exist
response.user.disabled=Account is disabled
response.user.passwordMismatch=Incorrect password
response.user.passkeySecureContextRequired=Passkeys require HTTPS or localhost
response.user.passkeyNotBound=This account has not bound a passkey yet
response.user.passkeyTicketExpired=The passkey login ticket has expired. Please try again
response.user.passkeyCounterMismatch=The passkey signature counter is invalid. Rebind the passkey and try again
response.user.passkeyVerifyFailed=Passkey verification failed
response.user.passkeyAlreadyBound=This account already has a passkey bound. Remove it before binding another one
response.user.passkeyCredentialExists=This passkey is already bound to another account
response.user.passkeyRegisterFailed=Failed to bind the passkey
response.user.oldPasswordMismatch=Current password is incorrect
response.user.mfaNotAllowed=MFA is not enabled for this account
response.user.mfaNotEnabled=MFA is not configured for this account
response.user.mfaCodeMismatch=Incorrect MFA verification code
response.user.mfaTicketExpired=The MFA login ticket has expired. Please sign in again
response.system.licenseExpired=License has expired
response.common.systemError=System error. Please try again later.
response.common.methodNotAllowed=Request method not supported
src/main/resources/i18n/zh-CN/messages.properties
@@ -16,9 +16,24 @@
common.workPage=工作页面
common.businessPage=业务页面
login.title=WCS系统V3.0
login.subtitle=请输入账号和密码进入系统。
login.hero.title=WCS系统让设备调度、任务执行与现场监控保持在同一套业务链路中。
login.hero.subtitle=Warehouse Control System 面向自动化立体仓库现场执行层,围绕堆垛机、RGV、输送站台、任务指令、库位状态和日志追踪进行统一调度与可视化管理,帮助仓储系统实现稳定、可追溯、可联动的作业控制。浙江中扬立库技术有限公司长期专注于自动化立体仓库与智能物流系统建设,覆盖方案设计、软件控制、设备集成与项目实施交付。
login.hero.metric.dispatch.title=调度
login.hero.metric.dispatch.desc=统一编排现场执行任务
login.hero.metric.trace.title=追溯
login.hero.metric.trace.desc=作业、设备、日志全链路留痕
login.hero.metric.integration.title=集成
login.hero.metric.integration.desc=对接WMS、设备与业务规则
login.hero.company.name=浙江中扬立库技术有限公司
login.hero.company.solution=自动化立体仓库与智能物流系统解决方案
login.username=账号
login.password=密码
login.submit=登录
login.passkey.submit=通行密钥登录
login.passkey.tip=支持使用设备生物识别或安全密钥登录。可先输入账号缩小凭证范围,留空则尝试发现式登录。
login.passkey.browserUnsupported=当前浏览器不支持通行密钥,请使用最新版 Chrome、Edge 或 Safari
login.passkey.secureContext=通行密钥仅支持在 HTTPS 或 localhost 环境下使用
login.tools.title=系统工具
login.tools.recommended=推荐操作
login.tools.recommendedDesc=优先使用“获取请求码”和“一键激活”完成许可证申请与激活。
@@ -37,6 +52,37 @@
login.serverInfo.title=获取系统配置
login.serverInfo.label=系统配置信息
login.serverInfo.tip=老项目仍可继续使用这份硬件信息 JSON 申请许可证。
login.dialog.close=关闭
login.mfa.title=MFA二次验证
login.mfa.tip=账号密码已通过,请输入身份验证器中的 6 位动态验证码后继续登录。
login.mfa.currentAccount=当前账号:
login.mfa.codeLabel=验证码
login.mfa.codePlaceholder=请输入6位动态码
login.mfa.cancel=取消
login.mfa.submit=验证并登录
login.license.label=许可证 Base64
login.license.tip=将许可证服务端返回的 license 字段完整粘贴到这里。
login.license.submit=提交
login.license.success=许可证更新成功
login.validation.usernameRequired=请输入账号
login.validation.passwordRequired=请输入密码
login.validation.mfaRequired=请输入6位验证码
login.validation.mfaInvalid=请输入6位数字验证码
login.error.loginFailed=登录失败
login.error.passkeyOptionsFailed=获取通行密钥登录参数失败
login.error.passkeyVerifyFailed=通行密钥验证失败
login.error.mfaTicketExpired=登录票据已失效,请重新登录
login.error.mfaFailed=验证失败
login.error.requestCodeFailed=获取请求码失败
login.error.serverInfoFailed=获取系统配置信息失败
login.error.licenseEmpty=许可证内容不能为空
login.error.licenseUpdateFailed=许可证更新失败
login.error.licenseImportFailed=许可证录入失败
login.activate.confirm=确定执行一键激活吗?
login.activate.success=激活成功
login.activate.failed=激活失败
login.projectName.title=项目名称
login.error.projectNameFailed=获取项目名称失败
index.searchMenu=搜索菜单
index.noMatchedMenu=没有匹配菜单
index.noAvailableMenu=当前账号没有可用菜单
@@ -59,6 +105,19 @@
response.user.notFound=账号不存在
response.user.disabled=账号已被禁用
response.user.passwordMismatch=密码错误
response.user.passkeySecureContextRequired=通行密钥仅支持在 HTTPS 或 localhost 环境下使用
response.user.passkeyNotBound=当前账号尚未绑定通行密钥
response.user.passkeyTicketExpired=通行密钥登录票据已失效,请重新发起
response.user.passkeyCounterMismatch=通行密钥签名计数异常,请重新绑定后再试
response.user.passkeyVerifyFailed=通行密钥验证失败
response.user.passkeyAlreadyBound=当前账号已绑定通行密钥,如需更换请先解绑
response.user.passkeyCredentialExists=该通行密钥已绑定其他账号
response.user.passkeyRegisterFailed=通行密钥绑定失败
response.user.oldPasswordMismatch=当前密码错误
response.user.mfaNotAllowed=当前账号未开通MFA使用权限
response.user.mfaNotEnabled=当前账号未启用MFA
response.user.mfaCodeMismatch=MFA验证码错误
response.user.mfaTicketExpired=MFA登录票据已失效,请重新输入账号密码
response.system.licenseExpired=许可证已失效
response.common.systemError=系统异常,请稍后重试
response.common.methodNotAllowed=请求方式不支持
src/main/resources/mapper/UserMapper.xml
@@ -9,10 +9,104 @@
        <result column="username" property="username" />
        <result column="mobile" property="mobile" />
        <result column="password" property="password" />
        <result column="mfa_allow" property="mfaAllow" />
        <result column="mfa_enabled" property="mfaEnabled" />
        <result column="mfa_bound_time" property="mfaBoundTime" />
        <result column="passkey_name" property="passkeyName" />
        <result column="passkey_bound_time" property="passkeyBoundTime" />
        <result column="passkey_last_used_time" property="passkeyLastUsedTime" />
        <result column="role_id" property="roleId" />
        <result column="create_time" property="createTime" />
        <result column="status" property="status" />
    </resultMap>
    <resultMap id="SecurityResultMap" type="com.zy.system.entity.User" extends="BaseResultMap">
        <result column="mfa_secret" property="mfaSecret" />
        <result column="passkey_credential_id" property="passkeyCredentialId" />
        <result column="passkey_public_key" property="passkeyPublicKey" />
        <result column="passkey_algorithm" property="passkeyAlgorithm" />
        <result column="passkey_sign_count" property="passkeySignCount" />
        <result column="passkey_transports" property="passkeyTransports" />
    </resultMap>
    <select id="selectByMobileWithSecurity" resultMap="SecurityResultMap">
        select
            id,
            host_id,
            username,
            mobile,
            password,
            mfa_allow,
            mfa_enabled,
            mfa_secret,
            mfa_bound_time,
            passkey_name,
            passkey_credential_id,
            passkey_public_key,
            passkey_algorithm,
            passkey_sign_count,
            passkey_transports,
            passkey_bound_time,
            passkey_last_used_time,
            role_id,
            create_time,
            status
        from sys_user
        where mobile = #{mobile}
        limit 1
    </select>
    <select id="selectByIdWithSecurity" resultMap="SecurityResultMap">
        select
            id,
            host_id,
            username,
            mobile,
            password,
            mfa_allow,
            mfa_enabled,
            mfa_secret,
            mfa_bound_time,
            passkey_name,
            passkey_credential_id,
            passkey_public_key,
            passkey_algorithm,
            passkey_sign_count,
            passkey_transports,
            passkey_bound_time,
            passkey_last_used_time,
            role_id,
            create_time,
            status
        from sys_user
        where id = #{id}
        limit 1
    </select>
    <select id="selectByPasskeyCredentialId" resultMap="SecurityResultMap">
        select
            id,
            host_id,
            username,
            mobile,
            password,
            mfa_allow,
            mfa_enabled,
            mfa_secret,
            mfa_bound_time,
            passkey_name,
            passkey_credential_id,
            passkey_public_key,
            passkey_algorithm,
            passkey_sign_count,
            passkey_transports,
            passkey_bound_time,
            passkey_last_used_time,
            role_id,
            create_time,
            status
        from sys_user
        where passkey_credential_id = #{credentialId}
        limit 1
    </select>
</mapper>
src/main/resources/sql/20260311_add_mfa_columns_to_sys_user.sql
New file
@@ -0,0 +1,78 @@
-- sys_user 增加 MFA 相关字段
-- 用途:支持账号级多因子登录授权、绑定和校验
-- 适用数据库:MySQL
SET @current_db := DATABASE();
SET @mfa_allow_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_user'
    AND COLUMN_NAME = 'mfa_allow'
);
SET @add_mfa_allow_sql := IF(
  @mfa_allow_exists = 0,
  'ALTER TABLE sys_user ADD COLUMN mfa_allow INT NOT NULL DEFAULT 0 COMMENT ''是否允许使用MFA'' AFTER password',
  'SELECT ''column mfa_allow already exists'' '
);
PREPARE stmt_mfa_allow FROM @add_mfa_allow_sql;
EXECUTE stmt_mfa_allow;
DEALLOCATE PREPARE stmt_mfa_allow;
SET @mfa_enabled_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_user'
    AND COLUMN_NAME = 'mfa_enabled'
);
SET @add_mfa_enabled_sql := IF(
  @mfa_enabled_exists = 0,
  'ALTER TABLE sys_user ADD COLUMN mfa_enabled INT NOT NULL DEFAULT 0 COMMENT ''是否已启用MFA'' AFTER mfa_allow',
  'SELECT ''column mfa_enabled already exists'' '
);
PREPARE stmt_mfa_enabled FROM @add_mfa_enabled_sql;
EXECUTE stmt_mfa_enabled;
DEALLOCATE PREPARE stmt_mfa_enabled;
SET @mfa_secret_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_user'
    AND COLUMN_NAME = 'mfa_secret'
);
SET @add_mfa_secret_sql := IF(
  @mfa_secret_exists = 0,
  'ALTER TABLE sys_user ADD COLUMN mfa_secret VARCHAR(128) NULL COMMENT ''MFA密钥'' AFTER mfa_enabled',
  'SELECT ''column mfa_secret already exists'' '
);
PREPARE stmt_mfa_secret FROM @add_mfa_secret_sql;
EXECUTE stmt_mfa_secret;
DEALLOCATE PREPARE stmt_mfa_secret;
SET @mfa_bound_time_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_user'
    AND COLUMN_NAME = 'mfa_bound_time'
);
SET @add_mfa_bound_time_sql := IF(
  @mfa_bound_time_exists = 0,
  'ALTER TABLE sys_user ADD COLUMN mfa_bound_time DATETIME NULL COMMENT ''MFA绑定时间'' AFTER mfa_secret',
  'SELECT ''column mfa_bound_time already exists'' '
);
PREPARE stmt_mfa_bound_time FROM @add_mfa_bound_time_sql;
EXECUTE stmt_mfa_bound_time;
DEALLOCATE PREPARE stmt_mfa_bound_time;
SHOW COLUMNS FROM sys_user LIKE 'mfa_allow';
SHOW COLUMNS FROM sys_user LIKE 'mfa_enabled';
SHOW COLUMNS FROM sys_user LIKE 'mfa_secret';
SHOW COLUMNS FROM sys_user LIKE 'mfa_bound_time';
src/main/resources/sql/20260311_add_passkey_columns_to_sys_user.sql
New file
@@ -0,0 +1,26 @@
-- 用途:支持账号绑定单个通行密钥并通过通行密钥登录
-- 适用表:sys_user
SET @table_exists := (
    SELECT COUNT(*)
    FROM information_schema.tables
    WHERE table_schema = DATABASE()
      AND table_name = 'sys_user'
);
SET @sql := IF(@table_exists = 0,
  'SELECT ''sys_user not found''',
  'ALTER TABLE sys_user
      ADD COLUMN passkey_name VARCHAR(100) NULL COMMENT ''通行密钥名称'' AFTER mfa_bound_time,
      ADD COLUMN passkey_credential_id VARCHAR(255) NULL COMMENT ''通行密钥凭证ID'' AFTER passkey_name,
      ADD COLUMN passkey_public_key TEXT NULL COMMENT ''通行密钥公钥SPKI'' AFTER passkey_credential_id,
      ADD COLUMN passkey_algorithm INT NULL COMMENT ''通行密钥算法'' AFTER passkey_public_key,
      ADD COLUMN passkey_sign_count BIGINT NOT NULL DEFAULT 0 COMMENT ''通行密钥签名计数器'' AFTER passkey_algorithm,
      ADD COLUMN passkey_transports VARCHAR(255) NULL COMMENT ''通行密钥传输方式'' AFTER passkey_sign_count,
      ADD COLUMN passkey_bound_time DATETIME NULL COMMENT ''通行密钥绑定时间'' AFTER passkey_transports,
      ADD COLUMN passkey_last_used_time DATETIME NULL COMMENT ''通行密钥最近使用时间'' AFTER passkey_bound_time'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
src/main/webapp/components/MapCanvas.js
@@ -119,6 +119,7 @@
      },
      shelfTooltipMinScale: 0.4,
      containerResizeObserver: null,
      resizeDebounceTimer: null,
      timer: null,
      adjustLabelTimer: null,
      isSwitchingFloor: false,
@@ -173,10 +174,11 @@
    if (this.hoverRaf) { cancelAnimationFrame(this.hoverRaf); this.hoverRaf = null; }
    if (this.shelfCullRaf) { cancelAnimationFrame(this.shelfCullRaf); this.shelfCullRaf = null; }
    if (this.resizeDebounceTimer) { clearTimeout(this.resizeDebounceTimer); this.resizeDebounceTimer = null; }
    if (window.gsap && this.pixiApp && this.pixiApp.stage) { window.gsap.killTweensOf(this.pixiApp.stage.position); }
    if (this.pixiApp) { this.pixiApp.destroy(true, { children: true }); }
    if (this.containerResizeObserver) { this.containerResizeObserver.disconnect(); this.containerResizeObserver = null; }
    window.removeEventListener('resize', this.resizeToContainer);
    window.removeEventListener('resize', this.scheduleResizeToContainer);
    if (this.wsReconnectTimer) { clearTimeout(this.wsReconnectTimer); this.wsReconnectTimer = null; }
    if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { try { this.ws.close(); } catch (e) {} }
  },
@@ -377,7 +379,7 @@
      this.pixiApp.view.style.height = '100%';
      this.pixiApp.view.style.display = 'block';
      this.resizeToContainer();
      window.addEventListener('resize', this.resizeToContainer);
      window.addEventListener('resize', this.scheduleResizeToContainer);
      this.graphicsCrnTrack = this.createTrackTexture(25, 25, 10);
      this.graphicsRgvTrack = this.createTrackTexture(25, 25, 10);
      this.objectsContainer = new PIXI.Container();
@@ -490,9 +492,18 @@
    startContainerResizeObserve() {
      if (typeof ResizeObserver === 'undefined' || !this.$el) { return; }
      this.containerResizeObserver = new ResizeObserver(() => {
        this.resizeToContainer();
        this.scheduleResizeToContainer();
      });
      this.containerResizeObserver.observe(this.$el);
    },
    scheduleResizeToContainer() {
      if (this.resizeDebounceTimer) {
        clearTimeout(this.resizeDebounceTimer);
      }
      this.resizeDebounceTimer = setTimeout(() => {
        this.resizeDebounceTimer = null;
        this.resizeToContainer();
      }, 80);
    },
    getViewportSize() {
      if (!this.pixiApp || !this.pixiApp.renderer) { return { width: 0, height: 0 }; }
@@ -2646,7 +2657,6 @@
    }
  }
});
src/main/webapp/static/js/config/config.js
@@ -850,6 +850,154 @@
        return result;
    }
    fieldMeta = [
        {
            field: 'id',
            columnName: 'id',
            label: 'ID',
            tableProp: 'id',
            exportField: 'id',
            kind: 'text',
            valueType: 'number',
            required: true,
            primaryKey: true,
            searchable: false,
            sortable: true,
            textarea: false,
            minWidth: 90,
            enumOptions: [],
            foreignQuery: '',
            checkboxActiveRaw: '1',
            checkboxInactiveRaw: '0'
        },
        {
            field: 'name',
            columnName: 'name',
            label: '名称',
            tableProp: 'name',
            exportField: 'name',
            kind: 'text',
            valueType: 'string',
            required: true,
            primaryKey: false,
            searchable: false,
            sortable: false,
            textarea: false,
            minWidth: 180,
            dialogSpan: 12,
            enumOptions: [],
            foreignQuery: '',
            checkboxActiveRaw: 'Y',
            checkboxInactiveRaw: 'N'
        },
        {
            field: 'code',
            columnName: 'code',
            label: '编码',
            tableProp: 'code',
            exportField: 'code',
            kind: 'text',
            valueType: 'string',
            required: true,
            primaryKey: false,
            searchable: true,
            sortable: false,
            textarea: false,
            minWidth: 180,
            dialogSpan: 12,
            enumOptions: [],
            foreignQuery: '',
            checkboxActiveRaw: 'Y',
            checkboxInactiveRaw: 'N'
        },
        {
            field: 'value',
            columnName: 'value',
            label: '对应值',
            tableProp: 'value',
            exportField: 'value',
            kind: 'text',
            valueType: 'string',
            required: true,
            primaryKey: false,
            searchable: false,
            sortable: false,
            textarea: false,
            minWidth: 220,
            dialogSpan: 24,
            enumOptions: [],
            foreignQuery: '',
            checkboxActiveRaw: 'Y',
            checkboxInactiveRaw: 'N'
        },
        {
            field: 'type',
            columnName: 'type',
            label: '类型',
            tableProp: 'type$',
            exportField: 'type$',
            kind: 'enum',
            valueType: 'number',
            required: true,
            primaryKey: false,
            searchable: false,
            sortable: false,
            textarea: false,
            minWidth: 120,
            dialogSpan: 12,
            enumOptions: [
                { rawValue: '1', label: 'String' },
                { rawValue: '2', label: 'JSON' }
            ],
            foreignQuery: '',
            checkboxActiveRaw: '1',
            checkboxInactiveRaw: '0'
        },
        {
            field: 'status',
            columnName: 'status',
            label: '状态',
            tableProp: 'status$',
            exportField: 'status$',
            kind: 'enum',
            valueType: 'number',
            required: true,
            primaryKey: false,
            searchable: false,
            sortable: false,
            textarea: false,
            minWidth: 120,
            dialogSpan: 12,
            enumOptions: [
                { rawValue: '1', label: '正常' },
                { rawValue: '0', label: '禁用' }
            ],
            foreignQuery: '',
            checkboxActiveRaw: '1',
            checkboxInactiveRaw: '0'
        },
        {
            field: 'selectType',
            columnName: 'select_type',
            label: '筛选类型',
            tableProp: 'selectType',
            exportField: 'selectType',
            kind: 'text',
            valueType: 'string',
            required: false,
            primaryKey: false,
            searchable: true,
            sortable: false,
            textarea: false,
            minWidth: 140,
            dialogSpan: 24,
            enumOptions: [],
            foreignQuery: '',
            checkboxActiveRaw: 'Y',
            checkboxInactiveRaw: 'N'
        }
    ];
    function isEmptyValue(value) {
        return value === null || value === undefined || value === '';
    }
@@ -877,11 +1025,17 @@
    }
    function isSearchableField(field) {
        return !!field && field.kind !== 'image' && !field.textarea;
        return !!field && field.searchable !== false && field.kind !== 'image' && !field.textarea;
    }
    function isSortableField(field) {
        if (!field) {
            return false;
        }
        if (field.sortable === true) {
            return true;
        }
        if (field.sortable === false) {
            return false;
        }
        if (field.primaryKey) {
@@ -1217,6 +1371,7 @@
                        mode: 'create',
                        submitting: false
                    },
                    tableHeight: 420,
                    layoutTimer: null,
                    tableResizeHandler: null,
                    dialogForm: createFormDefaults(),
@@ -1270,16 +1425,14 @@
                        };
                    });
                },
                tableHeight: function () {
                    return this.advancedFiltersVisible && this.hasAdvancedFilters
                        ? 'calc(100vh - 390px)'
                        : 'calc(100vh - 300px)';
                },
                formTarget: function () {
                    return this.dialogForm;
                },
                displayTarget: function () {
                    return this.dialogDisplay;
                },
                isDialogReadonly: function () {
                    return this.dialog.mode === 'detail';
                }
            },
            created: function () {
@@ -1304,12 +1457,26 @@
                }
            },
            methods: $.extend({}, sharedMethods, {
                calculateTableHeight: function () {
                    var viewportHeight = window.innerHeight || document.documentElement.clientHeight || 860;
                    var tableWrap = this.$refs.tableWrap;
                    var pagerBar = this.$refs.pagerBar;
                    if (!tableWrap) {
                        this.tableHeight = Math.max(360, viewportHeight - (this.advancedFiltersVisible ? 360 : 300));
                        return;
                    }
                    var tableTop = tableWrap.getBoundingClientRect().top;
                    var pagerHeight = pagerBar ? pagerBar.offsetHeight : 56;
                    var bottomGap = 56;
                    this.tableHeight = Math.max(320, Math.floor(viewportHeight - tableTop - pagerHeight - bottomGap));
                },
                requestTableLayout: function (delay) {
                    var self = this;
                    if (self.layoutTimer) {
                        clearTimeout(self.layoutTimer);
                    }
                    self.$nextTick(function () {
                        self.calculateTableHeight();
                        self.layoutTimer = setTimeout(function () {
                            var table = self.$refs.dataTable;
                            if (table && typeof table.doLayout === 'function') {
@@ -1475,8 +1642,23 @@
                        }
                    });
                },
                openDetailDialog: function (row) {
                    var self = this;
                    self.dialog.mode = 'detail';
                    self.dialog.visible = true;
                    self.$nextTick(function () {
                        self.resetDialogState();
                        fillFormFromRow(row, self.dialogForm, self.dialogDisplay);
                        if (self.$refs.dialogForm) {
                            self.$refs.dialogForm.clearValidate();
                        }
                    });
                },
                submitDialog: function () {
                    var self = this;
                    if (self.dialog.mode === 'detail') {
                        return;
                    }
                    if (!self.$refs.dialogForm) {
                        return;
                    }
@@ -1589,6 +1771,30 @@
                            self.$message.error('导出失败');
                        }
                    });
                },
                refreshCache: function () {
                    var self = this;
                    self.$confirm('确定刷新Redis缓存吗?', '提示', { type: 'warning' }).then(function () {
                        $.ajax({
                            url: baseUrl + '/config/refreshCache',
                            method: 'POST',
                            headers: self.authHeaders(),
                            success: function (res) {
                                if (self.handleForbidden(res)) {
                                    return;
                                }
                                if (!res || res.code !== 200) {
                                    self.$message.error((res && res.msg) ? res.msg : '刷新缓存失败');
                                    return;
                                }
                                self.$message.success('刷新缓存成功');
                                self.loadTable();
                            },
                            error: function () {
                                self.$message.error('刷新缓存失败');
                            }
                        });
                    }).catch(function () {});
                }
            })
        });
src/main/webapp/static/js/detail/detail.js
@@ -15,18 +15,48 @@
                saving: false,
                passwordDialogVisible: false,
                passwordSaving: false,
                mfaDialogVisible: false,
                mfaDialogMode: "enable",
                mfaSetupLoading: false,
                mfaSubmitting: false,
                passkeyDialogVisible: false,
                passkeyDialogMode: "register",
                passkeySubmitting: false,
                form: {
                    id: "",
                    roleName: "",
                    username: "",
                    mobile: "",
                    password: "",
                    createTime$: ""
                    createTime$: "",
                    mfaAllow: 0,
                    mfaAllow$: "否",
                    mfaEnabled: 0,
                    mfaEnabled$: "否",
                    mfaBoundTime$: "",
                    mfaMaskedSecret: "",
                    passkeyBound: false,
                    passkeyName: "",
                    passkeyBoundTime$: "",
                    passkeyLastUsedTime$: "",
                    passkeyTransports: ""
                },
                passwordForm: {
                    oldPassword: "",
                    password: "",
                    rePassword: ""
                },
                mfaForm: {
                    currentPassword: "",
                    code: ""
                },
                mfaSetup: {
                    secret: "",
                    qrCode: "",
                    otpAuth: ""
                },
                passkeyForm: {
                    name: "",
                    currentPassword: ""
                },
                rules: {
                    username: [
@@ -38,25 +68,7 @@
                },
                passwordRules: {
                    oldPassword: [
                        { required: true, message: "请输入当前密码", trigger: "blur" },
                        {
                            validator: function (rule, value, callback) {
                                if (!value) {
                                    callback(new Error("请输入当前密码"));
                                    return;
                                }
                                if (!this.form.password) {
                                    callback(new Error("未读取到当前用户密码"));
                                    return;
                                }
                                if (hex_md5(value) !== this.form.password) {
                                    callback(new Error("密码不匹配"));
                                    return;
                                }
                                callback();
                            }.bind(this),
                            trigger: "blur"
                        }
                        { required: true, message: "请输入当前密码", trigger: "blur" }
                    ],
                    password: [
                        { required: true, message: "请输入新密码", trigger: "blur" },
@@ -70,7 +82,7 @@
                                    callback(new Error("不能少于4个字符"));
                                    return;
                                }
                                if (this.form.password && hex_md5(value) === this.form.password) {
                                if (value === this.passwordForm.oldPassword) {
                                    callback(new Error("与旧密码不能相同"));
                                    return;
                                }
@@ -91,6 +103,29 @@
                            }.bind(this),
                            trigger: "blur"
                        }
                    ]
                },
                mfaRules: {
                    currentPassword: [
                        { required: true, message: "请输入当前密码", trigger: "blur" }
                    ],
                    code: [
                        { required: true, message: "请输入6位验证码", trigger: "blur" },
                        {
                            validator: function (rule, value, callback) {
                                if (!/^\d{6}$/.test(String(value || "").trim())) {
                                    callback(new Error("请输入6位数字验证码"));
                                    return;
                                }
                                callback();
                            },
                            trigger: "blur"
                        }
                    ]
                },
                passkeyRules: {
                    currentPassword: [
                        { required: true, message: "请输入当前密码", trigger: "blur" }
                    ]
                }
            };
@@ -155,10 +190,10 @@
                var vm = this;
                vm.passwordSaving = true;
                $.ajax({
                    url: baseUrl + "/user/update/auth",
                    url: baseUrl + "/user/password/update/auth",
                    headers: { token: localStorage.getItem("token") },
                    data: {
                        id: vm.form.id,
                        oldPassword: hex_md5(vm.passwordForm.oldPassword),
                        password: hex_md5(vm.passwordForm.password)
                    },
                    method: "POST",
@@ -170,7 +205,6 @@
                            vm.$message.error(res.msg || "密码修改失败");
                            return;
                        }
                        vm.form.password = hex_md5(vm.passwordForm.password);
                        vm.passwordDialogVisible = false;
                        vm.$alert("密码修改成功,请重新登录", "提示", {
                            confirmButtonText: "确定",
@@ -188,6 +222,304 @@
                    }
                });
            },
            openMfaEnableDialog: function () {
                if (Number(this.form.mfaAllow) !== 1) {
                    this.$message.warning("当前账号未开通MFA使用权限");
                    return;
                }
                this.mfaDialogMode = "enable";
                this.resetMfaDialogState();
                this.mfaDialogVisible = true;
                this.loadMfaSetup();
            },
            openMfaDisableDialog: function () {
                this.mfaDialogMode = "disable";
                this.resetMfaDialogState();
                this.mfaDialogVisible = true;
            },
            closeMfaDialog: function () {
                this.mfaDialogVisible = false;
                this.resetMfaDialogState();
            },
            resetMfaDialogState: function () {
                this.mfaSubmitting = false;
                this.mfaSetupLoading = false;
                this.mfaForm = {
                    currentPassword: "",
                    code: ""
                };
                this.mfaSetup = {
                    secret: "",
                    qrCode: "",
                    otpAuth: ""
                };
                if (this.$refs.mfaForm) {
                    this.$refs.mfaForm.clearValidate();
                }
            },
            loadMfaSetup: function () {
                var vm = this;
                vm.mfaSetupLoading = true;
                $.ajax({
                    url: baseUrl + "/user/mfa/setup/auth",
                    headers: { token: localStorage.getItem("token") },
                    method: "POST",
                    success: function (res) {
                        if (handleForbidden(res)) {
                            return;
                        }
                        if (Number(res.code) !== 200) {
                            vm.$message.error(res.msg || "MFA配置加载失败");
                            return;
                        }
                        vm.mfaSetup = Object.assign({}, vm.mfaSetup, res.data || {});
                    },
                    error: function () {
                        vm.$message.error("MFA配置加载失败");
                    },
                    complete: function () {
                        vm.mfaSetupLoading = false;
                    }
                });
            },
            handleMfaSubmit: function () {
                var vm = this;
                if (!vm.$refs.mfaForm) {
                    return;
                }
                vm.$refs.mfaForm.validate(function (valid) {
                    if (!valid) {
                        return false;
                    }
                    if (vm.mfaDialogMode === "enable" && !vm.mfaSetup.secret) {
                        vm.$message.error("MFA密钥尚未生成,请稍后重试");
                        return false;
                    }
                    vm.submitMfaAction();
                    return true;
                });
            },
            submitMfaAction: function () {
                var vm = this;
                vm.mfaSubmitting = true;
                $.ajax({
                    url: baseUrl + (vm.mfaDialogMode === "enable" ? "/user/mfa/enable/auth" : "/user/mfa/disable/auth"),
                    headers: { token: localStorage.getItem("token") },
                    data: {
                        currentPassword: hex_md5(vm.mfaForm.currentPassword),
                        code: vm.mfaForm.code,
                        secret: vm.mfaSetup.secret
                    },
                    method: "POST",
                    success: function (res) {
                        if (handleForbidden(res)) {
                            return;
                        }
                        if (Number(res.code) !== 200) {
                            vm.$message.error(res.msg || "MFA操作失败");
                            return;
                        }
                        vm.$message.success(vm.mfaDialogMode === "enable" ? "MFA已启用" : "MFA已停用");
                        vm.closeMfaDialog();
                        vm.loadDetail();
                    },
                    error: function () {
                        vm.$message.error("MFA操作失败");
                    },
                    complete: function () {
                        vm.mfaSubmitting = false;
                    }
                });
            },
            openPasskeyRegisterDialog: function () {
                if (!window.WCS_WEBAUTHN || !window.WCS_WEBAUTHN.isSupported()) {
                    this.$message.error(this.resolvePasskeyErrorMessage({ message: window.isSecureContext ? "not-supported" : "secure-context" }, "当前环境不支持通行密钥"));
                    return;
                }
                this.passkeyDialogMode = "register";
                this.resetPasskeyDialogState();
                this.passkeyDialogVisible = true;
            },
            openPasskeyRemoveDialog: function () {
                this.passkeyDialogMode = "remove";
                this.resetPasskeyDialogState();
                this.passkeyDialogVisible = true;
            },
            closePasskeyDialog: function () {
                this.passkeyDialogVisible = false;
                this.resetPasskeyDialogState();
            },
            resetPasskeyDialogState: function () {
                this.passkeySubmitting = false;
                this.passkeyForm = {
                    name: "",
                    currentPassword: ""
                };
                if (this.$refs.passkeyForm) {
                    this.$refs.passkeyForm.clearValidate();
                }
            },
            handlePasskeySubmit: function () {
                var vm = this;
                if (!vm.$refs.passkeyForm) {
                    return;
                }
                vm.$refs.passkeyForm.validate(function (valid) {
                    if (!valid) {
                        return false;
                    }
                    if (vm.passkeyDialogMode === "register") {
                        vm.submitPasskeyRegister();
                    } else {
                        vm.submitPasskeyRemove();
                    }
                    return true;
                });
            },
            submitPasskeyRegister: function () {
                var vm = this;
                if (!window.WCS_WEBAUTHN || !window.WCS_WEBAUTHN.isSupported()) {
                    vm.$message.error(vm.resolvePasskeyErrorMessage({ message: window.isSecureContext ? "not-supported" : "secure-context" }, "当前环境不支持通行密钥"));
                    return;
                }
                vm.passkeySubmitting = true;
                $.ajax({
                    url: baseUrl + "/user/passkey/register/options/auth",
                    headers: { token: localStorage.getItem("token") },
                    method: "POST",
                    success: function (res) {
                        if (handleForbidden(res)) {
                            return;
                        }
                        if (Number(res.code) !== 200) {
                            vm.passkeySubmitting = false;
                            vm.$message.error(res.msg || "通行密钥配置加载失败");
                            return;
                        }
                        vm.executePasskeyRegister(res.data || {});
                    },
                    error: function () {
                        vm.passkeySubmitting = false;
                        vm.$message.error("通行密钥配置加载失败");
                    }
                });
            },
            executePasskeyRegister: function (optionsPayload) {
                var vm = this;
                window.WCS_WEBAUTHN.register(optionsPayload).then(function (credential) {
                    $.ajax({
                        url: baseUrl + "/user/passkey/register/finish/auth",
                        headers: { token: localStorage.getItem("token") },
                        data: {
                            ticket: optionsPayload.ticket,
                            currentPassword: hex_md5(vm.passkeyForm.currentPassword),
                            name: vm.passkeyForm.name,
                            credentialId: credential.credentialId,
                            clientDataJSON: credential.clientDataJSON,
                            authenticatorData: credential.authenticatorData,
                            publicKey: credential.publicKey,
                            publicKeyAlgorithm: credential.publicKeyAlgorithm,
                            transports: credential.transports
                        },
                        method: "POST",
                        success: function (res) {
                            if (handleForbidden(res)) {
                                return;
                            }
                            if (Number(res.code) !== 200) {
                                vm.$message.error(res.msg || "通行密钥绑定失败");
                                return;
                            }
                            vm.$message.success("通行密钥已绑定");
                            vm.closePasskeyDialog();
                            vm.loadDetail();
                        },
                        error: function () {
                            vm.$message.error("通行密钥绑定失败");
                        },
                        complete: function () {
                            vm.passkeySubmitting = false;
                        }
                    });
                }).catch(function (err) {
                    vm.passkeySubmitting = false;
                    vm.$message.error(vm.resolvePasskeyErrorMessage(err, "通行密钥绑定失败"));
                });
            },
            submitPasskeyRemove: function () {
                var vm = this;
                vm.passkeySubmitting = true;
                $.ajax({
                    url: baseUrl + "/user/passkey/remove/auth",
                    headers: { token: localStorage.getItem("token") },
                    data: {
                        currentPassword: hex_md5(vm.passkeyForm.currentPassword)
                    },
                    method: "POST",
                    success: function (res) {
                        if (handleForbidden(res)) {
                            return;
                        }
                        if (Number(res.code) !== 200) {
                            vm.$message.error(res.msg || "通行密钥解绑失败");
                            return;
                        }
                        vm.$message.success("通行密钥已解绑");
                        vm.closePasskeyDialog();
                        vm.loadDetail();
                    },
                    error: function () {
                        vm.$message.error("通行密钥解绑失败");
                    },
                    complete: function () {
                        vm.passkeySubmitting = false;
                    }
                });
            },
            resolvePasskeyErrorMessage: function (err, fallback) {
                var message = err && err.message ? String(err.message) : "";
                if (message === "secure-context") {
                    return "通行密钥仅支持在 HTTPS 或 localhost 环境下使用";
                }
                if (message === "not-supported" || message === "extension-unsupported" || message === "public-key-missing") {
                    return "当前浏览器不支持通行密钥,请使用最新版 Chrome、Edge 或 Safari";
                }
                if (err && err.name === "NotAllowedError") {
                    return "已取消通行密钥操作或验证超时";
                }
                return fallback || "通行密钥操作失败";
            },
            copySecret: function () {
                var vm = this;
                var text = vm.mfaSetup.secret || "";
                if (!text) {
                    return;
                }
                if (navigator.clipboard && navigator.clipboard.writeText) {
                    navigator.clipboard.writeText(text).then(function () {
                        vm.$message.success("密钥已复制");
                    }).catch(function () {
                        vm.fallbackCopy(text);
                    });
                    return;
                }
                vm.fallbackCopy(text);
            },
            fallbackCopy: function (text) {
                try {
                    var textarea = document.createElement("textarea");
                    textarea.value = text;
                    textarea.style.position = "fixed";
                    textarea.style.opacity = "0";
                    document.body.appendChild(textarea);
                    textarea.select();
                    document.execCommand("copy");
                    document.body.removeChild(textarea);
                    this.$message.success("密钥已复制");
                } catch (err) {
                    this.$message.error("复制失败");
                }
            },
            handleSave: function () {
                var vm = this;
                vm.$refs.profileForm.validate(function (valid) {
src/main/webapp/static/js/login/login.js
@@ -11,15 +11,25 @@
                localeOptions: [],
                currentLocale: "zh-CN",
                loginLoading: false,
                passkeyLoading: false,
                mfaLoading: false,
                toolsDialogVisible: false,
                textDialogVisible: false,
                uploadDialogVisible: false,
                mfaDialogVisible: false,
                licenseBase64: "",
                titleClickCount: 0,
                titleClickTimer: null,
                loginForm: {
                    mobile: "",
                    password: ""
                },
                mfaForm: {
                    code: ""
                },
                mfaPending: {
                    ticket: "",
                    username: ""
                },
                textDialog: {
                    title: "",
@@ -34,6 +44,21 @@
                    password: [
                        { required: true, message: "请输入密码", trigger: "blur" }
                    ]
                },
                mfaRules: {
                    code: [
                        { required: true, message: "请输入6位验证码", trigger: "blur" },
                        {
                            validator: function (rule, value, callback) {
                                if (!/^\d{6}$/.test(String(value || "").trim())) {
                                    callback(new Error("请输入6位数字验证码"));
                                    return;
                                }
                                callback();
                            },
                            trigger: "blur"
                        }
                    ]
                }
            };
        },
@@ -45,19 +70,50 @@
                var localeTick = this.localeTick;
                void localeTick;
                if (window.WCS_I18N && typeof window.WCS_I18N.t === "function") {
                    return window.WCS_I18N.t(key);
                    var translated = window.WCS_I18N.t(key);
                    if (translated && translated !== key) {
                        return translated;
                    }
                }
                return fallback || key;
            },
            refreshRuleMessages: function () {
                var vm = this;
                vm.loginRules = {
                    mobile: [
                        { required: true, message: vm.text("login.validation.usernameRequired", "请输入账号"), trigger: "blur" }
                    ],
                    password: [
                        { required: true, message: vm.text("login.validation.passwordRequired", "请输入密码"), trigger: "blur" }
                    ]
                };
                vm.mfaRules = {
                    code: [
                        { required: true, message: vm.text("login.validation.mfaRequired", "请输入6位验证码"), trigger: "blur" },
                        {
                            validator: function (rule, value, callback) {
                                if (!/^\d{6}$/.test(String(value || "").trim())) {
                                    callback(new Error(vm.text("login.validation.mfaInvalid", "请输入6位数字验证码")));
                                    return;
                                }
                                callback();
                            },
                            trigger: "blur"
                        }
                    ]
                };
            },
            initLanguageSwitch: function () {
                var vm = this;
                if (!window.WCS_I18N || typeof window.WCS_I18N.onReady !== "function") {
                    vm.refreshRuleMessages();
                    return;
                }
                window.WCS_I18N.onReady(function (i18n) {
                    vm.localeOptions = i18n.getLocaleOptions();
                    vm.currentLocale = i18n.getLocale();
                    document.title = i18n.t("login.title");
                    vm.refreshRuleMessages();
                    vm.localeTick++;
                });
            },
@@ -97,6 +153,33 @@
                    return true;
                });
            },
            handlePasskeyLogin: function () {
                var vm = this;
                if (!window.WCS_WEBAUTHN || !window.WCS_WEBAUTHN.isSupported()) {
                    vm.$message.error(vm.resolvePasskeyErrorMessage({ message: window.isSecureContext ? "not-supported" : "secure-context" }, "login.error.passkeyOptionsFailed", "获取通行密钥登录参数失败"));
                    return;
                }
                vm.passkeyLoading = true;
                ajaxJson({
                    url: baseUrl + "/login/passkey/options.action",
                    data: {
                        mobile: vm.loginForm.mobile
                    },
                    method: "POST",
                    success: function (res) {
                        if (Number(res.code) !== 200) {
                            vm.passkeyLoading = false;
                            vm.$message.error(res.msg || vm.text("login.error.passkeyOptionsFailed", "获取通行密钥登录参数失败"));
                            return;
                        }
                        vm.executePasskeyLogin(res.data || {});
                    },
                    error: function () {
                        vm.passkeyLoading = false;
                        vm.$message.error(vm.text("login.error.passkeyOptionsFailed", "获取通行密钥登录参数失败"));
                    }
                });
            },
            submitLogin: function () {
                var vm = this;
                vm.loginLoading = true;
@@ -108,21 +191,143 @@
                    },
                    method: "POST",
                    success: function (res) {
                        var payload = res && res.data ? res.data : {};
                        if (Number(res.code) === 200) {
                            localStorage.setItem("token", res.data.token);
                            localStorage.setItem("username", res.data.username);
                            window.location.href = "index.html";
                            return;
                        }
                        vm.$message.error(res.msg || "登录失败");
                            if (payload.mfaRequired) {
                                vm.openMfaDialog(payload);
                                return;
                            }
                        vm.finishLogin(payload);
                        return;
                    }
                        vm.$message.error(res.msg || vm.text("login.error.loginFailed", "登录失败"));
                    },
                    error: function () {
                        vm.$message.error("登录失败");
                        vm.$message.error(vm.text("login.error.loginFailed", "登录失败"));
                    },
                    complete: function () {
                        vm.loginLoading = false;
                    }
                });
            },
            openMfaDialog: function (payload) {
                this.mfaPending = {
                    ticket: payload.mfaTicket || "",
                    username: payload.username || this.loginForm.mobile || ""
                };
                this.mfaForm.code = "";
                this.mfaDialogVisible = true;
                if (this.$refs.mfaForm) {
                    this.$nextTick(function () {
                        this.$refs.mfaForm.clearValidate();
                    });
                }
            },
            closeMfaDialog: function () {
                this.mfaDialogVisible = false;
                this.mfaLoading = false;
                this.mfaPending = {
                    ticket: "",
                    username: ""
                };
                this.mfaForm.code = "";
                if (this.$refs.mfaForm) {
                    this.$refs.mfaForm.clearValidate();
                }
            },
            handleMfaLogin: function () {
                var vm = this;
                if (!vm.$refs.mfaForm) {
                    return;
                }
                vm.$refs.mfaForm.validate(function (valid) {
                    if (!valid) {
                        return false;
                    }
                    vm.submitMfaLogin();
                    return true;
                });
            },
            submitMfaLogin: function () {
                var vm = this;
                if (!vm.mfaPending.ticket) {
                    vm.$message.error(vm.text("login.error.mfaTicketExpired", "登录票据已失效,请重新登录"));
                    vm.closeMfaDialog();
                    return;
                }
                vm.mfaLoading = true;
                ajaxJson({
                    url: baseUrl + "/login/mfa.action",
                    data: {
                        ticket: vm.mfaPending.ticket,
                        code: vm.mfaForm.code
                    },
                    method: "POST",
                    success: function (res) {
                        if (Number(res.code) === 200) {
                            vm.finishLogin(res.data || {});
                            return;
                        }
                        vm.$message.error(res.msg || vm.text("login.error.mfaFailed", "验证失败"));
                    },
                    error: function () {
                        vm.$message.error(vm.text("login.error.mfaFailed", "验证失败"));
                    },
                    complete: function () {
                        vm.mfaLoading = false;
                    }
                });
            },
            executePasskeyLogin: function (payload) {
                var vm = this;
                window.WCS_WEBAUTHN.authenticate(payload).then(function (assertion) {
                    ajaxJson({
                        url: baseUrl + "/login/passkey/verify.action",
                        data: {
                            ticket: payload.ticket,
                            credentialId: assertion.credentialId,
                            clientDataJSON: assertion.clientDataJSON,
                            authenticatorData: assertion.authenticatorData,
                            signature: assertion.signature
                        },
                        method: "POST",
                        success: function (res) {
                            if (Number(res.code) === 200) {
                                vm.finishLogin(res.data || {});
                                return;
                            }
                            vm.$message.error(res.msg || vm.text("login.error.passkeyVerifyFailed", "通行密钥验证失败"));
                        },
                        error: function () {
                            vm.$message.error(vm.text("login.error.passkeyVerifyFailed", "通行密钥验证失败"));
                        },
                        complete: function () {
                            vm.passkeyLoading = false;
                        }
                    });
                }).catch(function (err) {
                    vm.passkeyLoading = false;
                    vm.$message.error(vm.resolvePasskeyErrorMessage(err, "login.error.passkeyVerifyFailed", "通行密钥验证失败"));
                });
            },
            finishLogin: function (payload) {
                localStorage.setItem("token", payload.token || "");
                localStorage.setItem("username", payload.username || this.loginForm.mobile || "");
                this.closeMfaDialog();
                window.location.href = "index.html";
            },
            resolvePasskeyErrorMessage: function (err, fallbackKey, fallbackText) {
                var message = err && err.message ? String(err.message) : "";
                if (message === "secure-context") {
                    return this.text("login.passkey.secureContext", "通行密钥仅支持在 HTTPS 或 localhost 环境下使用");
                }
                if (message === "not-supported" || message === "extension-unsupported" || message === "public-key-missing") {
                    return this.text("login.passkey.browserUnsupported", "当前浏览器不支持通行密钥,请使用最新版 Chrome、Edge 或 Safari");
                }
                if (err && err.name === "NotAllowedError") {
                    return this.text(fallbackKey, fallbackText);
                }
                return this.text(fallbackKey, fallbackText);
            },
            openTextDialog: function (title, label, text, tip) {
                var pretty = "";
@@ -149,9 +354,9 @@
                    textarea.select();
                    document.execCommand("copy");
                    document.body.removeChild(textarea);
                    this.$message.success("已复制到剪贴板");
                    this.$message.success(this.text("login.dialog.copied", "已复制到剪贴板"));
                } catch (err) {
                    this.$message.error("复制失败");
                    this.$message.error(this.text("login.dialog.copyFailed", "复制失败"));
                }
            },
            copyText: function () {
@@ -159,7 +364,7 @@
                var text = vm.textDialog.text || "";
                if (navigator.clipboard && navigator.clipboard.writeText) {
                    navigator.clipboard.writeText(text).then(function () {
                        vm.$message.success("已复制到剪贴板");
                        vm.$message.success(vm.text("login.dialog.copied", "已复制到剪贴板"));
                    }).catch(function () {
                        vm.fallbackCopy(text);
                    });
@@ -174,13 +379,18 @@
                    method: "GET",
                    success: function (res) {
                        if (Number(res.code) === 200) {
                            vm.openTextDialog("获取请求码", "请求码", res.msg || "", "请求码中已包含项目名称,直接发给许可证服务端即可。");
                            vm.openTextDialog(
                                vm.text("login.requestCode.title", "获取请求码"),
                                vm.text("login.requestCode.label", "请求码"),
                                res.msg || "",
                                vm.text("login.requestCode.tip", "请求码中已包含项目名称,直接发给许可证服务端即可。")
                            );
                            return;
                        }
                        vm.$message.error(res.msg || "获取请求码失败");
                        vm.$message.error(res.msg || vm.text("login.error.requestCodeFailed", "获取请求码失败"));
                    },
                    error: function () {
                        vm.$message.error("获取请求码失败");
                        vm.$message.error(vm.text("login.error.requestCodeFailed", "获取请求码失败"));
                    }
                });
            },
@@ -190,17 +400,22 @@
                    url: baseUrl + "/license/getServerInfos",
                    method: "GET",
                    success: function (res) {
                        vm.openTextDialog("获取系统配置", "系统配置信息", res, "老项目仍可继续使用这份硬件信息 JSON 申请许可证。");
                        vm.openTextDialog(
                            vm.text("login.serverInfo.title", "获取系统配置"),
                            vm.text("login.serverInfo.label", "系统配置信息"),
                            res,
                            vm.text("login.serverInfo.tip", "老项目仍可继续使用这份硬件信息 JSON 申请许可证。")
                        );
                    },
                    error: function () {
                        vm.$message.error("获取系统配置信息失败");
                        vm.$message.error(vm.text("login.error.serverInfoFailed", "获取系统配置信息失败"));
                    }
                });
            },
            submitLicense: function () {
                var vm = this;
                if (!vm.licenseBase64) {
                    vm.$message.warning("许可证内容不能为空");
                    vm.$message.warning(vm.text("login.error.licenseEmpty", "许可证内容不能为空"));
                    return;
                }
                ajaxJson({
@@ -212,35 +427,35 @@
                        if (Number(res.code) === 200) {
                            vm.uploadDialogVisible = false;
                            vm.licenseBase64 = "";
                            vm.$message.success("许可证更新成功");
                            vm.$message.success(vm.text("login.license.success", "许可证更新成功"));
                            return;
                        }
                        vm.$message.error(res.msg || "许可证更新失败");
                        vm.$message.error(res.msg || vm.text("login.error.licenseUpdateFailed", "许可证更新失败"));
                    },
                    error: function () {
                        vm.$message.error("许可证录入失败");
                        vm.$message.error(vm.text("login.error.licenseImportFailed", "许可证录入失败"));
                    }
                });
            },
            activateLicense: function () {
                var vm = this;
                vm.$confirm("确定执行一键激活吗?", "提示", {
                vm.$confirm(vm.text("login.activate.confirm", "确定执行一键激活吗?"), vm.text("common.prompt", "提示"), {
                    type: "warning",
                    confirmButtonText: "确定",
                    cancelButtonText: "取消"
                    confirmButtonText: vm.text("common.ok", "确定"),
                    cancelButtonText: vm.text("login.mfa.cancel", "取消")
                }).then(function () {
                    ajaxJson({
                        url: baseUrl + "/license/activate",
                        method: "POST",
                        success: function (res) {
                            if (Number(res.code) === 200) {
                                vm.$message.success("激活成功");
                                vm.$message.success(vm.text("login.activate.success", "激活成功"));
                                return;
                            }
                            vm.$message.error(res.msg || "激活失败");
                            vm.$message.error(res.msg || vm.text("login.activate.failed", "激活失败"));
                        },
                        error: function () {
                            vm.$message.error("激活失败");
                            vm.$message.error(vm.text("login.activate.failed", "激活失败"));
                        }
                    });
                }).catch(function () {
@@ -253,15 +468,15 @@
                    method: "GET",
                    success: function (res) {
                        if (Number(res.code) === 200) {
                            vm.$alert(res.msg || "", "项目名称", {
                                confirmButtonText: "确定"
                            vm.$alert(res.msg || "", vm.text("login.projectName.title", "项目名称"), {
                                confirmButtonText: vm.text("common.ok", "确定")
                            });
                            return;
                        }
                        vm.$message.error(res.msg || "获取项目名称失败");
                        vm.$message.error(res.msg || vm.text("login.error.projectNameFailed", "获取项目名称失败"));
                    },
                    error: function () {
                        vm.$message.error("获取项目名称失败");
                        vm.$message.error(vm.text("login.error.projectNameFailed", "获取项目名称失败"));
                    }
                });
            }
src/main/webapp/static/js/user/user.js
@@ -1,3379 +1,406 @@
(function () {
    var simpleEntityName = 'user';
    var entityName = 'User';
    var primaryKeyField = 'id';
    var fieldMeta = dedupeFieldMeta([
    {
        field: 'id',
        columnName: 'id',
        label: 'I  D',
        tableProp: 'id',
        exportField: 'id',
        kind: 'text',
        valueType: 'number',
        required: true,
        primaryKey: true,
        sortable: true,
        textarea: false,
        minWidth: 90,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'username',
        columnName: 'username',
        label: '账  号',
        tableProp: 'username',
        exportField: 'username',
        kind: 'text',
        valueType: 'string',
        required: true,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'password',
        columnName: 'password',
        label: '密  码',
        tableProp: 'password',
        exportField: 'password',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'nickname',
        columnName: 'nickname',
        label: '昵  称',
        tableProp: 'nickname',
        exportField: 'nickname',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'avatar',
        columnName: 'avatar',
        label: '头  像',
        tableProp: 'avatar',
        exportField: 'avatar',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'code',
        columnName: 'code',
        label: '工  号',
        tableProp: 'code',
        exportField: 'code',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'sex',
        columnName: 'sex',
        label: '性  别',
        tableProp: 'sex$',
        exportField: 'sex$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '0', label: '未知' }, { rawValue: '1', label: '男' }, { rawValue: '2', label: '女' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'phone',
        columnName: 'phone',
        label: '手 机 号',
        tableProp: 'phone',
        exportField: 'phone',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 116,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'email',
        columnName: 'email',
        label: '邮  箱',
        tableProp: 'email',
        exportField: 'email',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'emailVerified',
        columnName: 'email_verified',
        label: '邮箱验证',
        tableProp: 'emailVerified$',
        exportField: 'emailVerified$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '0', label: '否' }, { rawValue: '1', label: '是' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deptId',
        columnName: 'dept_id',
        label: '所属部门',
        tableProp: 'deptId$',
        exportField: 'deptId$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'dept',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'realName',
        columnName: 'real_name',
        label: '真实姓名',
        tableProp: 'realName',
        exportField: 'realName',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'idCard',
        columnName: 'id_card',
        label: '身份证号',
        tableProp: 'idCard',
        exportField: 'idCard',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'birthday',
        columnName: 'birthday',
        label: '出生日期',
        tableProp: 'birthday',
        exportField: 'birthday',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'introduction',
        columnName: 'introduction',
        label: '个人简介',
        tableProp: 'introduction',
        exportField: 'introduction',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'hostId',
        columnName: 'host_id',
        label: '所属机构',
        tableProp: 'hostId$',
        exportField: 'hostId$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'host',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'status',
        columnName: 'status',
        label: '状  态',
        tableProp: 'status$',
        exportField: 'status$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '1', label: '正常' }, { rawValue: '0', label: '禁用' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deleted',
        columnName: 'deleted',
        label: '是否删除',
        tableProp: 'deleted$',
        exportField: 'deleted$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '1', label: '是' }, { rawValue: '0', label: '否' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'createTime',
        columnName: 'create_time',
        label: '添加时间',
        tableProp: 'createTime$',
        exportField: 'createTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'createBy',
        columnName: 'create_by',
        label: '添加人员',
        tableProp: 'createBy$',
        exportField: 'createBy$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'user',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'updateTime',
        columnName: 'update_time',
        label: '修改时间',
        tableProp: 'updateTime$',
        exportField: 'updateTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'updateBy',
        columnName: 'update_by',
        label: '修改人员',
        tableProp: 'updateBy$',
        exportField: 'updateBy$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'user',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'memo',
        columnName: 'memo',
        label: '备  注',
        tableProp: 'memo',
        exportField: 'memo',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: true,
        minWidth: 180,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'id',
        columnName: 'id',
        label: '*编  号',
        tableProp: 'id',
        exportField: 'id',
        kind: 'text',
        valueType: 'number',
        required: true,
        primaryKey: true,
        sortable: true,
        textarea: false,
        minWidth: 90,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'hostId',
        columnName: 'host_id',
        label: '*授权商户',
        tableProp: 'hostId',
        exportField: 'hostId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deptId',
        columnName: 'dept_id',
        label: '',
        tableProp: 'deptId',
        exportField: 'deptId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'roleId',
        columnName: 'role_id',
        label: '*角  色',
        tableProp: 'roleId',
        exportField: 'roleId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'username',
        columnName: 'username',
        label: '登录账户',
        tableProp: 'username',
        exportField: 'username',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'nickname',
        columnName: 'nickname',
        label: '用户名',
        tableProp: 'nickname',
        exportField: 'nickname',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'mobile',
        columnName: 'mobile',
        label: '手机号',
        tableProp: 'mobile',
        exportField: 'mobile',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'password',
        columnName: 'password',
        label: '密  码',
        tableProp: 'password',
        exportField: 'password',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'avatar',
        columnName: 'avatar',
        label: '',
        tableProp: 'avatar',
        exportField: 'avatar',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'email',
        columnName: 'email',
        label: '邮箱',
        tableProp: 'email',
        exportField: 'email',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'sex',
        columnName: 'sex',
        label: '性别',
        tableProp: 'sex',
        exportField: 'sex',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'createTime',
        columnName: 'create_time',
        label: '注册时间',
        tableProp: 'createTime$',
        exportField: 'createTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'status',
        columnName: 'status',
        label: '状态',
        tableProp: 'status',
        exportField: 'status',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'id',
        columnName: 'id',
        label: '*编  号',
        tableProp: 'id',
        exportField: 'id',
        kind: 'text',
        valueType: 'number',
        required: true,
        primaryKey: true,
        sortable: true,
        textarea: false,
        minWidth: 90,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'hostId',
        columnName: 'host_id',
        label: '*授权商户',
        tableProp: 'hostId',
        exportField: 'hostId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deptId',
        columnName: 'dept_id',
        label: '',
        tableProp: 'deptId',
        exportField: 'deptId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'roleId',
        columnName: 'role_id',
        label: '*角  色',
        tableProp: 'roleId',
        exportField: 'roleId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'username',
        columnName: 'username',
        label: '登录账户',
        tableProp: 'username',
        exportField: 'username',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'nickname',
        columnName: 'nickname',
        label: '用户名',
        tableProp: 'nickname',
        exportField: 'nickname',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'mobile',
        columnName: 'mobile',
        label: '手机号',
        tableProp: 'mobile',
        exportField: 'mobile',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'password',
        columnName: 'password',
        label: '密  码',
        tableProp: 'password',
        exportField: 'password',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'avatar',
        columnName: 'avatar',
        label: '',
        tableProp: 'avatar',
        exportField: 'avatar',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'email',
        columnName: 'email',
        label: '邮箱',
        tableProp: 'email',
        exportField: 'email',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'sex',
        columnName: 'sex',
        label: '性别',
        tableProp: 'sex',
        exportField: 'sex',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'createTime',
        columnName: 'create_time',
        label: '注册时间',
        tableProp: 'createTime$',
        exportField: 'createTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'status',
        columnName: 'status',
        label: '状态',
        tableProp: 'status',
        exportField: 'status',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'id',
        columnName: 'id',
        label: 'I  D',
        tableProp: 'id',
        exportField: 'id',
        kind: 'text',
        valueType: 'number',
        required: true,
        primaryKey: true,
        sortable: true,
        textarea: false,
        minWidth: 90,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'username',
        columnName: 'username',
        label: '账  号',
        tableProp: 'username',
        exportField: 'username',
        kind: 'text',
        valueType: 'string',
        required: true,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'password',
        columnName: 'password',
        label: '密  码',
        tableProp: 'password',
        exportField: 'password',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'nickname',
        columnName: 'nickname',
        label: '昵  称',
        tableProp: 'nickname',
        exportField: 'nickname',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'avatar',
        columnName: 'avatar',
        label: '头  像',
        tableProp: 'avatar',
        exportField: 'avatar',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'code',
        columnName: 'code',
        label: '工  号',
        tableProp: 'code',
        exportField: 'code',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'sex',
        columnName: 'sex',
        label: '性  别',
        tableProp: 'sex$',
        exportField: 'sex$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '0', label: '未知' }, { rawValue: '1', label: '男' }, { rawValue: '2', label: '女' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'phone',
        columnName: 'phone',
        label: '手 机 号',
        tableProp: 'phone',
        exportField: 'phone',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 116,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'email',
        columnName: 'email',
        label: '邮  箱',
        tableProp: 'email',
        exportField: 'email',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'emailVerified',
        columnName: 'email_verified',
        label: '邮箱验证',
        tableProp: 'emailVerified$',
        exportField: 'emailVerified$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '0', label: '否' }, { rawValue: '1', label: '是' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deptId',
        columnName: 'dept_id',
        label: '所属部门',
        tableProp: 'deptId$',
        exportField: 'deptId$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'dept',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'realName',
        columnName: 'real_name',
        label: '真实姓名',
        tableProp: 'realName',
        exportField: 'realName',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'idCard',
        columnName: 'id_card',
        label: '身份证号',
        tableProp: 'idCard',
        exportField: 'idCard',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'birthday',
        columnName: 'birthday',
        label: '出生日期',
        tableProp: 'birthday',
        exportField: 'birthday',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'introduction',
        columnName: 'introduction',
        label: '个人简介',
        tableProp: 'introduction',
        exportField: 'introduction',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'tenantId',
        columnName: 'tenant_id',
        label: '所属机构',
        tableProp: 'tenantId$',
        exportField: 'tenantId$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'host',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'status',
        columnName: 'status',
        label: '状  态',
        tableProp: 'status$',
        exportField: 'status$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '1', label: '正常' }, { rawValue: '0', label: '禁用' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deleted',
        columnName: 'deleted',
        label: '是否删除',
        tableProp: 'deleted$',
        exportField: 'deleted$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '1', label: '是' }, { rawValue: '0', label: '否' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'createTime',
        columnName: 'create_time',
        label: '添加时间',
        tableProp: 'createTime$',
        exportField: 'createTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'createBy',
        columnName: 'create_by',
        label: '添加人员',
        tableProp: 'createBy$',
        exportField: 'createBy$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'user',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'updateTime',
        columnName: 'update_time',
        label: '修改时间',
        tableProp: 'updateTime$',
        exportField: 'updateTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'updateBy',
        columnName: 'update_by',
        label: '修改人员',
        tableProp: 'updateBy$',
        exportField: 'updateBy$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'user',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'memo',
        columnName: 'memo',
        label: '备  注',
        tableProp: 'memo',
        exportField: 'memo',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: true,
        minWidth: 180,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'id',
        columnName: 'id',
        label: '*编  号',
        tableProp: 'id',
        exportField: 'id',
        kind: 'text',
        valueType: 'number',
        required: true,
        primaryKey: true,
        sortable: true,
        textarea: false,
        minWidth: 90,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'hostId',
        columnName: 'host_id',
        label: '*授权商户',
        tableProp: 'hostId',
        exportField: 'hostId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deptId',
        columnName: 'dept_id',
        label: '',
        tableProp: 'deptId',
        exportField: 'deptId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'roleId',
        columnName: 'role_id',
        label: '*角  色',
        tableProp: 'roleId',
        exportField: 'roleId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'username',
        columnName: 'username',
        label: '登录账户',
        tableProp: 'username',
        exportField: 'username',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'nickname',
        columnName: 'nickname',
        label: '用户名',
        tableProp: 'nickname',
        exportField: 'nickname',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'mobile',
        columnName: 'mobile',
        label: '手机号',
        tableProp: 'mobile',
        exportField: 'mobile',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'password',
        columnName: 'password',
        label: '密  码',
        tableProp: 'password',
        exportField: 'password',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'avatar',
        columnName: 'avatar',
        label: '',
        tableProp: 'avatar',
        exportField: 'avatar',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'email',
        columnName: 'email',
        label: '邮箱',
        tableProp: 'email',
        exportField: 'email',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'sex',
        columnName: 'sex',
        label: '性别',
        tableProp: 'sex',
        exportField: 'sex',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'createTime',
        columnName: 'create_time',
        label: '注册时间',
        tableProp: 'createTime$',
        exportField: 'createTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'status',
        columnName: 'status',
        label: '状态',
        tableProp: 'status',
        exportField: 'status',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'id',
        columnName: 'id',
        label: '*编  号',
        tableProp: 'id',
        exportField: 'id',
        kind: 'text',
        valueType: 'number',
        required: true,
        primaryKey: true,
        sortable: true,
        textarea: false,
        minWidth: 90,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'hostId',
        columnName: 'host_id',
        label: '*授权商户',
        tableProp: 'hostId',
        exportField: 'hostId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deptId',
        columnName: 'dept_id',
        label: '',
        tableProp: 'deptId',
        exportField: 'deptId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'roleId',
        columnName: 'role_id',
        label: '*角  色',
        tableProp: 'roleId',
        exportField: 'roleId',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'username',
        columnName: 'username',
        label: '登录账户',
        tableProp: 'username',
        exportField: 'username',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'nickname',
        columnName: 'nickname',
        label: '用户名',
        tableProp: 'nickname',
        exportField: 'nickname',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'mobile',
        columnName: 'mobile',
        label: '手机号',
        tableProp: 'mobile',
        exportField: 'mobile',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'password',
        columnName: 'password',
        label: '密  码',
        tableProp: 'password',
        exportField: 'password',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'avatar',
        columnName: 'avatar',
        label: '',
        tableProp: 'avatar',
        exportField: 'avatar',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'email',
        columnName: 'email',
        label: '邮箱',
        tableProp: 'email',
        exportField: 'email',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'sex',
        columnName: 'sex',
        label: '性别',
        tableProp: 'sex',
        exportField: 'sex',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'createTime',
        columnName: 'create_time',
        label: '注册时间',
        tableProp: 'createTime$',
        exportField: 'createTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'status',
        columnName: 'status',
        label: '状态',
        tableProp: 'status',
        exportField: 'status',
        kind: 'text',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'id',
        columnName: 'id',
        label: 'I  D',
        tableProp: 'id',
        exportField: 'id',
        kind: 'text',
        valueType: 'number',
        required: true,
        primaryKey: true,
        sortable: true,
        textarea: false,
        minWidth: 90,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'username',
        columnName: 'username',
        label: '账  号',
        tableProp: 'username',
        exportField: 'username',
        kind: 'text',
        valueType: 'string',
        required: true,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'password',
        columnName: 'password',
        label: '密  码',
        tableProp: 'password',
        exportField: 'password',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'nickname',
        columnName: 'nickname',
        label: '昵  称',
        tableProp: 'nickname',
        exportField: 'nickname',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'avatar',
        columnName: 'avatar',
        label: '头  像',
        tableProp: 'avatar',
        exportField: 'avatar',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'code',
        columnName: 'code',
        label: '工  号',
        tableProp: 'code',
        exportField: 'code',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'sex',
        columnName: 'sex',
        label: '性  别',
        tableProp: 'sex$',
        exportField: 'sex$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '0', label: '未知' }, { rawValue: '1', label: '男' }, { rawValue: '2', label: '女' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'phone',
        columnName: 'phone',
        label: '手 机 号',
        tableProp: 'phone',
        exportField: 'phone',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 116,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'email',
        columnName: 'email',
        label: '邮  箱',
        tableProp: 'email',
        exportField: 'email',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'emailVerified',
        columnName: 'email_verified',
        label: '邮箱验证',
        tableProp: 'emailVerified$',
        exportField: 'emailVerified$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '0', label: '否' }, { rawValue: '1', label: '是' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deptId',
        columnName: 'dept_id',
        label: '所属部门',
        tableProp: 'deptId$',
        exportField: 'deptId$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'dept',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'realName',
        columnName: 'real_name',
        label: '真实姓名',
        tableProp: 'realName',
        exportField: 'realName',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'idCard',
        columnName: 'id_card',
        label: '身份证号',
        tableProp: 'idCard',
        exportField: 'idCard',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'birthday',
        columnName: 'birthday',
        label: '出生日期',
        tableProp: 'birthday',
        exportField: 'birthday',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'introduction',
        columnName: 'introduction',
        label: '个人简介',
        tableProp: 'introduction',
        exportField: 'introduction',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'hostId',
        columnName: 'host_id',
        label: '所属机构',
        tableProp: 'hostId$',
        exportField: 'hostId$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'host',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'status',
        columnName: 'status',
        label: '状  态',
        tableProp: 'status$',
        exportField: 'status$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '1', label: '正常' }, { rawValue: '0', label: '禁用' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deleted',
        columnName: 'deleted',
        label: '是否删除',
        tableProp: 'deleted$',
        exportField: 'deleted$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '1', label: '是' }, { rawValue: '0', label: '否' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'createTime',
        columnName: 'create_time',
        label: '添加时间',
        tableProp: 'createTime$',
        exportField: 'createTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'createBy',
        columnName: 'create_by',
        label: '添加人员',
        tableProp: 'createBy$',
        exportField: 'createBy$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'user',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'updateTime',
        columnName: 'update_time',
        label: '修改时间',
        tableProp: 'updateTime$',
        exportField: 'updateTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'updateBy',
        columnName: 'update_by',
        label: '修改人员',
        tableProp: 'updateBy$',
        exportField: 'updateBy$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'user',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'memo',
        columnName: 'memo',
        label: '备  注',
        tableProp: 'memo',
        exportField: 'memo',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: true,
        minWidth: 180,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'id',
        columnName: 'id',
        label: 'I  D',
        tableProp: 'id',
        exportField: 'id',
        kind: 'text',
        valueType: 'number',
        required: true,
        primaryKey: true,
        sortable: true,
        textarea: false,
        minWidth: 90,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'username',
        columnName: 'username',
        label: '账  号',
        tableProp: 'username',
        exportField: 'username',
        kind: 'text',
        valueType: 'string',
        required: true,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'password',
        columnName: 'password',
        label: '密  码',
        tableProp: 'password',
        exportField: 'password',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'nickname',
        columnName: 'nickname',
        label: '昵  称',
        tableProp: 'nickname',
        exportField: 'nickname',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'avatar',
        columnName: 'avatar',
        label: '头  像',
        tableProp: 'avatar',
        exportField: 'avatar',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'code',
        columnName: 'code',
        label: '工  号',
        tableProp: 'code',
        exportField: 'code',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'sex',
        columnName: 'sex',
        label: '性  别',
        tableProp: 'sex$',
        exportField: 'sex$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '0', label: '未知' }, { rawValue: '1', label: '男' }, { rawValue: '2', label: '女' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'phone',
        columnName: 'phone',
        label: '手 机 号',
        tableProp: 'phone',
        exportField: 'phone',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 116,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'email',
        columnName: 'email',
        label: '邮  箱',
        tableProp: 'email',
        exportField: 'email',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'emailVerified',
        columnName: 'email_verified',
        label: '邮箱验证',
        tableProp: 'emailVerified$',
        exportField: 'emailVerified$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '0', label: '否' }, { rawValue: '1', label: '是' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deptId',
        columnName: 'dept_id',
        label: '所属部门',
        tableProp: 'deptId$',
        exportField: 'deptId$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'dept',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'realName',
        columnName: 'real_name',
        label: '真实姓名',
        tableProp: 'realName',
        exportField: 'realName',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'idCard',
        columnName: 'id_card',
        label: '身份证号',
        tableProp: 'idCard',
        exportField: 'idCard',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'birthday',
        columnName: 'birthday',
        label: '出生日期',
        tableProp: 'birthday',
        exportField: 'birthday',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'introduction',
        columnName: 'introduction',
        label: '个人简介',
        tableProp: 'introduction',
        exportField: 'introduction',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'hostId',
        columnName: 'host_id',
        label: '所属机构',
        tableProp: 'hostId$',
        exportField: 'hostId$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'host',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'status',
        columnName: 'status',
        label: '状  态',
        tableProp: 'status$',
        exportField: 'status$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '1', label: '正常' }, { rawValue: '0', label: '禁用' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'deleted',
        columnName: 'deleted',
        label: '是否删除',
        tableProp: 'deleted$',
        exportField: 'deleted$',
        kind: 'enum',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 120,
        enumOptions: [{ rawValue: '1', label: '是' }, { rawValue: '0', label: '否' }],
        foreignQuery: '',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'createTime',
        columnName: 'create_time',
        label: '添加时间',
        tableProp: 'createTime$',
        exportField: 'createTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'createBy',
        columnName: 'create_by',
        label: '添加人员',
        tableProp: 'createBy$',
        exportField: 'createBy$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'user',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'updateTime',
        columnName: 'update_time',
        label: '修改时间',
        tableProp: 'updateTime$',
        exportField: 'updateTime$',
        kind: 'date',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 168,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    },
    {
        field: 'updateBy',
        columnName: 'update_by',
        label: '修改人员',
        tableProp: 'updateBy$',
        exportField: 'updateBy$',
        kind: 'foreign',
        valueType: 'number',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: false,
        minWidth: 110,
        enumOptions: [],
        foreignQuery: 'user',
        checkboxActiveRaw: '1',
        checkboxInactiveRaw: '0'
    },
    {
        field: 'memo',
        columnName: 'memo',
        label: '备  注',
        tableProp: 'memo',
        exportField: 'memo',
        kind: 'text',
        valueType: 'string',
        required: false,
        primaryKey: false,
        sortable: false,
        textarea: true,
        minWidth: 180,
        enumOptions: [],
        foreignQuery: '',
        checkboxActiveRaw: 'Y',
        checkboxInactiveRaw: 'N'
    }
    ]);
    function formatFieldLabel(field) {
        var raw = field && field.label ? String(field.label).trim() : '';
        if (raw) {
            return raw;
        }
        raw = field && field.columnName ? field.columnName : (field && field.field ? field.field : '');
        if (!raw) {
            return '';
        }
        raw = String(raw)
            .replace(/\$/g, '')
            .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
            .replace(/_/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
        return raw.replace(/\b[a-z]/g, function (letter) {
            return letter.toUpperCase();
        });
    }
    function dedupeFieldMeta(list) {
        var result = [];
        var seen = {};
        (list || []).forEach(function (field) {
            if (!field || !field.field || seen[field.field]) {
                return;
            }
            field.label = formatFieldLabel(field);
            seen[field.field] = true;
            result.push(field);
        });
        return result;
    }
    function isEmptyValue(value) {
        return value === null || value === undefined || value === '';
    }
    function stringValue(value) {
        return isEmptyValue(value) ? '' : String(value);
    }
    function valueOrDash(value) {
        return isEmptyValue(value) ? '--' : value;
    }
    function normalizeOptionValue(field, rawValue) {
        if (rawValue === null || rawValue === undefined) {
            return null;
        }
        if (rawValue === '') {
            return '';
        }
        if (field && field.valueType === 'number') {
            var numberVal = Number(rawValue);
            return isNaN(numberVal) ? rawValue : numberVal;
        }
        return String(rawValue);
    }
    function isSearchableField(field) {
        return !!field && field.kind !== 'image' && !field.textarea;
    }
    function isSortableField(field) {
        if (!field) {
            return false;
        }
        if (field.primaryKey) {
            return true;
        }
        return field.kind !== 'image' && !field.textarea && field.kind !== 'foreign';
    }
    function defaultFieldValue(field) {
        if (field.primaryKey) {
            return null;
        }
        if (field.kind === 'checkbox') {
            return normalizeOptionValue(field, field.checkboxInactiveRaw);
        }
        return '';
    }
    function defaultSearchFieldValue(field) {
        if (field.kind === 'date') {
            return [];
        }
        if (field.kind === 'enum' || field.kind === 'checkbox') {
            return null;
        }
        return '';
    }
    function createSearchDefaults() {
        var result = {
            condition: ''
        };
        fieldMeta.forEach(function (field) {
            if (!isSearchableField(field)) {
                return;
            }
            result[field.field] = defaultSearchFieldValue(field);
        });
        return result;
    }
    function createSearchDisplayDefaults() {
        var result = {};
        fieldMeta.forEach(function (field) {
            if (field.kind === 'foreign' && isSearchableField(field)) {
                result[field.field] = '';
            }
        });
        return result;
    }
    function createDefaultVisibleColumnKeys() {
        return fieldMeta.map(function (field) {
            return field.field;
        });
    }
    function createFormDefaults() {
        var result = {};
        fieldMeta.forEach(function (field) {
            result[field.field] = defaultFieldValue(field);
        });
        return result;
    }
    function createDisplayDefaults() {
        var result = {};
        fieldMeta.forEach(function (field) {
            if (field.kind === 'foreign') {
                result[field.field] = '';
            }
        });
        return result;
    }
    function createFormRules() {
        var rules = {};
        fieldMeta.forEach(function (field) {
            if (field.primaryKey || !field.required) {
                return;
            }
            rules[field.field] = [{
                required: true,
                message: (field.kind === 'date' || field.kind === 'enum' ? '请选择' : '请输入') + field.label,
                trigger: (field.kind === 'date' || field.kind === 'enum') ? 'change' : 'blur'
            }];
        });
        return rules;
    }
    function getTableValue(row, field) {
        var prop = field.tableProp || field.field;
        if (row && !isEmptyValue(row[prop])) {
            return row[prop];
        }
        return row ? row[field.field] : '';
    }
    function isCheckboxChecked(row, field) {
        var value = row ? row[field.field] : null;
        var activeValue = normalizeOptionValue(field, field.checkboxActiveRaw);
        return String(value) === String(activeValue);
    }
    function exportCell(value) {
        return stringValue(value).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
    }
    function escapeHtml(value) {
        return exportCell(value)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    }
    function buildPayload(form) {
        var payload = {};
        fieldMeta.forEach(function (field) {
            var value = form[field.field];
            if (field.primaryKey) {
                if (!isEmptyValue(value)) {
                    payload[field.field] = value;
                }
                return;
            }
            if (field.kind === 'foreign' && isEmptyValue(value)) {
                value = null;
            }
            if (field.kind === 'enum' && value === '') {
                value = null;
            }
            if (field.kind === 'checkbox' && isEmptyValue(value)) {
                value = normalizeOptionValue(field, field.checkboxInactiveRaw);
            }
            if (field.valueType === 'number' && !isEmptyValue(value)) {
                value = Number(value);
            }
            if (field.valueType === 'number' && value === '') {
                value = null;
            }
            payload[field.field] = value;
        });
        return payload;
    }
    function fillFormFromRow(row, form, display) {
        fieldMeta.forEach(function (field) {
            if (field.primaryKey) {
                form[field.field] = row[field.field];
                return;
            }
            if (field.kind === 'date') {
                form[field.field] = row[field.tableProp] || row[field.field] || '';
                return;
            }
            if (field.kind === 'foreign') {
                form[field.field] = isEmptyValue(row[field.field]) ? '' : normalizeOptionValue(field, row[field.field]);
                if (display) {
                    display[field.field] = row[field.tableProp] || (isEmptyValue(row[field.field]) ? '' : String(row[field.field]));
                }
                return;
            }
            if (field.kind === 'enum') {
                form[field.field] = isEmptyValue(row[field.field]) ? '' : normalizeOptionValue(field, row[field.field]);
                return;
            }
            if (field.kind === 'checkbox') {
                form[field.field] = isEmptyValue(row[field.field])
                    ? normalizeOptionValue(field, field.checkboxInactiveRaw)
                    : normalizeOptionValue(field, row[field.field]);
                return;
            }
            form[field.field] = isEmptyValue(row[field.field])
                ? ''
                : (field.valueType === 'number' ? String(row[field.field]) : row[field.field]);
        });
    }
    function resolveSearchParam(field) {
        if (field.kind === 'date' && field.columnName) {
            return field.columnName;
        }
        return field.field;
    }
    function createDownloadFile(filename, titles, rows) {
        var html = [
            '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">',
            '<head><meta charset="UTF-8"></head><body><table border="1"><thead><tr>',
            titles.map(function (title) {
                return '<th>' + escapeHtml(title) + '</th>';
            }).join(''),
            '</tr></thead><tbody>',
            (rows || []).map(function (row) {
                return '<tr>' + (row || []).map(function (value) {
                    return '<td style="mso-number-format:\\@;">' + escapeHtml(value) + '</td>';
                }).join('') + '</tr>';
            }).join(''),
            '</tbody></table></body></html>'
        ].join('');
        var blob = new Blob(['\ufeff' + html], {
            type: 'application/vnd.ms-excel;charset=utf-8;'
        });
        var anchor = document.createElement('a');
        anchor.href = URL.createObjectURL(blob);
        anchor.download = filename;
        document.body.appendChild(anchor);
        anchor.click();
        setTimeout(function () {
            URL.revokeObjectURL(anchor.href);
            document.body.removeChild(anchor);
        }, 0);
    }
    function buildTimestamp() {
        var now = new Date();
        var pad = function (num) {
            return num < 10 ? '0' + num : String(num);
        };
        return now.getFullYear()
            + pad(now.getMonth() + 1)
            + pad(now.getDate())
            + '_'
            + pad(now.getHours())
            + pad(now.getMinutes())
            + pad(now.getSeconds());
    }
    function authHeaders() {
        return {
            token: localStorage.getItem('token')
        };
    }
    function handleForbidden(res) {
        if (res && res.code === 403) {
            top.location.href = baseUrl + '/';
        if (res && Number(res.code) === 403) {
            top.location.href = baseUrl + "/";
            return true;
        }
        return false;
    }
    var sharedMethods = {
        authHeaders: authHeaders,
        handleForbidden: handleForbidden,
        valueOrDash: valueOrDash,
        stringValue: stringValue,
        getTableValue: getTableValue,
        isCheckboxChecked: isCheckboxChecked,
        normalizeOptionValue: normalizeOptionValue,
        isSortableField: isSortableField,
        getSuggestionFetcher: function (field) {
            var self = this;
            return function (queryString, callback) {
                self.fetchForeignSuggestions(field, queryString, callback);
    new Vue({
        el: "#app",
        data: function () {
            return {
                loading: false,
                dialogSubmitting: false,
                resetSubmitting: false,
                searchForm: {
                    username: "",
                    mobile: ""
                },
                tableData: [],
                selection: [],
                roles: [],
                page: {
                    curr: 1,
                    limit: 15,
                    total: 0
                },
                dialog: {
                    visible: false,
                    mode: "create"
                },
                dialogForm: {
                    id: null,
                    username: "",
                    mobile: "",
                    password: "",
                    roleId: null,
                    status: 1,
                    mfaAllow: 0
                },
                resetDialog: {
                    visible: false,
                    userId: null,
                    username: "",
                    password: ""
                },
                dialogRules: {
                    username: [
                        { required: true, message: "请输入登录账户", trigger: "blur" }
                    ],
                    mobile: [
                        { required: true, message: "请输入账号", trigger: "blur" }
                    ],
                    roleId: [
                        { required: true, message: "请选择角色", trigger: "change" }
                    ],
                    password: [
                        {
                            validator: function (rule, value, callback) {
                                if (this.dialog.mode !== "create") {
                                    callback();
                                    return;
                                }
                                if (!value) {
                                    callback(new Error("请输入初始密码"));
                                    return;
                                }
                                if (String(value).length < 4) {
                                    callback(new Error("初始密码不能少于4位"));
                                    return;
                                }
                                callback();
                            }.bind(this),
                            trigger: "blur"
                        }
                    ]
                },
                resetRules: {
                    password: [
                        { required: true, message: "请输入新密码", trigger: "blur" },
                        {
                            validator: function (rule, value, callback) {
                                if (String(value || "").length < 4) {
                                    callback(new Error("新密码不能少于4位"));
                                    return;
                                }
                                callback();
                            },
                            trigger: "blur"
                        }
                    ]
                }
            };
        },
        fetchForeignSuggestions: function (field, queryString, callback) {
            if (!field.foreignQuery || !queryString) {
                callback([]);
                return;
            }
            var self = this;
            $.ajax({
                url: baseUrl + '/' + field.foreignQuery + 'Query/auth',
                method: 'GET',
                headers: self.authHeaders(),
                data: { condition: queryString },
                success: function (res) {
                    if (self.handleForbidden(res)) {
                        return;
                    }
                    if (!res || res.code !== 200 || !Array.isArray(res.data)) {
                        callback([]);
                        return;
                    }
                    callback(res.data.map(function (item) {
                        return {
                            id: item.id,
                            value: item.value
                        };
                    }));
                },
                error: function () {
                    callback([]);
                }
            });
        created: function () {
            this.loadRoles();
            this.loadTable();
        },
        handleForeignSelect: function (field, item) {
            this.$set(this.displayTarget, field.field, item && item.value ? item.value : '');
            this.$set(this.formTarget, field.field, item && item.id !== undefined ? normalizeOptionValue(field, item.id) : '');
        },
        handleForeignInput: function (field) {
            if (!this.displayTarget || !this.formTarget) {
                return;
            }
            if (this.displayTarget[field.field]) {
                return;
            }
            this.$set(this.formTarget, field.field, '');
        }
    };
    if (document.getElementById('app')) {
        new Vue({
            el: '#app',
            data: function () {
                return {
                    fieldMeta: fieldMeta,
                    primaryKeyField: primaryKeyField,
                    loading: false,
                    exporting: false,
                    tableData: [],
                    selection: [],
                    advancedFiltersVisible: false,
                    allColumns: fieldMeta.slice(),
                    visibleColumnKeys: createDefaultVisibleColumnKeys(),
                    searchForm: createSearchDefaults(),
                    searchDisplay: createSearchDisplayDefaults(),
                    page: {
                        curr: 1,
                        limit: 15,
                        total: 0
                    },
                    sortState: {
                        prop: '',
                        order: ''
                    },
                    dialog: {
                        visible: false,
                        mode: 'create',
                        submitting: false
                    },
                    layoutTimer: null,
                    tableResizeHandler: null,
                    dialogForm: createFormDefaults(),
                    dialogDisplay: createDisplayDefaults(),
                    dialogRules: createFormRules()
                };
        methods: {
            authHeaders: function () {
                return { token: localStorage.getItem("token") };
            },
            computed: {
                searchableFields: function () {
                    return this.fieldMeta.filter(function (field) {
                        return isSearchableField(field);
                    });
                },
                quickSearchableFields: function () {
                    var result = [];
                    this.searchableFields.forEach(function (field) {
                        if (result.length >= 3 || field.kind === 'date') {
            loadRoles: function () {
                var vm = this;
                $.ajax({
                    url: baseUrl + "/role/list/auth",
                    method: "GET",
                    headers: vm.authHeaders(),
                    data: {
                        curr: 1,
                        limit: 500
                    },
                    success: function (res) {
                        if (handleForbidden(res)) {
                            return;
                        }
                        result.push(field);
                    });
                    return result;
                },
                advancedSearchableFields: function () {
                    var quickKeys = this.quickSearchableFields.map(function (field) {
                        return field.field;
                    });
                    return this.searchableFields.filter(function (field) {
                        return quickKeys.indexOf(field.field) === -1;
                    });
                },
                hasAdvancedFilters: function () {
                    return this.advancedSearchableFields.length > 0;
                },
                visibleColumns: function () {
                    var keys = this.visibleColumnKeys;
                    return this.allColumns.filter(function (field) {
                        return keys.indexOf(field.field) !== -1;
                    });
                },
                editableFields: function () {
                    return this.fieldMeta.filter(function (field) {
                        return !field.primaryKey;
                    });
                },
                exportColumns: function () {
                    return this.visibleColumns.map(function (field) {
                        return {
                            field: field.exportField || field.tableProp || field.field,
                            label: field.label
                        };
                    });
                },
                tableHeight: function () {
                    return this.advancedFiltersVisible && this.hasAdvancedFilters
                        ? 'calc(100vh - 390px)'
                        : 'calc(100vh - 300px)';
                },
                formTarget: function () {
                    return this.dialogForm;
                },
                displayTarget: function () {
                    return this.dialogDisplay;
                }
                        if (Number(res.code) !== 200) {
                            vm.$message.error(res.msg || "角色加载失败");
                            return;
                        }
                        var payload = res.data || {};
                        vm.roles = Array.isArray(payload.records) ? payload.records : [];
                    },
                    error: function () {
                        vm.$message.error("角色加载失败");
                    }
                });
            },
            created: function () {
            loadTable: function () {
                var vm = this;
                vm.loading = true;
                $.ajax({
                    url: baseUrl + "/user/list/auth",
                    method: "GET",
                    headers: vm.authHeaders(),
                    data: {
                        curr: vm.page.curr,
                        limit: vm.page.limit,
                        username: vm.searchForm.username,
                        mobile: vm.searchForm.mobile
                    },
                    success: function (res) {
                        if (handleForbidden(res)) {
                            return;
                        }
                        if (Number(res.code) !== 200) {
                            vm.$message.error(res.msg || "加载失败");
                            return;
                        }
                        var payload = res.data || {};
                        vm.tableData = Array.isArray(payload.records) ? payload.records : [];
                        vm.page.total = Number(payload.total || 0);
                    },
                    error: function () {
                        vm.$message.error("加载失败");
                    },
                    complete: function () {
                        vm.loading = false;
                    }
                });
            },
            handleSearch: function () {
                this.page.curr = 1;
                this.loadTable();
            },
            mounted: function () {
                var self = this;
                self.requestTableLayout(80);
                self.tableResizeHandler = function () {
                    self.requestTableLayout(80);
            handleResetSearch: function () {
                this.searchForm = {
                    username: "",
                    mobile: ""
                };
                window.addEventListener('resize', self.tableResizeHandler);
                this.page.curr = 1;
                this.loadTable();
            },
            beforeDestroy: function () {
                if (this.layoutTimer) {
                    clearTimeout(this.layoutTimer);
                    this.layoutTimer = null;
                }
                if (this.tableResizeHandler) {
                    window.removeEventListener('resize', this.tableResizeHandler);
                    this.tableResizeHandler = null;
                }
            handleSelectionChange: function (rows) {
                this.selection = rows || [];
            },
            methods: $.extend({}, sharedMethods, {
                requestTableLayout: function (delay) {
                    var self = this;
                    if (self.layoutTimer) {
                        clearTimeout(self.layoutTimer);
            handleCurrentChange: function (curr) {
                this.page.curr = curr;
                this.loadTable();
            },
            handleSizeChange: function (limit) {
                this.page.limit = limit;
                this.page.curr = 1;
                this.loadTable();
            },
            openCreateDialog: function () {
                this.dialog.mode = "create";
                this.dialog.visible = true;
                this.dialogForm = {
                    id: null,
                    username: "",
                    mobile: "",
                    password: "",
                    roleId: null,
                    status: 1,
                    mfaAllow: 0
                };
                this.clearDialogValidate();
            },
            openEditDialog: function (row) {
                this.dialog.mode = "edit";
                this.dialog.visible = true;
                this.dialogForm = {
                    id: row.id,
                    username: row.username || "",
                    mobile: row.mobile || "",
                    password: "",
                    roleId: row.roleId == null ? null : Number(row.roleId),
                    status: row.status == null ? 1 : Number(row.status),
                    mfaAllow: row.mfaAllow == null ? 0 : Number(row.mfaAllow)
                };
                this.clearDialogValidate();
            },
            clearDialogValidate: function () {
                var vm = this;
                vm.$nextTick(function () {
                    if (vm.$refs.dialogForm) {
                        vm.$refs.dialogForm.clearValidate();
                    }
                    self.$nextTick(function () {
                        self.layoutTimer = setTimeout(function () {
                            var table = self.$refs.dataTable;
                            if (table && typeof table.doLayout === 'function') {
                                table.doLayout();
                });
            },
            closeDialog: function () {
                this.dialog.visible = false;
            },
            submitDialog: function () {
                var vm = this;
                if (!vm.$refs.dialogForm) {
                    return;
                }
                vm.$refs.dialogForm.validate(function (valid) {
                    if (!valid) {
                        return false;
                    }
                    vm.dialogSubmitting = true;
                    $.ajax({
                        url: baseUrl + "/user/" + (vm.dialog.mode === "create" ? "add" : "update") + "/auth",
                        method: "POST",
                        headers: vm.authHeaders(),
                        data: vm.buildDialogPayload(),
                        success: function (res) {
                            if (handleForbidden(res)) {
                                return;
                            }
                        }, delay || 40);
                    });
                },
                isColumnVisible: function (fieldName) {
                    return this.visibleColumnKeys.indexOf(fieldName) !== -1;
                },
                toggleColumn: function (fieldName, visible) {
                    if (visible) {
                        if (this.visibleColumnKeys.indexOf(fieldName) === -1) {
                            this.visibleColumnKeys.push(fieldName);
                            if (Number(res.code) !== 200) {
                                vm.$message.error(res.msg || "保存失败");
                                return;
                            }
                            vm.$message.success(res.msg || "保存成功");
                            vm.dialog.visible = false;
                            vm.loadTable();
                        },
                        error: function () {
                            vm.$message.error("保存失败");
                        },
                        complete: function () {
                            vm.dialogSubmitting = false;
                        }
                        this.requestTableLayout(80);
                        return;
                    }
                    if (this.visibleColumnKeys.length === 1) {
                        this.$message.warning('至少保留一列');
                        return;
                    }
                    this.visibleColumnKeys = this.visibleColumnKeys.filter(function (item) {
                        return item !== fieldName;
                    });
                    this.requestTableLayout(80);
                },
                selectAllColumns: function () {
                    this.visibleColumnKeys = createDefaultVisibleColumnKeys();
                    this.requestTableLayout(80);
                },
                resetColumns: function () {
                    this.visibleColumnKeys = createDefaultVisibleColumnKeys();
                    this.requestTableLayout(80);
                },
                toggleAdvancedFilters: function () {
                    this.advancedFiltersVisible = !this.advancedFiltersVisible;
                    this.requestTableLayout(260);
                },
                handleSearchForeignSelect: function (field, item) {
                    this.$set(this.searchDisplay, field.field, item && item.value ? item.value : '');
                    this.$set(this.searchForm, field.field, item && item.id !== undefined ? normalizeOptionValue(field, item.id) : '');
                },
                handleSearchForeignInput: function (field) {
                    if (this.searchDisplay[field.field]) {
                        return;
                    return true;
                });
            },
            buildDialogPayload: function () {
                var payload = {
                    username: this.dialogForm.username,
                    mobile: this.dialogForm.mobile,
                    roleId: this.dialogForm.roleId,
                    status: this.dialogForm.status,
                    mfaAllow: this.dialogForm.mfaAllow
                };
                if (this.dialog.mode === "edit") {
                    payload.id = this.dialogForm.id;
                } else {
                    payload.password = hex_md5(this.dialogForm.password);
                }
                return payload;
            },
            openResetPasswordDialog: function (row) {
                this.resetDialog = {
                    visible: true,
                    userId: row.id,
                    username: row.username || "",
                    password: ""
                };
                var vm = this;
                vm.$nextTick(function () {
                    if (vm.$refs.resetForm) {
                        vm.$refs.resetForm.clearValidate();
                    }
                    this.$set(this.searchForm, field.field, '');
                },
                buildQueryParams: function () {
                    var self = this;
                    var params = {
                        curr: self.page.curr,
                        limit: self.page.limit
                    };
                    if (self.searchForm.condition) {
                        params.condition = self.searchForm.condition;
                });
            },
            closeResetDialog: function () {
                this.resetDialog.visible = false;
            },
            submitResetPassword: function () {
                var vm = this;
                if (!vm.$refs.resetForm) {
                    return;
                }
                vm.$refs.resetForm.validate(function (valid) {
                    if (!valid) {
                        return false;
                    }
                    self.searchableFields.forEach(function (field) {
                        var value = self.searchForm[field.field];
                        if (field.kind === 'date') {
                            if (value && value.length === 2) {
                                params[resolveSearchParam(field)] = value[0] + ' - ' + value[1];
                    vm.resetSubmitting = true;
                    $.ajax({
                        url: baseUrl + "/user/resetPassword/auth",
                        method: "POST",
                        headers: vm.authHeaders(),
                        data: {
                            id: vm.resetDialog.userId,
                            password: hex_md5(vm.resetDialog.password)
                        },
                        success: function (res) {
                            if (handleForbidden(res)) {
                                return;
                            }
                            if (Number(res.code) !== 200) {
                                vm.$message.error(res.msg || "重置密码失败");
                                return;
                            }
                            vm.$message.success("重置密码成功");
                            vm.resetDialog.visible = false;
                        },
                        error: function () {
                            vm.$message.error("重置密码失败");
                        },
                        complete: function () {
                            vm.resetSubmitting = false;
                        }
                    });
                    return true;
                });
            },
            toggleStatus: function (row) {
                var vm = this;
                var currentStatus = Number(row.status) === 1 ? 1 : 0;
                $.ajax({
                    url: baseUrl + "/user/edit/auth",
                    method: "POST",
                    headers: vm.authHeaders(),
                    data: {
                        id: row.id,
                        status: currentStatus
                    },
                    success: function (res) {
                        if (handleForbidden(res)) {
                            return;
                        }
                        if (!isEmptyValue(value)) {
                            params[resolveSearchParam(field)] = value;
                        if (Number(res.code) !== 200) {
                            row.status = currentStatus === 1 ? 0 : 1;
                            vm.$message.error(res.msg || "状态更新失败");
                            return;
                        }
                    });
                    if (self.sortState.prop && self.sortState.order) {
                        params.orderByField = self.sortState.prop;
                        params.orderByType = self.sortState.order === 'ascending' ? 'asc' : 'desc';
                        row.status$ = currentStatus === 1 ? "正常" : "禁用";
                        vm.$message.success("状态已更新");
                    },
                    error: function () {
                        row.status = currentStatus === 1 ? 0 : 1;
                        vm.$message.error("状态更新失败");
                    }
                    return params;
                },
                loadTable: function () {
                    var self = this;
                    self.loading = true;
                    $.ajax({
                        url: baseUrl + '/' + simpleEntityName + '/list/auth',
                        method: 'GET',
                        headers: self.authHeaders(),
                        data: self.buildQueryParams(),
                        success: function (res) {
                            self.loading = false;
                            if (self.handleForbidden(res)) {
                                return;
                            }
                            if (!res || res.code !== 200) {
                                self.$message.error((res && res.msg) ? res.msg : '加载失败');
                                return;
                            }
                            var payload = res.data || {};
                            self.tableData = Array.isArray(payload.records) ? payload.records : [];
                            self.page.total = payload.total || 0;
                            self.requestTableLayout(80);
                        },
                        error: function () {
                            self.loading = false;
                            self.requestTableLayout(80);
                            self.$message.error('加载失败');
                        }
                    });
                },
                handleSearch: function () {
                    this.page.curr = 1;
                    this.loadTable();
                },
                handleReset: function () {
                    this.searchForm = createSearchDefaults();
                    this.searchDisplay = createSearchDisplayDefaults();
                    this.advancedFiltersVisible = false;
                    this.page.curr = 1;
                    this.sortState = {
                        prop: '',
                        order: ''
                    };
                    this.loadTable();
                },
                handleSelectionChange: function (rows) {
                    this.selection = rows || [];
                },
                handleSortChange: function (payload) {
                    this.sortState = {
                        prop: payload && payload.prop ? payload.prop : '',
                        order: payload && payload.order ? payload.order : ''
                    };
                    this.page.curr = 1;
                    this.loadTable();
                },
                handleCurrentChange: function (curr) {
                    this.page.curr = curr;
                    this.loadTable();
                },
                handleSizeChange: function (limit) {
                    this.page.limit = limit;
                    this.page.curr = 1;
                    this.loadTable();
                },
                resetDialogState: function () {
                    this.dialogForm = createFormDefaults();
                    this.dialogDisplay = createDisplayDefaults();
                    if (this.$refs.dialogForm) {
                        this.$refs.dialogForm.clearValidate();
                    }
                },
                openCreateDialog: function () {
                    this.dialog.mode = 'create';
                    this.dialog.visible = true;
                    this.$nextTick(this.resetDialogState);
                },
                openEditDialog: function (row) {
                    var self = this;
                    self.dialog.mode = 'edit';
                    self.dialog.visible = true;
                    self.$nextTick(function () {
                        self.resetDialogState();
                        fillFormFromRow(row, self.dialogForm, self.dialogDisplay);
                        if (self.$refs.dialogForm) {
                            self.$refs.dialogForm.clearValidate();
                        }
                    });
                },
                submitDialog: function () {
                    var self = this;
                    if (!self.$refs.dialogForm) {
                        return;
                    }
                    self.$refs.dialogForm.validate(function (valid) {
                        if (!valid) {
                            return false;
                        }
                        self.dialog.submitting = true;
                        $.ajax({
                            url: baseUrl + '/' + simpleEntityName + '/' + (self.dialog.mode === 'create' ? 'add' : 'update') + '/auth',
                            method: 'POST',
                            headers: self.authHeaders(),
                            data: buildPayload(self.dialogForm),
                            success: function (res) {
                                self.dialog.submitting = false;
                                if (self.handleForbidden(res)) {
                                    return;
                                }
                                if (!res || res.code !== 200) {
                                    self.$message.error((res && res.msg) ? res.msg : '保存失败');
                                    return;
                                }
                                self.$message.success(res.msg || '保存成功');
                                self.dialog.visible = false;
                                self.loadTable();
                            },
                            error: function () {
                                self.dialog.submitting = false;
                                self.$message.error('保存失败');
                            }
                        });
                        return true;
                    });
                },
                removeSelection: function () {
                    var self = this;
                    var ids = self.selection.map(function (row) {
                        return row[self.primaryKeyField];
                    });
                    self.removeRows(ids);
                },
                removeRows: function (ids) {
                    var self = this;
                    if (!ids || ids.length === 0) {
                        self.$message.warning('请选择要删除的数据');
                        return;
                    }
                    self.$confirm('确定删除选中的记录吗?', '提示', { type: 'warning' }).then(function () {
                        $.ajax({
                            url: baseUrl + '/' + simpleEntityName + '/delete/auth',
                            method: 'POST',
                            headers: self.authHeaders(),
                            traditional: true,
                            data: { 'ids[]': ids },
                            success: function (res) {
                                if (self.handleForbidden(res)) {
                                    return;
                                }
                                if (!res || res.code !== 200) {
                                    self.$message.error((res && res.msg) ? res.msg : '删除失败');
                                    return;
                                }
                                self.$message.success(res.msg || '删除成功');
                                self.selection = [];
                                if (self.tableData.length === ids.length && self.page.curr > 1) {
                                    self.page.curr = self.page.curr - 1;
                                }
                                self.loadTable();
                            },
                            error: function () {
                                self.$message.error('删除失败');
                            }
                        });
                    }).catch(function () {});
                },
                exportRows: function () {
                    var self = this;
                    self.exporting = true;
                    var requestBody = {
                        fields: self.exportColumns.map(function (item) {
                            return item.field;
                        })
                    };
                    requestBody[simpleEntityName] = self.buildQueryParams();
                    $.ajax({
                        url: baseUrl + '/' + simpleEntityName + '/export/auth',
                        method: 'POST',
                        headers: $.extend({ 'Content-Type': 'application/json;charset=UTF-8' }, self.authHeaders()),
                        data: JSON.stringify(requestBody),
                        success: function (res) {
                            self.exporting = false;
                            if (self.handleForbidden(res)) {
                                return;
                            }
                            if (!res || res.code !== 200) {
                                self.$message.error((res && res.msg) ? res.msg : '导出失败');
                                return;
                            }
                            createDownloadFile(
                                simpleEntityName + '_' + buildTimestamp() + '.xls',
                                self.exportColumns.map(function (item) {
                                    return item.label;
                                }),
                                Array.isArray(res.data) ? res.data : []
                            );
                            self.$message.success('导出成功');
                        },
                        error: function () {
                            self.exporting = false;
                            self.$message.error('导出失败');
                        }
                    });
                });
            },
            removeSelection: function () {
                var ids = this.selection.map(function (item) {
                    return item.id;
                });
                this.removeRows(ids);
            },
            removeRows: function (ids) {
                var vm = this;
                if (!ids || ids.length === 0) {
                    vm.$message.warning("请选择要删除的数据");
                    return;
                }
            })
        });
    }
                vm.$confirm("确定删除选中的记录吗?", "提示", {
                    type: "warning",
                    confirmButtonText: "确定",
                    cancelButtonText: "取消"
                }).then(function () {
                    $.ajax({
                        url: baseUrl + "/user/delete/auth",
                        method: "POST",
                        headers: vm.authHeaders(),
                        traditional: true,
                        data: { "ids[]": ids },
                        success: function (res) {
                            if (handleForbidden(res)) {
                                return;
                            }
                            if (Number(res.code) !== 200) {
                                vm.$message.error(res.msg || "删除失败");
                                return;
                            }
                            vm.$message.success("删除成功");
                            vm.loadTable();
                        },
                        error: function () {
                            vm.$message.error("删除失败");
                        }
                    });
                }).catch(function () {
                });
            }
        }
    });
})();
src/main/webapp/static/js/webauthn-utils.js
New file
@@ -0,0 +1,141 @@
(function (window) {
    "use strict";
    function base64UrlToArrayBuffer(base64Url) {
        var value = String(base64Url || "").replace(/-/g, "+").replace(/_/g, "/");
        var padding = value.length % 4;
        if (padding) {
            value += new Array(5 - padding).join("=");
        }
        var binary = window.atob(value);
        var bytes = new Uint8Array(binary.length);
        for (var i = 0; i < binary.length; i++) {
            bytes[i] = binary.charCodeAt(i);
        }
        return bytes.buffer;
    }
    function arrayBufferToBase64Url(buffer) {
        var bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer || []);
        var binary = "";
        for (var i = 0; i < bytes.length; i++) {
            binary += String.fromCharCode(bytes[i]);
        }
        return window.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
    }
    function normalizeArray(value) {
        return Array.isArray(value) ? value : [];
    }
    function toCreationOptions(payload) {
        var publicKey = {
            challenge: base64UrlToArrayBuffer(payload.challenge),
            rp: {
                id: payload.rpId,
                name: payload.rpName || "WCS"
            },
            user: {
                id: base64UrlToArrayBuffer(payload.userId),
                name: payload.userName,
                displayName: payload.userDisplayName || payload.userName
            },
            pubKeyCredParams: normalizeArray(payload.pubKeyCredParams),
            timeout: Number(payload.timeout || 60000),
            attestation: payload.attestation || "none",
            authenticatorSelection: payload.authenticatorSelection || {
                residentKey: "preferred",
                userVerification: "required"
            }
        };
        var excludeCredentials = normalizeArray(payload.excludeCredentials).map(function (item) {
            return {
                type: item.type || "public-key",
                id: base64UrlToArrayBuffer(item.id),
                transports: normalizeArray(item.transports)
            };
        });
        if (excludeCredentials.length) {
            publicKey.excludeCredentials = excludeCredentials;
        }
        return { publicKey: publicKey };
    }
    function toRequestOptions(payload) {
        var publicKey = {
            challenge: base64UrlToArrayBuffer(payload.challenge),
            rpId: payload.rpId,
            timeout: Number(payload.timeout || 60000),
            userVerification: payload.userVerification || "required"
        };
        var allowCredentials = normalizeArray(payload.allowCredentials).map(function (item) {
            return {
                type: item.type || "public-key",
                id: base64UrlToArrayBuffer(item.id),
                transports: normalizeArray(item.transports)
            };
        });
        if (allowCredentials.length) {
            publicKey.allowCredentials = allowCredentials;
        }
        return { publicKey: publicKey };
    }
    function ensureSupported() {
        if (!window.isSecureContext) {
            throw new Error("secure-context");
        }
        if (!window.PublicKeyCredential || !window.navigator || !window.navigator.credentials) {
            throw new Error("not-supported");
        }
    }
    async function register(payload) {
        ensureSupported();
        var credential = await window.navigator.credentials.create(toCreationOptions(payload));
        if (!credential || !credential.response) {
            throw new Error("create-empty");
        }
        var response = credential.response;
        if (typeof response.getPublicKey !== "function" || typeof response.getAuthenticatorData !== "function") {
            throw new Error("extension-unsupported");
        }
        var publicKey = response.getPublicKey();
        var authenticatorData = response.getAuthenticatorData();
        if (!publicKey || !authenticatorData) {
            throw new Error("public-key-missing");
        }
        return {
            credentialId: arrayBufferToBase64Url(credential.rawId),
            clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
            authenticatorData: arrayBufferToBase64Url(authenticatorData),
            publicKey: arrayBufferToBase64Url(publicKey),
            publicKeyAlgorithm: response.getPublicKeyAlgorithm(),
            transports: JSON.stringify(typeof response.getTransports === "function" ? response.getTransports() || [] : [])
        };
    }
    async function authenticate(payload) {
        ensureSupported();
        var credential = await window.navigator.credentials.get(toRequestOptions(payload));
        if (!credential || !credential.response) {
            throw new Error("get-empty");
        }
        var response = credential.response;
        return {
            credentialId: arrayBufferToBase64Url(credential.rawId),
            clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
            authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
            signature: arrayBufferToBase64Url(response.signature),
            userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : ""
        };
    }
    window.WCS_WEBAUTHN = {
        isSupported: function () {
            return !!(window.isSecureContext && window.PublicKeyCredential && window.navigator && window.navigator.credentials);
        },
        register: register,
        authenticate: authenticate
    };
})(window);
src/main/webapp/static/js/wrkMast/wrkMast.js
New file
@@ -0,0 +1,319 @@
(function () {
    var COLUMN_STORAGE_KEY = "wrk-mast-visible-columns";
    var DEFAULT_COLUMNS = [
        { key: "wrkNo", prop: "wrkNo", label: "工作号", width: 110, sortable: true, align: "center" },
        { key: "wmsWrkNo", prop: "wmsWrkNo", label: "WMS任务号", minWidth: 160, sortable: true },
        { key: "wrkSts$", prop: "wrkSts$", label: "工作状态", minWidth: 120, sortable: true },
        { key: "ioType$", prop: "ioType$", label: "任务类型", minWidth: 120, sortable: true },
        { key: "ioPri", prop: "ioPri", label: "优先级", width: 90, sortable: true, align: "center" },
        { key: "sourceStaNo", prop: "sourceStaNo", label: "源站", width: 90, sortable: true, align: "center" },
        { key: "staNo", prop: "staNo", label: "目标站", width: 90, sortable: true, align: "center" },
        { key: "sourceLocNo", prop: "sourceLocNo", label: "源库位", minWidth: 140, sortable: true },
        { key: "locNo", prop: "locNo", label: "目标库位", minWidth: 140, sortable: true },
        { key: "modiTime$", prop: "modiTime$", label: "修改时间", minWidth: 168, sortable: true },
        { key: "barcode", prop: "barcode", label: "托盘码", minWidth: 150, sortable: true },
        { key: "crnNo", prop: "crnNo", label: "堆垛机", width: 90, sortable: true, align: "center" },
        { key: "dualCrnNo", prop: "dualCrnNo", label: "双工位堆垛机", minWidth: 120, sortable: true, align: "center" },
        { key: "batch", prop: "batch", label: "批次", minWidth: 120, sortable: true },
        { key: "batchSeq", prop: "batchSeq", label: "批次序列", width: 100, sortable: true, align: "center" },
        { key: "systemMsg", prop: "systemMsg", label: "系统消息", minWidth: 220, sortable: false, showOverflow: false }
    ];
    function cloneSearchForm() {
        return {
            condition: "",
            wrk_no: "",
            wms_wrk_no: "",
            loc_no: "",
            source_loc_no: "",
            crn_no: "",
            dual_crn_no: ""
        };
    }
    function loadStoredColumns() {
        try {
            var raw = localStorage.getItem(COLUMN_STORAGE_KEY);
            var parsed = raw ? JSON.parse(raw) : null;
            if (!parsed || !parsed.length) {
                return DEFAULT_COLUMNS.map(function (column) { return column.key; });
            }
            return DEFAULT_COLUMNS.map(function (column) { return column.key; }).filter(function (key) {
                return parsed.indexOf(key) > -1;
            });
        } catch (e) {
            return DEFAULT_COLUMNS.map(function (column) { return column.key; });
        }
    }
    function saveVisibleColumns(keys) {
        localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(keys));
    }
    new Vue({
        el: "#app",
        data: function () {
            return {
                loading: false,
                advancedVisible: false,
                columnPopoverVisible: false,
                tableData: [],
                currentPage: 1,
                pageSize: 30,
                pageSizes: [16, 30, 50, 100, 150, 200],
                pageTotal: 0,
                tableHeight: 520,
                searchForm: cloneSearchForm(),
                sortState: {
                    prop: "",
                    order: ""
                },
                columnDefs: DEFAULT_COLUMNS,
                visibleColumnKeys: loadStoredColumns(),
                layoutTimer: null
            };
        },
        computed: {
            visibleColumns: function () {
                var keys = this.visibleColumnKeys;
                return this.columnDefs.filter(function (column) {
                    return keys.indexOf(column.key) > -1;
                });
            },
            tableRenderKey: function () {
                return this.visibleColumnKeys.join("|");
            }
        },
        created: function () {
            this.loadList();
        },
        mounted: function () {
            this.updateTableHeight();
            window.addEventListener("resize", this.handleResize);
        },
        beforeDestroy: function () {
            window.removeEventListener("resize", this.handleResize);
            if (this.layoutTimer) {
                clearTimeout(this.layoutTimer);
                this.layoutTimer = null;
            }
        },
        methods: {
            buildQueryParams: function () {
                var data = {
                    curr: this.currentPage,
                    limit: this.pageSize
                };
                var key;
                for (key in this.searchForm) {
                    if (Object.prototype.hasOwnProperty.call(this.searchForm, key) && this.searchForm[key] !== "" && this.searchForm[key] !== null) {
                        data[key] = this.searchForm[key];
                    }
                }
                if (this.sortState.prop && this.sortState.order) {
                    data.orderByField = this.sortState.prop;
                    data.orderByType = this.sortState.order === "ascending" ? "asc" : "desc";
                }
                return data;
            },
            loadList: function () {
                var vm = this;
                vm.loading = true;
                $.ajax({
                    url: baseUrl + "/wrkMast/list/auth",
                    headers: { token: localStorage.getItem("token") },
                    method: "GET",
                    data: vm.buildQueryParams(),
                    success: function (res) {
                        if (res.code === 200) {
                            vm.tableData = (res.data && res.data.records) || [];
                            vm.pageTotal = (res.data && res.data.total) || 0;
                            vm.scheduleTableLayout();
                            return;
                        }
                        if (res.code === 403) {
                            top.location.href = baseUrl + "/";
                            return;
                        }
                        vm.$message.error(res.msg || "任务列表加载失败");
                    },
                    error: function () {
                        vm.$message.error("任务列表加载失败");
                    },
                    complete: function () {
                        vm.loading = false;
                    }
                });
            },
            handleSearch: function () {
                this.currentPage = 1;
                this.loadList();
            },
            handleReset: function () {
                this.searchForm = cloneSearchForm();
                this.currentPage = 1;
                this.sortState = {
                    prop: "",
                    order: ""
                };
                this.loadList();
                this.scheduleTableLayout();
            },
            toggleAdvanced: function () {
                this.advancedVisible = !this.advancedVisible;
                this.updateTableHeight();
                this.scheduleTableLayout();
            },
            handleSizeChange: function (size) {
                this.pageSize = size;
                this.currentPage = 1;
                this.loadList();
            },
            handleCurrentChange: function (page) {
                this.currentPage = page;
                this.loadList();
            },
            handleSortChange: function (sort) {
                this.sortState = {
                    prop: sort.prop || "",
                    order: sort.order || ""
                };
                this.currentPage = 1;
                this.loadList();
            },
            isColumnVisible: function (key) {
                return this.visibleColumnKeys.indexOf(key) > -1;
            },
            toggleColumn: function (key, checked) {
                var next = this.visibleColumnKeys.slice();
                var index = next.indexOf(key);
                if (checked && index === -1) {
                    next.push(key);
                }
                if (!checked && index > -1) {
                    if (next.length === 1) {
                        this.$message.warning("至少保留一列");
                        return;
                    }
                    next.splice(index, 1);
                }
                this.visibleColumnKeys = this.columnDefs.map(function (column) {
                    return column.key;
                }).filter(function (columnKey) {
                    return next.indexOf(columnKey) > -1;
                });
                saveVisibleColumns(this.visibleColumnKeys);
                this.scheduleTableLayout();
            },
            showAllColumns: function () {
                this.visibleColumnKeys = this.columnDefs.map(function (column) {
                    return column.key;
                });
                saveVisibleColumns(this.visibleColumnKeys);
                this.scheduleTableLayout();
            },
            resetColumns: function () {
                this.visibleColumnKeys = DEFAULT_COLUMNS.map(function (column) {
                    return column.key;
                });
                saveVisibleColumns(this.visibleColumnKeys);
                this.scheduleTableLayout();
            },
            handleRowCommand: function (command, row) {
                if (command === "complete") {
                    this.completeTask(row);
                    return;
                }
                if (command === "cancel") {
                    this.cancelTask(row);
                }
            },
            completeTask: function (row) {
                var vm = this;
                vm.$confirm("确定完成该任务吗?", "提示", {
                    type: "warning",
                    confirmButtonText: "确定",
                    cancelButtonText: "取消"
                }).then(function () {
                    $.ajax({
                        url: baseUrl + "/openapi/completeTask",
                        contentType: "application/json",
                        headers: { token: localStorage.getItem("token") },
                        data: JSON.stringify({ wrkNo: row.wrkNo }),
                        method: "POST",
                        success: function (res) {
                            if (res.code === 200) {
                                vm.$message.success("完成成功");
                                vm.loadList();
                                return;
                            }
                            if (res.code === 403) {
                                top.location.href = baseUrl + "/";
                                return;
                            }
                            vm.$message.error(res.msg || "完成失败");
                        },
                        error: function () {
                            vm.$message.error("完成失败");
                        }
                    });
                }).catch(function () {});
            },
            cancelTask: function (row) {
                var vm = this;
                vm.$confirm("确定取消该任务吗?", "提示", {
                    type: "warning",
                    confirmButtonText: "确定",
                    cancelButtonText: "取消"
                }).then(function () {
                    $.ajax({
                        url: baseUrl + "/openapi/cancelTask",
                        contentType: "application/json",
                        headers: { token: localStorage.getItem("token") },
                        data: JSON.stringify({ wrkNo: row.wrkNo }),
                        method: "POST",
                        success: function (res) {
                            if (res.code === 200) {
                                vm.$message.success("取消成功");
                                vm.loadList();
                                return;
                            }
                            if (res.code === 403) {
                                top.location.href = baseUrl + "/";
                                return;
                            }
                            vm.$message.error(res.msg || "取消失败");
                        },
                        error: function () {
                            vm.$message.error("取消失败");
                        }
                    });
                }).catch(function () {});
            },
            updateTableHeight: function () {
                var viewport = window.innerHeight || document.documentElement.clientHeight || 860;
                this.tableHeight = Math.max(340, viewport - (this.advancedVisible ? 276 : 222));
            },
            scheduleTableLayout: function () {
                var vm = this;
                vm.updateTableHeight();
                vm.$nextTick(function () {
                    if (vm.layoutTimer) {
                        clearTimeout(vm.layoutTimer);
                    }
                    vm.layoutTimer = setTimeout(function () {
                        if (vm.$refs.dataTable && typeof vm.$refs.dataTable.doLayout === "function") {
                            vm.$refs.dataTable.doLayout();
                        }
                    }, 50);
                });
            },
            handleResize: function () {
                this.scheduleTableLayout();
            }
        }
    });
})();
src/main/webapp/views/ai/diagnosis.html
@@ -644,7 +644,7 @@
  <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
  <script type="text/javascript" src="../../static/vue/element/element.js"></script>
  <script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
  <script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
  <script src="../../static/js/marked.min.js"></script>
  <script src="../../static/js/purify.min.js"></script>
  <script>
src/main/webapp/views/ai/llm_config.html
@@ -472,7 +472,7 @@
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script>
  new Vue({
    el: '#app',
src/main/webapp/views/basOutStationArea/basOutStationArea.html
@@ -80,7 +80,7 @@
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/basOutStationArea/basOutStationArea.js"></script>
</body>
</html>
src/main/webapp/views/basStationDevice/basStationDevice.html
@@ -263,7 +263,7 @@
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/basStationDevice/basStationDevice.js"></script>
</body>
</html>
src/main/webapp/views/config/config.html
@@ -22,6 +22,7 @@
        html,
        body {
            margin: 0;
            height: 100%;
            min-height: 100%;
            color: var(--text-main);
            font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
@@ -34,12 +35,16 @@
        .page-shell {
            max-width: 1700px;
            margin: 0 auto;
            height: 100%;
            padding: 14px;
            box-sizing: border-box;
            display: flex;
        }
        .card-shell {
            position: relative;
            flex: 1 1 auto;
            display: flex;
            border-radius: 24px;
            border: 1px solid var(--card-border);
            background:
@@ -53,6 +58,12 @@
        .card-body {
            position: relative;
            z-index: 1;
            flex: 1 1 auto;
            min-height: 0;
            min-width: 0;
            width: 100%;
            display: flex;
            flex-direction: column;
        }
        .list-toolbar {
@@ -164,6 +175,11 @@
        .table-wrap {
            padding: 10px 16px;
            flex: 1 1 auto;
            min-height: 0;
            min-width: 0;
            width: 100%;
            display: flex;
        }
        .table-shell {
@@ -171,6 +187,11 @@
            overflow: hidden;
            border: 1px solid rgba(217, 227, 238, 0.98);
            background: rgba(255, 255, 255, 0.95);
            flex: 1 1 auto;
            min-height: 0;
            min-width: 0;
            width: 100%;
            max-width: 100%;
        }
        .table-shell .el-table {
@@ -436,6 +457,7 @@
                            <el-button slot="reference" size="small" plain icon="el-icon-setting">列设置</el-button>
                        </el-popover>
                        <el-button size="small" plain icon="el-icon-download" :loading="exporting" @click="exportRows">导出</el-button>
                        <el-button size="small" plain type="warning" icon="el-icon-refresh-right" @click="refreshCache">刷新缓存</el-button>
                    </div>
                </div>
            </div>
@@ -510,7 +532,7 @@
                </div>
            </el-collapse-transition>
            <div class="table-wrap">
            <div ref="tableWrap" class="table-wrap">
                <div class="table-shell">
                    <el-table
                        ref="dataTable"
@@ -552,8 +574,9 @@
                                <span v-else>{{ valueOrDash(getTableValue(scope.row, field)) }}</span>
                            </template>
                        </el-table-column>
                        <el-table-column label="操作" width="160" fixed="right" align="center">
                        <el-table-column label="操作" width="190" fixed="right" align="center">
                            <template slot-scope="scope">
                                <el-button type="text" @click="openDetailDialog(scope.row)">详情</el-button>
                                <el-button type="text" @click="openEditDialog(scope.row)">修改</el-button>
                                <el-button type="text" style="color:#f56c6c;" @click="removeRows([scope.row[primaryKeyField]])">删除</el-button>
                            </template>
@@ -562,7 +585,7 @@
                </div>
            </div>
            <div class="pager-bar">
            <div ref="pagerBar" class="pager-bar">
                <el-pagination
                    small
                    background
@@ -580,7 +603,7 @@
    <el-dialog
        class="dialog-panel"
        :title="dialog.mode === 'create' ? '新增 Config' : '修改 Config'"
        :title="dialog.mode === 'create' ? '新增 Config' : (dialog.mode === 'detail' ? '详情 Config' : '修改 Config')"
        :visible.sync="dialog.visible"
        width="760px"
        :close-on-click-modal="false">
@@ -594,7 +617,7 @@
                <el-col
                    v-for="field in editableFields"
                    :key="'dialog-' + field.field"
                    :span="field.textarea || field.kind === 'image' ? 24 : 12">
                    :span="field.dialogSpan || (field.textarea || field.kind === 'image' ? 24 : 12)">
                    <el-form-item :label="field.label" :prop="field.field">
                        <el-date-picker
                            v-if="field.kind === 'date'"
@@ -602,12 +625,14 @@
                            type="datetime"
                            value-format="yyyy-MM-dd HH:mm:ss"
                            :placeholder="'请选择' + field.label"
                            :disabled="isDialogReadonly"
                            style="width: 100%;">
                        </el-date-picker>
                        <el-select
                            v-else-if="field.kind === 'enum'"
                            v-model="dialogForm[field.field]"
                            clearable
                            :disabled="isDialogReadonly"
                            :placeholder="'请选择' + field.label"
                            style="width: 100%;">
                            <el-option
@@ -622,6 +647,7 @@
                            v-model="dialogDisplay[field.field]"
                            :fetch-suggestions="getSuggestionFetcher(field)"
                            :placeholder="'请输入' + field.label"
                            :disabled="isDialogReadonly"
                            style="width: 100%;"
                            @select="handleForeignSelect(field, $event)"
                            @input="handleForeignInput(field)">
@@ -633,6 +659,7 @@
                        <el-switch
                            v-else-if="field.kind === 'checkbox'"
                            v-model="dialogForm[field.field]"
                            :disabled="isDialogReadonly"
                            :active-value="normalizeOptionValue(field, field.checkboxActiveRaw)"
                            :inactive-value="normalizeOptionValue(field, field.checkboxInactiveRaw)"
                            active-color="#13ce66"
@@ -643,11 +670,13 @@
                            v-model.trim="dialogForm[field.field]"
                            type="textarea"
                            :rows="3"
                            :disabled="isDialogReadonly"
                            :placeholder="'请输入' + field.label">
                        </el-input>
                        <el-input
                            v-else
                            v-model.trim="dialogForm[field.field]"
                            :disabled="isDialogReadonly"
                            :placeholder="'请输入' + field.label">
                        </el-input>
                    </el-form-item>
@@ -656,7 +685,7 @@
        </el-form>
        <div slot="footer" class="dialog-footer">
            <el-button @click="dialog.visible = false">取消</el-button>
            <el-button type="primary" :loading="dialog.submitting" @click="submitDialog">保存</el-button>
            <el-button v-if="!isDialogReadonly" type="primary" :loading="dialog.submitting" @click="submitDialog">保存</el-button>
        </div>
    </el-dialog>
</div>
@@ -665,6 +694,6 @@
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/config/config.js?v=20260310" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/config/config.js" charset="utf-8"></script>
</body>
</html>
src/main/webapp/views/debugParam/debugParam.html
@@ -6,7 +6,7 @@
        <title>调试参数</title>
        <link rel="stylesheet" href="../../static/vue/element/element.css">
        <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
        <script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1"></script>
        <script type="text/javascript" src="../../static/js/common.js"></script>
        <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
        <script type="text/javascript" src="../../static/vue/element/element.js"></script>
        <style>
src/main/webapp/views/detail.html
@@ -108,6 +108,65 @@
            flex: 0 0 118px;
        }
        .mfa-panel {
            border: 1px solid rgba(222, 230, 239, 0.92);
            border-radius: 14px;
            background: #f8fbff;
            padding: 14px 16px;
        }
        .mfa-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            margin-bottom: 10px;
        }
        .mfa-title {
            font-size: 14px;
            font-weight: 700;
            color: #2f4358;
        }
        .mfa-actions {
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
        }
        .mfa-meta {
            display: grid;
            grid-template-columns: repeat(3, minmax(0, 1fr));
            gap: 10px;
        }
        .mfa-meta-item {
            padding: 10px 12px;
            border-radius: 12px;
            background: rgba(255, 255, 255, 0.95);
            border: 1px solid rgba(226, 233, 242, 0.96);
        }
        .mfa-meta-label {
            font-size: 12px;
            color: #7b8b9b;
            margin-bottom: 6px;
        }
        .mfa-meta-value {
            font-size: 13px;
            color: #2f4358;
            word-break: break-all;
        }
        .mfa-tip {
            margin-top: 10px;
            font-size: 12px;
            line-height: 1.7;
            color: #7b8b9b;
        }
        .footer-bar {
            display: flex;
            justify-content: flex-end;
@@ -123,18 +182,21 @@
            overflow: hidden;
        }
        .password-dialog .el-dialog__header {
        .password-dialog .el-dialog__header,
        .mfa-dialog .el-dialog__header {
            padding: 18px 20px 12px;
            border-bottom: 1px solid rgba(222, 230, 239, 0.92);
            background: #f8fbff;
        }
        .password-dialog .el-dialog__title {
        .password-dialog .el-dialog__title,
        .mfa-dialog .el-dialog__title {
            font-weight: 700;
            color: var(--text-main);
        }
        .password-dialog .el-dialog__body {
        .password-dialog .el-dialog__body,
        .mfa-dialog .el-dialog__body {
            padding: 18px 20px 12px;
        }
@@ -169,18 +231,77 @@
            padding-top: 4px;
        }
        .mfa-setup {
            margin-bottom: 16px;
            padding: 14px;
            border-radius: 14px;
            background: rgba(248, 251, 255, 0.92);
            border: 1px solid rgba(226, 233, 242, 0.96);
        }
        .mfa-setup-tip {
            font-size: 12px;
            line-height: 1.7;
            color: #738396;
            margin-bottom: 12px;
        }
        .mfa-qr-wrap {
            display: flex;
            align-items: center;
            justify-content: center;
            min-height: 220px;
            margin-bottom: 14px;
            background: #fff;
            border-radius: 14px;
            border: 1px dashed rgba(210, 220, 233, 0.96);
        }
        .mfa-qr-wrap img {
            width: 220px;
            height: 220px;
            object-fit: contain;
        }
        .mfa-secret-row {
            display: flex;
            gap: 10px;
            align-items: center;
        }
        .mfa-secret-row .el-input {
            flex: 1;
        }
        .mfa-footer {
            display: flex;
            justify-content: center;
            gap: 12px;
            padding-top: 6px;
        }
        @media (max-width: 768px) {
            .page-shell {
                padding: 10px;
            }
            .password-row {
            .password-row,
            .mfa-secret-row {
                flex-direction: column;
                align-items: stretch;
            }
            .form-shell {
                max-width: none;
            }
            .mfa-head {
                flex-direction: column;
                align-items: stretch;
            }
            .mfa-meta {
                grid-template-columns: 1fr;
            }
        }
    </style>
@@ -194,7 +315,6 @@
        <div class="card-body">
            <div class="form-shell">
                <input id="id" type="hidden" v-model="form.id">
                <input id="password" type="hidden" v-model="form.password">
                <el-form
                    ref="profileForm"
                    class="profile-form"
@@ -229,6 +349,89 @@
                                <div class="password-row">
                                    <el-input class="password-mask" value="已设置密码" disabled></el-input>
                                    <el-button type="primary" plain icon="el-icon-lock" @click="openPasswordDialog">修改密码</el-button>
                                </div>
                            </el-form-item>
                        </el-col>
                        <el-col :xs="24">
                            <el-form-item label="MFA">
                                <div class="mfa-panel">
                                    <div class="mfa-head">
                                        <div class="mfa-title">多因子登录验证</div>
                                        <div class="mfa-actions">
                                            <el-button
                                                v-if="Number(form.mfaAllow) === 1 && Number(form.mfaEnabled) !== 1"
                                                type="primary"
                                                plain
                                                icon="el-icon-key"
                                                @click="openMfaEnableDialog">启用 MFA</el-button>
                                            <el-button
                                                v-if="Number(form.mfaEnabled) === 1"
                                                type="danger"
                                                plain
                                                icon="el-icon-switch-button"
                                                @click="openMfaDisableDialog">停用 MFA</el-button>
                                        </div>
                                    </div>
                                    <div class="mfa-meta">
                                        <div class="mfa-meta-item">
                                            <div class="mfa-meta-label">账号授权</div>
                                            <div class="mfa-meta-value">{{ form.mfaAllow$ || '--' }}</div>
                                        </div>
                                        <div class="mfa-meta-item">
                                            <div class="mfa-meta-label">启用状态</div>
                                            <div class="mfa-meta-value">{{ form.mfaEnabled$ || '--' }}</div>
                                        </div>
                                        <div class="mfa-meta-item">
                                            <div class="mfa-meta-label">绑定时间</div>
                                            <div class="mfa-meta-value">{{ form.mfaBoundTime$ || '--' }}</div>
                                        </div>
                                    </div>
                                    <div class="mfa-tip">
                                        <span v-if="Number(form.mfaAllow) !== 1">当前账号未开通 MFA 使用权限,请联系管理员授权。</span>
                                        <span v-else-if="Number(form.mfaEnabled) === 1">已绑定密钥:{{ form.mfaMaskedSecret || '--' }}。登录时需要再输入一次动态验证码。</span>
                                        <span v-else>当前账号已允许使用 MFA,启用后登录会增加一次 6 位动态验证码校验。</span>
                                    </div>
                                </div>
                            </el-form-item>
                        </el-col>
                        <el-col :xs="24">
                            <el-form-item label="通行密钥">
                                <div class="mfa-panel">
                                    <div class="mfa-head">
                                        <div class="mfa-title">设备生物识别 / 安全密钥登录</div>
                                        <div class="mfa-actions">
                                            <el-button
                                                v-if="!form.passkeyBound"
                                                type="primary"
                                                plain
                                                icon="el-icon-key"
                                                @click="openPasskeyRegisterDialog">绑定通行密钥</el-button>
                                            <el-button
                                                v-else
                                                type="danger"
                                                plain
                                                icon="el-icon-delete"
                                                @click="openPasskeyRemoveDialog">解绑通行密钥</el-button>
                                        </div>
                                    </div>
                                    <div class="mfa-meta">
                                        <div class="mfa-meta-item">
                                            <div class="mfa-meta-label">绑定状态</div>
                                            <div class="mfa-meta-value">{{ form.passkeyBound ? '已绑定' : '未绑定' }}</div>
                                        </div>
                                        <div class="mfa-meta-item">
                                            <div class="mfa-meta-label">显示名称</div>
                                            <div class="mfa-meta-value">{{ form.passkeyName || '--' }}</div>
                                        </div>
                                        <div class="mfa-meta-item">
                                            <div class="mfa-meta-label">绑定时间</div>
                                            <div class="mfa-meta-value">{{ form.passkeyBoundTime$ || '--' }}</div>
                                        </div>
                                    </div>
                                    <div class="mfa-tip">
                                        <span v-if="form.passkeyBound">最近使用:{{ form.passkeyLastUsedTime$ || '--' }}</span>
                                        <span v-else>绑定后可直接使用设备指纹、人脸或安全密钥登录。该能力要求浏览器支持通行密钥,且系统以 HTTPS 或 localhost 打开。</span>
                                    </div>
                                </div>
                            </el-form-item>
                        </el-col>
@@ -271,12 +474,89 @@
            </div>
        </el-form>
    </el-dialog>
    <el-dialog
        class="mfa-dialog"
        :title="mfaDialogMode === 'enable' ? '启用 MFA' : '停用 MFA'"
        :visible.sync="mfaDialogVisible"
        width="520px"
        :close-on-click-modal="false"
        @close="closeMfaDialog"
        append-to-body>
        <div v-if="mfaDialogMode === 'enable'" class="mfa-setup">
            <div class="mfa-setup-tip">请先使用 Google Authenticator、Microsoft Authenticator 等身份验证器扫描二维码,再输入当前密码和 6 位动态码完成绑定。</div>
            <div class="mfa-qr-wrap" v-loading="mfaSetupLoading">
                <img v-if="mfaSetup.qrCode" :src="mfaSetup.qrCode" alt="MFA QR Code">
                <span v-else>二维码生成中...</span>
            </div>
            <div class="mfa-secret-row">
                <el-input :value="mfaSetup.secret" readonly placeholder="MFA密钥"></el-input>
                <el-button plain @click="copySecret">复制密钥</el-button>
            </div>
        </div>
        <div v-else class="mfa-setup">
            <div class="mfa-setup-tip">停用 MFA 前,请输入当前密码和身份验证器中的 6 位动态码进行确认。</div>
        </div>
        <el-form
            ref="mfaForm"
            class="password-form"
            :model="mfaForm"
            :rules="mfaRules"
            label-width="112px"
            size="small"
            @submit.native.prevent>
            <el-form-item label="当前密码" prop="currentPassword">
                <el-input v-model="mfaForm.currentPassword" type="password" show-password autocomplete="off"></el-input>
            </el-form-item>
            <el-form-item label="动态验证码" prop="code">
                <el-input v-model.trim="mfaForm.code" maxlength="6" autocomplete="off" placeholder="请输入6位验证码"></el-input>
            </el-form-item>
            <div class="mfa-footer">
                <el-button @click="closeMfaDialog">关闭</el-button>
                <el-button type="primary" :loading="mfaSubmitting" @click="handleMfaSubmit">保存</el-button>
            </div>
        </el-form>
    </el-dialog>
    <el-dialog
        class="mfa-dialog"
        :title="passkeyDialogMode === 'register' ? '绑定通行密钥' : '解绑通行密钥'"
        :visible.sync="passkeyDialogVisible"
        width="520px"
        :close-on-click-modal="false"
        @close="closePasskeyDialog"
        append-to-body>
        <div class="mfa-setup">
            <div v-if="passkeyDialogMode === 'register'" class="mfa-setup-tip">绑定时会弹出系统级身份验证窗口,请使用当前设备的人脸、指纹、PIN 或安全密钥完成确认。若浏览器或环境不支持,无法启用该能力。</div>
            <div v-else class="mfa-setup-tip">解绑前请输入当前密码确认。解绑后将不能再用当前通行密钥直接登录。</div>
        </div>
        <el-form
            ref="passkeyForm"
            class="password-form"
            :model="passkeyForm"
            :rules="passkeyRules"
            label-width="112px"
            size="small"
            @submit.native.prevent>
            <el-form-item v-if="passkeyDialogMode === 'register'" label="显示名称" prop="name">
                <el-input v-model.trim="passkeyForm.name" maxlength="100" autocomplete="off" placeholder="例如:办公室电脑"></el-input>
            </el-form-item>
            <el-form-item label="当前密码" prop="currentPassword">
                <el-input v-model="passkeyForm.currentPassword" type="password" show-password autocomplete="off"></el-input>
            </el-form-item>
            <div class="mfa-footer">
                <el-button @click="closePasskeyDialog">关闭</el-button>
                <el-button type="primary" :loading="passkeySubmitting" @click="handlePasskeySubmit">保存</el-button>
            </div>
        </el-form>
    </el-dialog>
</div>
</body>
<script type="text/javascript" src="../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../static/js/tools/md5.js"></script>
<script type="text/javascript" src="../static/js/common.js?v=20260309_i18n_fix1"></script>
<script type="text/javascript" src="../static/js/common.js"></script>
<script type="text/javascript" src="../static/js/webauthn-utils.js"></script>
<script type="text/javascript" src="../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../static/vue/element/element.js"></script>
<script type="text/javascript" src="../static/js/detail/detail.js?v=20260310_detail_vue3"></script>
<script type="text/javascript" src="../static/js/detail/detail.js?v=20260311_detail_passkey"></script>
</html>
src/main/webapp/views/deviceLogs/deviceLogs.html
@@ -203,7 +203,7 @@
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script src="../../static/vue/js/vue.min.js"></script>
<script src="../../static/vue/element/element.js"></script>
<script src="../../components/MonitorCardKit.js"></script>
src/main/webapp/views/index.html
@@ -50,6 +50,8 @@
      will-change: width;
      transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
      box-shadow 0.22s ease;
      -webkit-user-select: none;
      user-select: none;
    }
    .layout-aside.is-animating {
@@ -124,6 +126,8 @@
      border: 1px solid rgba(255, 255, 255, 0.10);
      background: rgba(255, 255, 255, 0.08);
      color: #fff;
      -webkit-user-select: text;
      user-select: text;
    }
    .aside-search .el-input__inner::placeholder {
@@ -415,6 +419,8 @@
      background: #fff;
      border-bottom: 1px solid #e8edf5;
      box-sizing: border-box;
      -webkit-user-select: none;
      user-select: none;
    }
    .page-tabs {
@@ -433,6 +439,8 @@
    .page-tabs .el-tabs__item {
      height: 38px;
      line-height: 38px;
      -webkit-user-select: none;
      user-select: none;
    }
    .tabs-tools {
@@ -441,6 +449,8 @@
      gap: 8px;
      padding-bottom: 6px;
      flex-shrink: 0;
      -webkit-user-select: none;
      user-select: none;
    }
    .content-main {
@@ -499,45 +509,10 @@
      word-break: break-word;
    }
    @keyframes slideInRight {
      from {
        transform: translate3d(100%, 0, 0);
        opacity: 0;
      }
      to {
        transform: translate3d(0, 0, 0);
        opacity: 1;
      }
    }
    @keyframes slideOutRight {
      from {
        transform: translate3d(0, 0, 0);
        opacity: 1;
      }
      to {
        transform: translate3d(100%, 0, 0);
        opacity: 0;
      }
    }
    @keyframes asideSkeletonShimmer {
      100% {
        transform: translateX(100%);
      }
    }
    .ai-drawer-layer {
      box-shadow: -8px 0 24px rgba(0, 0, 0, 0.15) !important;
      border-radius: 8px 0 0 8px !important;
      overflow: hidden;
      animation: slideInRight 0.5s cubic-bezier(0.16, 1, 0.3, 1);
    }
    .ai-drawer-layer-close {
      animation: slideOutRight 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards !important;
    }
    .ai-assistant-btn {
@@ -546,6 +521,87 @@
      bottom: 40px;
      z-index: 9999;
      cursor: pointer;
    }
    .ai-assistant-mask {
      position: fixed;
      inset: 0;
      z-index: 9997;
      background: rgba(15, 23, 42, 0.10);
      backdrop-filter: blur(3px);
    }
    .ai-assistant-drawer {
      position: fixed;
      top: 0;
      right: 0;
      z-index: 9998;
      width: min(600px, 100vw);
      height: 100vh;
      display: flex;
      flex-direction: column;
      background: #fff;
      border-radius: 12px 0 0 12px;
      box-shadow: -10px 0 28px rgba(15, 23, 42, 0.18);
      overflow: hidden;
    }
    .ai-drawer-header {
      height: 54px;
      padding: 0 16px 0 20px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      border-bottom: 1px solid rgba(226, 232, 240, 0.92);
      background: rgba(248, 251, 255, 0.96);
      color: #243447;
      font-size: 15px;
      font-weight: 700;
      box-sizing: border-box;
    }
    .ai-drawer-close {
      width: 32px;
      height: 32px;
      border: none;
      border-radius: 10px;
      background: transparent;
      color: #5f7084;
      cursor: pointer;
      font-size: 16px;
    }
    .ai-drawer-close:hover {
      background: rgba(236, 243, 249, 0.92);
      color: #24405c;
    }
    .ai-drawer-frame {
      flex: 1;
      width: 100%;
      border: none;
      background: #fff;
    }
    .ai-mask-enter-active,
    .ai-mask-leave-active {
      transition: opacity .22s ease;
    }
    .ai-mask-enter,
    .ai-mask-leave-to {
      opacity: 0;
    }
    .ai-panel-enter-active,
    .ai-panel-leave-active {
      transition: transform .28s cubic-bezier(0.22, 1, 0.36, 1), opacity .22s ease;
    }
    .ai-panel-enter,
    .ai-panel-leave-to {
      transform: translate3d(100%, 0, 0);
      opacity: 0;
    }
    @media (max-width: 1440px) {
@@ -766,18 +822,43 @@
    </span>
  </el-dialog>
  <div
      id="ai-assistant-btn"
      class="ai-assistant-btn"
      @mouseenter="showAiTip"
      @mouseleave="hideAiTip"
      @click="openAiAssistant">
  </div>
  <el-tooltip :content="t('common.aiAssistant')" placement="left">
    <div
        id="ai-assistant-btn"
        class="ai-assistant-btn"
        @click="openAiAssistant">
    </div>
  </el-tooltip>
  <transition name="ai-mask">
    <div
        v-if="aiAssistantVisible"
        class="ai-assistant-mask"
        @click="closeAiAssistant">
    </div>
  </transition>
  <transition name="ai-panel">
    <div
        v-if="aiAssistantVisible"
        class="ai-assistant-drawer">
      <div class="ai-drawer-header">
        <span>{{ t('common.aiAssistant') }}</span>
        <button type="button" class="ai-drawer-close" @click="closeAiAssistant">
          <i class="el-icon-close"></i>
        </button>
      </div>
      <iframe
          v-if="aiAssistantMounted"
          class="ai-drawer-frame"
          :src="aiAssistantSrc">
      </iframe>
    </div>
  </transition>
</div>
<script type="text/javascript" src="../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../static/js/layer/layer.js"></script>
<script type="text/javascript" src="../static/js/common.js?v=20260309_i18n_fix1"></script>
<script type="text/javascript" src="../static/js/common.js"></script>
<script type="text/javascript" src="../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../static/vue/element/element.js"></script>
<script>
@@ -867,8 +948,9 @@
        menuSyncVersion: 0,
        menuSyncTimer: null,
        userName: localStorage.getItem(USER_STORAGE_KEY) || (window.WCS_I18N ? window.WCS_I18N.tl("管理员") : "管理员"),
        aiLayerIndex: null,
        aiTipIndex: null
        aiAssistantVisible: false,
        aiAssistantMounted: false,
        aiAssistantSrc: baseUrl + "/views/ai/diagnosis.html"
      };
    },
    computed: {
@@ -1011,10 +1093,6 @@
        clearTimeout(this.collapseTimer);
        this.collapseTimer = null;
      }
      if (this.aiTipIndex) {
        layer.close(this.aiTipIndex);
        this.aiTipIndex = null;
      }
    },
    methods: {
      t: function (key, params) {
@@ -1058,17 +1136,7 @@
        PROFILE_TAB_CONFIG.title = profileConfig.title;
        PROFILE_TAB_CONFIG.group = profileConfig.group;
        for (i = 0; i < this.tabs.length; i++) {
          if (this.isHomeTabUrl(this.tabs[i].url)) {
            this.tabs[i].title = homeConfig.title;
            this.tabs[i].group = homeConfig.group;
            this.tabs[i].home = true;
          } else if (this.resolveViewSrc(this.tabs[i].url) === this.resolveViewSrc(profileConfig.url)) {
            this.tabs[i].title = profileConfig.title;
            this.tabs[i].group = profileConfig.group;
          } else {
            this.tabs[i].title = this.translateTabTitle(this.tabs[i].title);
            this.tabs[i].group = this.tl(this.tabs[i].group);
          }
          this.syncTabMeta(this.tabs[i], homeConfig, profileConfig);
        }
        this.updateDocumentTitle(this.activeTabTitle);
        this.persistTabs();
@@ -1105,6 +1173,56 @@
      },
      translateTabTitle: function (title) {
        return this.tl(title);
      },
      findMenuMeta: function (tab) {
        var normalizedUrl;
        var i;
        var j;
        var group;
        var item;
        if (!tab) {
          return null;
        }
        normalizedUrl = this.resolveViewSrc(tab.url);
        for (i = 0; i < this.menus.length; i++) {
          group = this.menus[i];
          for (j = 0; j < group.subMenu.length; j++) {
            item = group.subMenu[j];
            if ((tab.menuKey && item.tabKey === tab.menuKey) || item.url === normalizedUrl) {
              return {
                group: group,
                item: item
              };
            }
          }
        }
        return null;
      },
      syncTabMeta: function (tab, homeConfig, profileConfig) {
        var menuMeta;
        if (!tab) {
          return;
        }
        if (this.isHomeTabUrl(tab.url)) {
          tab.title = homeConfig.title;
          tab.group = homeConfig.group;
          tab.home = true;
          return;
        }
        if (this.resolveViewSrc(tab.url) === this.resolveViewSrc(profileConfig.url)) {
          tab.title = profileConfig.title;
          tab.group = profileConfig.group;
          return;
        }
        menuMeta = this.findMenuMeta(tab);
        if (menuMeta) {
          tab.title = menuMeta.item.name;
          tab.group = menuMeta.group.menu;
          tab.menuKey = menuMeta.item.tabKey || tab.menuKey;
          return;
        }
        tab.title = this.translateTabTitle(tab.title);
        tab.group = this.tl(tab.group);
      },
      updateDocumentTitle: function (title) {
        document.title = title + " - " + this.t("app.title");
@@ -1395,7 +1513,7 @@
        script = frameDocument.createElement("script");
        script.id = "wcs-i18n-bridge-script";
        script.type = "text/javascript";
        script.src = baseUrl + "/static/js/common.js?v=20260309_i18n_fix1";
        script.src = baseUrl + "/static/js/common.js";
        script.onload = applyFrameI18n;
        frameDocument.head.appendChild(script);
      },
@@ -1664,6 +1782,7 @@
            that.menuLoading = false;
            if (res.code === 200) {
              that.menus = that.normalizeMenuData(res.data || []);
              that.refreshI18nState();
              that.syncMenuStateByUrl(that.activeTabUrl);
            } else if (res.code === 403) {
              top.location.href = baseUrl + "/login";
@@ -1821,72 +1940,14 @@
          document.exitFullscreen();
        }
      },
      showAiTip: function () {
        this.hideAiTip();
        this.aiTipIndex = layer.tips(this.t("common.aiAssistant"), "#ai-assistant-btn", {
          tips: [1, "#333"],
          time: -1
        });
      },
      hideAiTip: function () {
        if (this.aiTipIndex) {
          layer.close(this.aiTipIndex);
          this.aiTipIndex = null;
        }
      },
      openAiAssistant: function () {
        var that = this;
        var $layero;
        var $shade;
        this.hideAiTip();
        if (this.aiLayerIndex !== null && $("#layui-layer" + this.aiLayerIndex).length > 0) {
          $layero = $("#layui-layer" + this.aiLayerIndex);
          $shade = $("#layui-layer-shade" + this.aiLayerIndex);
          $shade.show().css("opacity", 0.1);
          $layero.show();
          $layero.removeClass("ai-drawer-layer-close");
          $layero.removeClass("ai-drawer-layer");
          void $layero.get(0).offsetWidth;
          $layero.addClass("ai-drawer-layer");
          return;
        if (!this.aiAssistantMounted) {
          this.aiAssistantMounted = true;
        }
        layer.open({
          type: 2,
          title: false,
          closeBtn: 0,
          shadeClose: false,
          shade: 0.1,
          area: ["600px", "100%"],
          offset: "r",
          anim: -1,
          isOutAnim: false,
          skin: "ai-drawer-layer",
          content: "ai/diagnosis.html",
          success: function (layero, index) {
            var shadeId;
            var $shadeEl;
            that.aiLayerIndex = index;
            shadeId = layero.attr("id").replace("layui-layer", "layui-layer-shade");
            $shadeEl = $("#" + shadeId);
            $shadeEl.css({
              "backdrop-filter": "blur(3px)",
              transition: "opacity 0.8s"
            });
            $shadeEl.off("click.aiAssistant").on("click.aiAssistant", function () {
              layero.addClass("ai-drawer-layer-close");
              $shadeEl.css("opacity", 0);
              setTimeout(function () {
                layero.hide();
                $shadeEl.hide();
              }, 400);
            });
          }
        });
        this.aiAssistantVisible = true;
      },
      closeAiAssistant: function () {
        this.aiAssistantVisible = false;
      },
      handleUserCommand: function (command) {
        if (command === "profile") {
src/main/webapp/views/locMap/locMap.html
@@ -5,7 +5,7 @@
  <title>库位地图</title>
  <link rel="stylesheet" href="../../static/vue/element/element.css">
  <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
  <script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1"></script>
  <script type="text/javascript" src="../../static/js/common.js"></script>
  <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
  <script type="text/javascript" src="../../static/vue/element/element.js"></script>
  <script src="../../static/js/gsap.min.js"></script>
src/main/webapp/views/login.html
@@ -260,27 +260,57 @@
        }
        .login-submit {
            width: 100%;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            font-size: 16px;
            font-weight: 600;
            border-radius: 16px;
            margin-top: 6px;
            box-shadow: 0 14px 24px rgba(46, 115, 223, 0.28);
        }
        .login-actions {
            display: flex;
            flex-direction: column;
            gap: 12px;
            margin-top: 6px;
        }
        .login-actions .el-button {
            width: 100%;
            margin-left: 0;
            box-sizing: border-box;
        }
        .login-passkey {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            border-radius: 16px;
            border-color: rgba(71, 110, 162, 0.24);
            color: #26496a;
            background: rgba(245, 249, 255, 0.96);
        }
        .login-passkey-tip {
            margin-top: 12px;
            color: #7b8c9d;
            font-size: 12px;
            line-height: 1.7;
        }
        .tools-dialog .el-dialog,
        .text-dialog .el-dialog,
        .upload-dialog .el-dialog {
        .upload-dialog .el-dialog,
        .mfa-dialog .el-dialog {
            border-radius: 20px;
            overflow: hidden;
        }
        .tools-dialog .el-dialog__header,
        .text-dialog .el-dialog__header,
        .upload-dialog .el-dialog__header {
        .upload-dialog .el-dialog__header,
        .mfa-dialog .el-dialog__header {
            padding: 18px 20px 12px;
            border-bottom: 1px solid rgba(222, 230, 239, 0.92);
            background: #f8fbff;
@@ -288,14 +318,16 @@
        .tools-dialog .el-dialog__title,
        .text-dialog .el-dialog__title,
        .upload-dialog .el-dialog__title {
        .upload-dialog .el-dialog__title,
        .mfa-dialog .el-dialog__title {
            font-weight: 700;
            color: #243447;
        }
        .tools-dialog .el-dialog__body,
        .text-dialog .el-dialog__body,
        .upload-dialog .el-dialog__body {
        .upload-dialog .el-dialog__body,
        .mfa-dialog .el-dialog__body {
            padding: 18px 20px 20px;
        }
@@ -357,6 +389,33 @@
            display: inline-flex;
            align-items: center;
            justify-content: center;
        }
        .mfa-tip {
            color: #6f7f92;
            font-size: 13px;
            line-height: 1.7;
            margin-bottom: 12px;
        }
        .mfa-account {
            margin-bottom: 14px;
            padding: 12px 14px;
            border-radius: 14px;
            background: rgba(244, 248, 253, 0.92);
            color: #42576d;
            font-size: 13px;
        }
        .mfa-account strong {
            color: #1e3956;
        }
        .mfa-footer {
            display: flex;
            justify-content: flex-end;
            gap: 10px;
            margin-top: 14px;
        }
        @media (max-width: 640px) {
@@ -423,37 +482,34 @@
    <div class="login-layout">
        <section class="hero-panel animate__animated animate__fadeInLeft">
            <div class="brand-chip">Zoneyung WCS</div>
            <div class="hero-title">WCS系统让设备调度、任务执行与现场监控保持在同一套业务链路中。</div>
            <div class="hero-title">{{ text('login.hero.title', 'WCS系统让设备调度、任务执行与现场监控保持在同一套业务链路中。') }}</div>
            <div class="hero-subtitle">
                Warehouse Control System 面向自动化立体仓库现场执行层,围绕堆垛机、RGV、输送站台、任务指令、
                库位状态和日志追踪进行统一调度与可视化管理,帮助仓储系统实现稳定、可追溯、可联动的作业控制。
                浙江中扬立库技术有限公司长期专注于自动化立体仓库与智能物流系统建设,覆盖方案设计、软件控制、
                设备集成与项目实施交付。
                {{ text('login.hero.subtitle', 'Warehouse Control System 面向自动化立体仓库现场执行层,围绕堆垛机、RGV、输送站台、任务指令、库位状态和日志追踪进行统一调度与可视化管理,帮助仓储系统实现稳定、可追溯、可联动的作业控制。浙江中扬立库技术有限公司长期专注于自动化立体仓库与智能物流系统建设,覆盖方案设计、软件控制、设备集成与项目实施交付。') }}
            </div>
            <div class="hero-metrics">
                <div class="metric-card">
                    <div class="metric-value">调度</div>
                    <div class="metric-label">统一编排现场执行任务</div>
                    <div class="metric-value">{{ text('login.hero.metric.dispatch.title', '调度') }}</div>
                    <div class="metric-label">{{ text('login.hero.metric.dispatch.desc', '统一编排现场执行任务') }}</div>
                </div>
                <div class="metric-card">
                    <div class="metric-value">追溯</div>
                    <div class="metric-label">作业、设备、日志全链路留痕</div>
                    <div class="metric-value">{{ text('login.hero.metric.trace.title', '追溯') }}</div>
                    <div class="metric-label">{{ text('login.hero.metric.trace.desc', '作业、设备、日志全链路留痕') }}</div>
                </div>
                <div class="metric-card">
                    <div class="metric-value">集成</div>
                    <div class="metric-label">对接WMS、设备与业务规则</div>
                    <div class="metric-value">{{ text('login.hero.metric.integration.title', '集成') }}</div>
                    <div class="metric-label">{{ text('login.hero.metric.integration.desc', '对接WMS、设备与业务规则') }}</div>
                </div>
            </div>
            <div class="hero-footer">
                <span>浙江中扬立库技术有限公司</span>
                <span>自动化立体仓库与智能物流系统解决方案</span>
                <span>{{ text('login.hero.company.name', '浙江中扬立库技术有限公司') }}</span>
                <span>{{ text('login.hero.company.solution', '自动化立体仓库与智能物流系统解决方案') }}</span>
            </div>
        </section>
        <section class="login-card animate__animated animate__fadeInUp">
            <div class="login-head">
                <h1 class="login-title" @click="handleTitleClick">{{ text('login.title', 'WCS系统V3.0') }}</h1>
                <div class="login-subtitle">请输入账号和密码进入系统。</div>
                <div class="login-subtitle">{{ text('login.subtitle', '请输入账号和密码进入系统。') }}</div>
            </div>
            <div class="login-body">
                <el-form ref="loginForm" class="login-form" :model="loginForm" :rules="loginRules" @submit.native.prevent>
@@ -467,15 +523,23 @@
                            <i slot="prefix" class="el-input__icon el-icon-lock"></i>
                        </el-input>
                    </el-form-item>
                    <el-button class="login-submit" type="primary" :loading="loginLoading" @click="handleLogin">
                        {{ text('login.submit', '登录') }}
                    </el-button>
                    <div class="login-actions">
                        <el-button class="login-submit" type="primary" :loading="loginLoading" @click="handleLogin">
                            {{ text('login.submit', '登录') }}
                        </el-button>
                        <el-button class="login-passkey" plain :loading="passkeyLoading" @click="handlePasskeyLogin">
                            {{ text('login.passkey.submit', '通行密钥登录') }}
                        </el-button>
                    </div>
                    <div class="login-passkey-tip">
                        {{ text('login.passkey.tip', '支持使用设备生物识别或安全密钥登录。可先输入账号缩小凭证范围,留空则尝试发现式登录。') }}
                    </div>
                </el-form>
            </div>
        </section>
    </div>
    <el-dialog class="tools-dialog" title="系统工具" :visible.sync="toolsDialogVisible" width="560px" :close-on-click-modal="true" append-to-body>
    <el-dialog class="tools-dialog" :title="text('login.tools.title', '系统工具')" :visible.sync="toolsDialogVisible" width="560px" :close-on-click-modal="true" append-to-body>
        <div class="tool-group">
            <div class="tool-title">{{ text('login.tools.recommended', '推荐操作') }}</div>
            <div class="tool-actions">
@@ -494,31 +558,53 @@
        </div>
    </el-dialog>
    <el-dialog
        class="mfa-dialog"
        :title="text('login.mfa.title', 'MFA二次验证')"
        :visible.sync="mfaDialogVisible"
        width="420px"
        :close-on-click-modal="false"
        @close="closeMfaDialog"
        append-to-body>
        <div class="mfa-tip">{{ text('login.mfa.tip', '账号密码已通过,请输入身份验证器中的 6 位动态验证码后继续登录。') }}</div>
        <div class="mfa-account">{{ text('login.mfa.currentAccount', '当前账号:') }}<strong>{{ mfaPending.username || loginForm.mobile || '--' }}</strong></div>
        <el-form ref="mfaForm" :model="mfaForm" :rules="mfaRules" label-width="82px" size="small" @submit.native.prevent>
            <el-form-item :label="text('login.mfa.codeLabel', '验证码')" prop="code">
                <el-input v-model.trim="mfaForm.code" maxlength="6" :placeholder="text('login.mfa.codePlaceholder', '请输入6位动态码')" @keyup.enter.native="handleMfaLogin"></el-input>
            </el-form-item>
        </el-form>
        <div class="mfa-footer">
            <el-button @click="closeMfaDialog">{{ text('login.mfa.cancel', '取消') }}</el-button>
            <el-button type="primary" :loading="mfaLoading" @click="handleMfaLogin">{{ text('login.mfa.submit', '验证并登录') }}</el-button>
        </div>
    </el-dialog>
    <el-dialog class="text-dialog" :title="textDialog.title" :visible.sync="textDialogVisible" width="720px" append-to-body>
        <div class="dialog-text-label">{{ textDialog.label }}</div>
        <div v-if="textDialog.tip" class="dialog-text-tip">{{ textDialog.tip }}</div>
        <el-input v-model="textDialog.text" type="textarea" :rows="10" readonly></el-input>
        <div class="text-footer">
            <el-button @click="textDialogVisible = false">关闭</el-button>
            <el-button @click="textDialogVisible = false">{{ text('login.dialog.close', '关闭') }}</el-button>
            <el-button type="primary" @click="copyText">{{ text('copy', '复制') }}</el-button>
        </div>
    </el-dialog>
    <el-dialog class="upload-dialog" title="录入许可证" :visible.sync="uploadDialogVisible" width="760px" append-to-body>
        <div class="dialog-text-label">许可证 Base64</div>
        <div class="dialog-text-tip">将许可证服务端返回的 license 字段完整粘贴到这里。</div>
    <el-dialog class="upload-dialog" :title="text('login.tools.uploadLicense', '录入许可证')" :visible.sync="uploadDialogVisible" width="760px" append-to-body>
        <div class="dialog-text-label">{{ text('login.license.label', '许可证 Base64') }}</div>
        <div class="dialog-text-tip">{{ text('login.license.tip', '将许可证服务端返回的 license 字段完整粘贴到这里。') }}</div>
        <el-input v-model.trim="licenseBase64" type="textarea" :rows="10"></el-input>
        <div class="upload-footer">
            <el-button @click="uploadDialogVisible = false">取消</el-button>
            <el-button type="primary" @click="submitLicense">提交</el-button>
            <el-button @click="uploadDialogVisible = false">{{ text('login.mfa.cancel', '取消') }}</el-button>
            <el-button type="primary" @click="submitLicense">{{ text('login.license.submit', '提交') }}</el-button>
        </div>
    </el-dialog>
</div>
</body>
<script type="text/javascript" src="../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../static/js/tools/md5.js"></script>
<script type="text/javascript" src="../static/js/common.js?v=20260309_i18n_fix1"></script>
<script type="text/javascript" src="../static/js/common.js"></script>
<script type="text/javascript" src="../static/js/webauthn-utils.js"></script>
<script type="text/javascript" src="../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../static/vue/element/element.js"></script>
<script type="text/javascript" src="../static/js/login/login.js?v=20260310_login_vue"></script>
<script type="text/javascript" src="../static/js/login/login.js?v=20260311_login_passkey"></script>
</html>
src/main/webapp/views/notifyReport/notifyReport.html
@@ -544,7 +544,7 @@
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script>
  new Vue({
    el: '#app',
src/main/webapp/views/password.html
@@ -96,7 +96,7 @@
</body>
<script type="text/javascript" src="../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../static/js/tools/md5.js"></script>
<script type="text/javascript" src="../static/js/common.js?v=20260309_i18n_fix1"></script>
<script type="text/javascript" src="../static/js/common.js"></script>
<script type="text/javascript" src="../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../static/vue/element/element.js"></script>
<script type="text/javascript" src="../static/js/password/password.js?v=20260310_password_vue"></script>
src/main/webapp/views/role/role_power_detail.html
@@ -31,6 +31,6 @@
</body>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/layui/layui.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1"></script>
<script type="text/javascript" src="../../static/js/common.js"></script>
<script type="text/javascript" src="../../static/js/role/rolePower.js" charset="utf-8"></script>
</html>
src/main/webapp/views/user/user.html
@@ -1,8 +1,8 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>User 管理</title>
    <meta charset="UTF-8">
    <title>系统用户</title>
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
@@ -10,9 +10,10 @@
    <link rel="stylesheet" href="../../static/css/cool.css">
    <style>
        :root {
            --card-bg: rgba(255, 255, 255, 0.94);
            --card-border: rgba(216, 226, 238, 0.95);
            --card-bg: rgba(255, 255, 255, 0.96);
            --card-border: rgba(217, 227, 238, 0.96);
            --text-main: #243447;
            --text-sub: #6d7f90;
        }
        [v-cloak] {
@@ -26,156 +27,103 @@
            color: var(--text-main);
            font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
            background:
                radial-gradient(1000px 420px at 0% -10%, rgba(44, 107, 193, 0.12), transparent 56%),
                radial-gradient(900px 400px at 100% 0%, rgba(28, 150, 126, 0.10), transparent 58%),
                linear-gradient(180deg, #f2f6fb 0%, #f8fafc 100%);
                radial-gradient(920px 360px at 0% 0%, rgba(48, 112, 196, 0.10), transparent 58%),
                radial-gradient(760px 280px at 100% 10%, rgba(25, 160, 138, 0.08), transparent 60%),
                linear-gradient(180deg, #f3f7fb 0%, #f8fafc 100%);
        }
        .page-shell {
            max-width: 1700px;
            margin: 0 auto;
            padding: 14px;
            box-sizing: border-box;
        }
        .card-shell {
            position: relative;
            border-radius: 24px;
            max-width: 1600px;
            margin: 0 auto;
            border-radius: 22px;
            border: 1px solid var(--card-border);
            background:
                radial-gradient(760px 220px at -8% 0%, rgba(43, 117, 196, 0.05), transparent 55%),
                radial-gradient(680px 200px at 108% 10%, rgba(24, 150, 129, 0.05), transparent 58%),
                radial-gradient(780px 220px at -8% 0%, rgba(45, 118, 196, 0.05), transparent 58%),
                radial-gradient(640px 180px at 106% 16%, rgba(24, 150, 129, 0.05), transparent 56%),
                var(--card-bg);
            box-shadow: 0 16px 32px rgba(44, 67, 96, 0.08);
            overflow: hidden;
        }
        .card-body {
            position: relative;
            z-index: 1;
        .card-head {
            padding: 16px 18px 14px;
            border-bottom: 1px solid rgba(223, 231, 240, 0.92);
        }
        .list-toolbar {
            padding: 12px 16px 10px;
            border-bottom: 1px solid rgba(222, 230, 239, 0.92);
        }
        .toolbar-main {
        .head-top {
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 8px;
            gap: 16px;
            flex-wrap: wrap;
            margin-bottom: 12px;
        }
        .head-title {
            display: flex;
            flex-direction: column;
            gap: 6px;
        }
        .head-title h1 {
            margin: 0;
            font-size: 20px;
            line-height: 1.2;
        }
        .head-title p {
            margin: 0;
            font-size: 13px;
            line-height: 1.6;
            color: var(--text-sub);
        }
        .head-actions {
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
        }
        .toolbar-left {
            flex: 1 1 960px;
        .head-actions .el-button {
            min-width: 96px;
        }
        .search-form {
            display: flex;
            align-items: center;
            gap: 8px;
            gap: 8px 12px;
            flex-wrap: wrap;
        }
        .toolbar-search {
            flex: 1 1 auto;
            display: flex;
            align-items: center;
            gap: 8px;
            flex-wrap: wrap;
        .search-form .el-form-item {
            margin-bottom: 0;
        }
        .toolbar-search-item {
            flex: 0 0 152px;
            min-width: 152px;
        .search-form .el-form-item__label {
            font-weight: 600;
            color: #55697d;
        }
        .toolbar-search-item.keyword {
            flex: 0 0 220px;
            min-width: 220px;
        }
        .toolbar-query-actions,
        .toolbar-ops {
            display: flex;
            gap: 8px;
            flex-wrap: wrap;
        }
        .toolbar-ops {
            justify-content: flex-end;
        }
        .list-toolbar .el-input__inner,
        .list-toolbar .el-range-editor.el-input__inner,
        .advanced-panel .el-input__inner,
        .advanced-panel .el-range-editor.el-input__inner {
            height: 32px;
            line-height: 32px;
        }
        .list-toolbar .el-range-editor.el-input__inner,
        .advanced-panel .el-range-editor.el-input__inner {
            align-items: center;
        }
        .list-toolbar .el-input__icon,
        .advanced-panel .el-input__icon {
            line-height: 32px;
        }
        .list-toolbar .el-range-editor .el-range__icon,
        .list-toolbar .el-range-editor .el-range-separator,
        .list-toolbar .el-range-editor .el-range__close-icon,
        .advanced-panel .el-range-editor .el-range__icon,
        .advanced-panel .el-range-editor .el-range-separator,
        .advanced-panel .el-range-editor .el-range__close-icon {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            height: 100%;
            line-height: 1;
        }
        .list-toolbar .el-button,
        .advanced-panel .el-button {
            padding: 8px 12px;
            border-radius: 8px;
        }
        .advanced-panel {
            padding: 10px 16px 12px;
            border-bottom: 1px solid rgba(222, 230, 239, 0.92);
            background: rgba(248, 251, 254, 0.78);
        }
        .advanced-grid {
            display: grid;
            grid-template-columns: repeat(6, minmax(0, 1fr));
            gap: 8px;
        }
        .advanced-item {
            min-width: 0;
        }
        .advanced-item.span-2 {
            grid-column: span 2;
        .search-form .el-input__inner,
        .search-form .el-button {
            height: 34px;
            line-height: 34px;
        }
        .table-wrap {
            padding: 10px 16px;
            padding: 12px 18px 16px;
        }
        .table-shell {
            border-radius: 20px;
            border-radius: 18px;
            overflow: hidden;
            border: 1px solid rgba(217, 227, 238, 0.98);
            background: rgba(255, 255, 255, 0.95);
        }
        .table-shell .el-table {
            border-radius: 20px;
            overflow: hidden;
        }
        .table-shell .el-table th {
@@ -184,56 +132,29 @@
            font-weight: 700;
        }
        .payload-cell {
            display: inline-block;
            max-width: 280px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        .table-shell .el-table td,
        .table-shell .el-table th {
            padding: 10px 0;
        }
        .mono {
            font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
        .status-switch {
            display: inline-flex;
            align-items: center;
        }
        .pager-bar {
            padding: 0 16px 16px;
            padding: 16px 0 0;
            display: flex;
            align-items: center;
            justify-content: flex-end;
        }
        .column-popover {
            max-width: 320px;
        }
        .column-popover-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            margin-bottom: 10px;
            font-size: 13px;
            font-weight: 700;
            color: var(--text-main);
        }
        .column-list {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 8px 10px;
            max-height: 280px;
            overflow: auto;
            padding-right: 4px;
        }
        .dialog-panel .el-dialog {
            border-radius: 24px;
            border-radius: 22px;
            overflow: hidden;
        }
        .dialog-panel .el-dialog__header {
            padding: 22px 24px 12px;
            padding: 20px 22px 12px;
            background: linear-gradient(180deg, #f8fbff 0%, #f3f7fb 100%);
            border-bottom: 1px solid rgba(224, 232, 241, 0.92);
        }
@@ -244,7 +165,33 @@
        }
        .dialog-panel .el-dialog__body {
            padding: 18px 24px 8px;
            padding: 20px 22px 8px;
        }
        .dialog-form .el-form-item {
            margin-bottom: 16px;
        }
        .dialog-form .el-form-item__label {
            font-weight: 600;
            color: #5b6f83;
        }
        .dialog-form .el-input__inner,
        .dialog-form .el-select .el-input__inner {
            height: 36px;
            line-height: 36px;
        }
        .dialog-tip {
            margin: 4px 0 2px;
            padding: 10px 12px;
            border-radius: 12px;
            font-size: 12px;
            line-height: 1.6;
            color: #748497;
            background: rgba(248, 251, 255, 0.92);
            border: 1px solid rgba(226, 233, 242, 0.96);
        }
        .dialog-footer {
@@ -253,418 +200,230 @@
            gap: 10px;
        }
        @media (max-width: 1520px) {
            .advanced-grid {
                grid-template-columns: repeat(5, minmax(0, 1fr));
            }
        .reset-user {
            margin-bottom: 16px;
            padding: 12px 14px;
            border-radius: 12px;
            background: rgba(248, 251, 255, 0.92);
            border: 1px solid rgba(226, 233, 242, 0.96);
            color: var(--text-sub);
            font-size: 13px;
        }
        @media (max-width: 1280px) {
            .advanced-grid {
                grid-template-columns: repeat(4, minmax(0, 1fr));
            }
        .table-action-danger {
            color: #f56c6c;
        }
        @media (max-width: 960px) {
            .toolbar-left {
                flex-basis: 100%;
            }
            .advanced-grid {
                grid-template-columns: repeat(3, minmax(0, 1fr));
            }
            .advanced-item.span-2 {
                grid-column: span 2;
            }
        }
        @media (max-width: 720px) {
        @media (max-width: 768px) {
            .page-shell {
                padding: 12px;
                padding: 10px;
            }
            .toolbar-search-item,
            .toolbar-search-item.keyword {
                min-width: 100%;
                flex-basis: 100%;
            .card-head,
            .table-wrap {
                padding-left: 12px;
                padding-right: 12px;
            }
            .toolbar-query-actions,
            .toolbar-ops {
                width: 100%;
            }
            .advanced-grid {
                grid-template-columns: repeat(2, minmax(0, 1fr));
            }
            .advanced-item.span-2 {
                grid-column: span 2;
            }
        }
        @media (max-width: 560px) {
            .advanced-grid {
                grid-template-columns: 1fr;
            }
            .advanced-item.span-2 {
                grid-column: auto;
            }
            .list-toolbar,
            .advanced-panel,
            .table-wrap,
            .pager-bar {
                padding-left: 14px;
                padding-right: 14px;
            }
            .column-list {
                grid-template-columns: 1fr;
            .dialog-panel .el-dialog {
                width: calc(100% - 24px) !important;
            }
        }
    </style>
</head>
<body>
<div id="app" class="page-shell" v-cloak>
    <section class="card-shell list-card">
        <div class="card-body">
            <div class="list-toolbar">
                <div class="toolbar-main">
                    <div class="toolbar-left">
                        <div class="toolbar-search">
                            <div class="toolbar-search-item keyword">
                                <el-input
                                    v-model.trim="searchForm.condition"
                                    size="small"
                                    clearable
                                    placeholder="请输入"
                                    @keyup.enter.native="handleSearch">
                                </el-input>
                            </div>
                            <div
                                v-for="field in quickSearchableFields"
                                :key="'quick-' + field.field"
                                class="toolbar-search-item">
                                <el-select
                                    v-if="field.kind === 'enum'"
                                    v-model="searchForm[field.field]"
                                    size="small"
                                    clearable
                                    :placeholder="field.label"
                                    style="width: 100%;">
                                    <el-option
                                        v-for="option in field.enumOptions"
                                        :key="'quick-' + field.field + '-' + option.rawValue"
                                        :label="option.label"
                                        :value="normalizeOptionValue(field, option.rawValue)">
                                    </el-option>
                                </el-select>
                                <el-autocomplete
                                    v-else-if="field.kind === 'foreign'"
                                    v-model="searchDisplay[field.field]"
                                    size="small"
                                    :fetch-suggestions="getSuggestionFetcher(field)"
                                    :placeholder="field.label"
                                    style="width: 100%;"
                                    @select="handleSearchForeignSelect(field, $event)"
                                    @input="handleSearchForeignInput(field)">
                                    <template slot-scope="{ item }">
                                        <div class="mono">{{ item.value }}</div>
                                        <div style="font-size:12px;color:#8a98ac;">ID: {{ item.id }}</div>
                                    </template>
                                </el-autocomplete>
                                <el-select
                                    v-else-if="field.kind === 'checkbox'"
                                    v-model="searchForm[field.field]"
                                    size="small"
                                    clearable
                                    :placeholder="field.label"
                                    style="width: 100%;">
                                    <el-option label="是" :value="normalizeOptionValue(field, field.checkboxActiveRaw)"></el-option>
                                    <el-option label="否" :value="normalizeOptionValue(field, field.checkboxInactiveRaw)"></el-option>
                                </el-select>
                                <el-input
                                    v-else
                                    v-model.trim="searchForm[field.field]"
                                    size="small"
                                    clearable
                                    :placeholder="field.label"
                                    @keyup.enter.native="handleSearch">
                                </el-input>
                            </div>
                        </div>
                        <div class="toolbar-query-actions">
                            <el-button size="small" type="primary" icon="el-icon-search" @click="handleSearch">搜索</el-button>
                            <el-button size="small" icon="el-icon-refresh-left" @click="handleReset">重置</el-button>
                            <el-button
                                v-if="hasAdvancedFilters"
                                size="small"
                                plain
                                :icon="advancedFiltersVisible ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"
                                @click="toggleAdvancedFilters">
                                {{ advancedFiltersVisible ? '收起' : '筛选' }}
                            </el-button>
                        </div>
                    </div>
                    <div class="toolbar-ops">
                        <el-button size="small" type="primary" plain icon="el-icon-plus" @click="openCreateDialog">新增</el-button>
                        <el-button size="small" type="danger" plain icon="el-icon-delete" :disabled="selection.length === 0" @click="removeSelection">删除</el-button>
                        <el-popover
                            placement="bottom"
                            width="320"
                            trigger="click"
                            popper-class="column-popover">
                            <div class="column-popover-head">
                                <span>列设置</span>
                                <div>
                                    <el-button type="text" @click="selectAllColumns">全选</el-button>
                                    <el-button type="text" @click="resetColumns">重置</el-button>
                                </div>
                            </div>
                            <div class="column-list">
                                <el-checkbox
                                    v-for="field in allColumns"
                                    :key="'column-' + field.field"
                                    :value="isColumnVisible(field.field)"
                                    @change="toggleColumn(field.field, $event)">
                                    {{ field.label }}
                                </el-checkbox>
                            </div>
                            <el-button slot="reference" size="small" plain icon="el-icon-setting">列设置</el-button>
                        </el-popover>
                        <el-button size="small" plain icon="el-icon-download" :loading="exporting" @click="exportRows">导出</el-button>
                    </div>
<div id="app" v-cloak class="page-shell">
    <div class="card-shell">
        <div class="card-head">
            <div class="head-top">
                <div class="head-title">
                    <h1>系统用户</h1>
                    <p>修改界面不再显示密码,密码调整统一通过“重置密码”完成;角色、状态等字段恢复为选择控件。</p>
                </div>
                <div class="head-actions">
                    <el-button type="primary" icon="el-icon-plus" @click="openCreateDialog">新增用户</el-button>
                    <el-button type="danger" icon="el-icon-delete" :disabled="selection.length === 0" @click="removeSelection">删除所选</el-button>
                </div>
            </div>
            <el-form :inline="true" :model="searchForm" class="search-form" @submit.native.prevent>
                <el-form-item label="登录账户">
                    <el-input v-model.trim="searchForm.username" clearable placeholder="请输入登录账户" @keyup.enter.native="handleSearch"></el-input>
                </el-form-item>
                <el-form-item label="账号">
                    <el-input v-model.trim="searchForm.mobile" clearable placeholder="请输入账号" @keyup.enter.native="handleSearch"></el-input>
                </el-form-item>
                <el-form-item>
                    <el-button type="primary" icon="el-icon-search" @click="handleSearch">查询</el-button>
                    <el-button icon="el-icon-refresh-left" @click="handleResetSearch">重置</el-button>
                </el-form-item>
            </el-form>
        </div>
            <el-collapse-transition>
                <div v-show="advancedFiltersVisible && hasAdvancedFilters" class="advanced-panel">
                    <div class="advanced-grid">
                        <div
                            v-for="field in advancedSearchableFields"
                            :key="'advanced-' + field.field"
                            :class="['advanced-item', field.kind === 'date' ? 'span-2' : '']">
                            <el-date-picker
                                v-if="field.kind === 'date'"
                                v-model="searchForm[field.field]"
                                size="small"
                                type="datetimerange"
                                unlink-panels
                                range-separator="至"
                                :start-placeholder="field.label + '开始'"
                                :end-placeholder="field.label + '结束'"
                                value-format="yyyy-MM-dd HH:mm:ss"
                                style="width: 100%;">
                            </el-date-picker>
                            <el-select
                                v-else-if="field.kind === 'enum'"
                                v-model="searchForm[field.field]"
                                size="small"
                                clearable
                                :placeholder="field.label"
                                style="width: 100%;">
                                <el-option
                                    v-for="option in field.enumOptions"
                                    :key="'advanced-' + field.field + '-' + option.rawValue"
                                    :label="option.label"
                                    :value="normalizeOptionValue(field, option.rawValue)">
                                </el-option>
                            </el-select>
                            <el-autocomplete
                                v-else-if="field.kind === 'foreign'"
                                v-model="searchDisplay[field.field]"
                                size="small"
                                :fetch-suggestions="getSuggestionFetcher(field)"
                                :placeholder="field.label"
                                style="width: 100%;"
                                @select="handleSearchForeignSelect(field, $event)"
                                @input="handleSearchForeignInput(field)">
                                <template slot-scope="{ item }">
                                    <div class="mono">{{ item.value }}</div>
                                    <div style="font-size:12px;color:#8a98ac;">ID: {{ item.id }}</div>
                                </template>
                            </el-autocomplete>
                            <el-select
                                v-else-if="field.kind === 'checkbox'"
                                v-model="searchForm[field.field]"
                                size="small"
                                clearable
                                :placeholder="field.label"
                                style="width: 100%;">
                                <el-option label="是" :value="normalizeOptionValue(field, field.checkboxActiveRaw)"></el-option>
                                <el-option label="否" :value="normalizeOptionValue(field, field.checkboxInactiveRaw)"></el-option>
                            </el-select>
                            <el-input
                                v-else
                                v-model.trim="searchForm[field.field]"
                                size="small"
                                clearable
                                :placeholder="field.label"
                                @keyup.enter.native="handleSearch">
                            </el-input>
                        </div>
                    </div>
                </div>
            </el-collapse-transition>
            <div class="table-wrap">
                <div class="table-shell">
                    <el-table
                        ref="dataTable"
                        :key="'table-' + visibleColumnKeys.join('|')"
                        v-loading="loading"
                        :data="tableData"
                        border
                        stripe
                        :height="tableHeight"
                        @selection-change="handleSelectionChange"
                        @sort-change="handleSortChange">
                        <el-table-column type="selection" width="52" align="center"></el-table-column>
                        <el-table-column
                            v-for="field in visibleColumns"
                            :key="field.field"
                            :prop="field.field"
                            :label="field.label"
                            :width="field.primaryKey ? 90 : null"
                            :min-width="field.primaryKey ? null : field.minWidth"
                            :sortable="isSortableField(field) ? 'custom' : false"
                            :show-overflow-tooltip="field.kind !== 'image'"
                            align="center">
                            <template slot-scope="scope">
                                <el-image
                                    v-if="field.kind === 'image' && getTableValue(scope.row, field)"
                                    :src="getTableValue(scope.row, field)"
                                    fit="cover"
                                    style="width: 48px; height: 48px; border-radius: 10px;">
                                </el-image>
                                <el-tag v-else-if="field.kind === 'enum'" size="mini" type="success">
                                    {{ valueOrDash(getTableValue(scope.row, field)) }}
                                </el-tag>
                                <el-tag v-else-if="field.kind === 'checkbox'" size="mini" :type="isCheckboxChecked(scope.row, field) ? 'success' : 'info'">
                                    {{ isCheckboxChecked(scope.row, field) ? '是' : '否' }}
                                </el-tag>
                                <span v-else-if="field.textarea" class="payload-cell mono" :title="stringValue(getTableValue(scope.row, field))">
                                    {{ valueOrDash(getTableValue(scope.row, field)) }}
                                </span>
                                <span v-else>{{ valueOrDash(getTableValue(scope.row, field)) }}</span>
                            </template>
                        </el-table-column>
                        <el-table-column label="操作" width="160" fixed="right" align="center">
                            <template slot-scope="scope">
                                <el-button type="text" @click="openEditDialog(scope.row)">修改</el-button>
                                <el-button type="text" style="color:#f56c6c;" @click="removeRows([scope.row[primaryKeyField]])">删除</el-button>
                            </template>
                        </el-table-column>
                    </el-table>
                </div>
        <div class="table-wrap">
            <div class="table-shell" v-loading="loading">
                <el-table
                    :data="tableData"
                    border
                    stripe
                    height="calc(100vh - 245px)"
                    @selection-change="handleSelectionChange">
                    <el-table-column type="selection" width="48" align="center"></el-table-column>
                    <el-table-column prop="username" label="登录账户" min-width="150" show-overflow-tooltip></el-table-column>
                    <el-table-column prop="mobile" label="账号" min-width="150" show-overflow-tooltip></el-table-column>
                    <el-table-column prop="roleName" label="角色" min-width="150" show-overflow-tooltip></el-table-column>
                    <el-table-column label="MFA授权" width="110" align="center">
                        <template slot-scope="scope">
                            <el-tag size="mini" :type="Number(scope.row.mfaAllow) === 1 ? 'success' : 'info'">
                                {{ Number(scope.row.mfaAllow) === 1 ? '已授权' : '未授权' }}
                            </el-tag>
                        </template>
                    </el-table-column>
                    <el-table-column label="MFA状态" width="110" align="center">
                        <template slot-scope="scope">
                            <el-tag size="mini" :type="Number(scope.row.mfaEnabled) === 1 ? 'success' : 'warning'">
                                {{ Number(scope.row.mfaEnabled) === 1 ? '已启用' : '未启用' }}
                            </el-tag>
                        </template>
                    </el-table-column>
                    <el-table-column label="状态" width="130" align="center">
                        <template slot-scope="scope">
                            <div class="status-switch">
                                <el-switch
                                    v-model="scope.row.status"
                                    :active-value="1"
                                    :inactive-value="0"
                                    active-text="正常"
                                    inactive-text="禁用"
                                    @change="toggleStatus(scope.row)">
                                </el-switch>
                            </div>
                        </template>
                    </el-table-column>
                    <el-table-column label="操作" width="220" fixed="right" align="center">
                        <template slot-scope="scope">
                            <el-button type="text" @click="openEditDialog(scope.row)">编辑</el-button>
                            <el-button type="text" @click="openResetPasswordDialog(scope.row)">重置密码</el-button>
                            <el-button type="text" class="table-action-danger" @click="removeRows([scope.row.id])">删除</el-button>
                        </template>
                    </el-table-column>
                </el-table>
            </div>
            <div class="pager-bar">
                <el-pagination
                    small
                    background
                    layout="total, sizes, prev, pager, next, jumper"
                    :current-page="page.curr"
                    :page-size="page.limit"
                    :page-sizes="[15, 30, 50, 100, 200, 500]"
                    :total="page.total"
                    @current-change="handleCurrentChange"
                    @size-change="handleSizeChange">
                    @size-change="handleSizeChange"
                    @current-change="handleCurrentChange">
                </el-pagination>
            </div>
        </div>
    </section>
    </div>
    <el-dialog
        class="dialog-panel"
        :title="dialog.mode === 'create' ? '新增 User' : '修改 User'"
        :title="dialog.mode === 'create' ? '新增用户' : '修改用户'"
        :visible.sync="dialog.visible"
        width="760px"
        :close-on-click-modal="false">
        width="620px"
        :close-on-click-modal="false"
        append-to-body>
        <el-form
            ref="dialogForm"
            :model="dialogForm"
            :rules="dialogRules"
            label-width="110px"
            size="small">
            <el-row :gutter="16">
                <el-col
                    v-for="field in editableFields"
                    :key="'dialog-' + field.field"
                    :span="field.textarea || field.kind === 'image' ? 24 : 12">
                    <el-form-item :label="field.label" :prop="field.field">
                        <el-date-picker
                            v-if="field.kind === 'date'"
                            v-model="dialogForm[field.field]"
                            type="datetime"
                            value-format="yyyy-MM-dd HH:mm:ss"
                            :placeholder="'请选择' + field.label"
                            style="width: 100%;">
                        </el-date-picker>
                        <el-select
                            v-else-if="field.kind === 'enum'"
                            v-model="dialogForm[field.field]"
                            clearable
                            :placeholder="'请选择' + field.label"
                            style="width: 100%;">
            label-width="88px"
            class="dialog-form"
            @submit.native.prevent>
            <el-row :gutter="18">
                <el-col :xs="24" :sm="12">
                    <el-form-item label="登录账户" prop="username">
                        <el-input v-model.trim="dialogForm.username" maxlength="32" placeholder="请输入登录账户"></el-input>
                    </el-form-item>
                </el-col>
                <el-col :xs="24" :sm="12">
                    <el-form-item label="账号" prop="mobile">
                        <el-input v-model.trim="dialogForm.mobile" maxlength="32" placeholder="请输入账号"></el-input>
                    </el-form-item>
                </el-col>
                <el-col :xs="24" :sm="12">
                    <el-form-item label="角色" prop="roleId">
                        <el-select v-model="dialogForm.roleId" placeholder="请选择角色" style="width: 100%;">
                            <el-option
                                v-for="option in field.enumOptions"
                                :key="'dialog-' + field.field + '-' + option.rawValue"
                                :label="option.label"
                                :value="normalizeOptionValue(field, option.rawValue)">
                                v-for="role in roles"
                                :key="role.id"
                                :label="role.name"
                                :value="Number(role.id)">
                            </el-option>
                        </el-select>
                        <el-autocomplete
                            v-else-if="field.kind === 'foreign'"
                            v-model="dialogDisplay[field.field]"
                            :fetch-suggestions="getSuggestionFetcher(field)"
                            :placeholder="'请输入' + field.label"
                            style="width: 100%;"
                            @select="handleForeignSelect(field, $event)"
                            @input="handleForeignInput(field)">
                            <template slot-scope="{ item }">
                                <div class="mono">{{ item.value }}</div>
                                <div style="font-size:12px;color:#8a98ac;">ID: {{ item.id }}</div>
                            </template>
                        </el-autocomplete>
                        <el-switch
                            v-else-if="field.kind === 'checkbox'"
                            v-model="dialogForm[field.field]"
                            :active-value="normalizeOptionValue(field, field.checkboxActiveRaw)"
                            :inactive-value="normalizeOptionValue(field, field.checkboxInactiveRaw)"
                            active-color="#13ce66"
                            inactive-color="#c0c4cc">
                        </el-switch>
                        <el-input
                            v-else-if="field.textarea"
                            v-model.trim="dialogForm[field.field]"
                            type="textarea"
                            :rows="3"
                            :placeholder="'请输入' + field.label">
                        </el-input>
                        <el-input
                            v-else
                            v-model.trim="dialogForm[field.field]"
                            :placeholder="'请输入' + field.label">
                        </el-input>
                    </el-form-item>
                </el-col>
                <el-col :xs="24" :sm="12">
                    <el-form-item label="状态">
                        <el-select v-model="dialogForm.status" placeholder="请选择状态" style="width: 100%;">
                            <el-option label="正常" :value="1"></el-option>
                            <el-option label="禁用" :value="0"></el-option>
                        </el-select>
                    </el-form-item>
                </el-col>
                <el-col :xs="24" :sm="12">
                    <el-form-item label="MFA授权">
                        <el-select v-model="dialogForm.mfaAllow" placeholder="请选择" style="width: 100%;">
                            <el-option label="不允许" :value="0"></el-option>
                            <el-option label="允许" :value="1"></el-option>
                        </el-select>
                    </el-form-item>
                </el-col>
                <el-col v-if="dialog.mode === 'create'" :xs="24" :sm="12">
                    <el-form-item label="初始密码" prop="password">
                        <el-input v-model.trim="dialogForm.password" show-password maxlength="32" placeholder="请输入初始密码"></el-input>
                    </el-form-item>
                </el-col>
            </el-row>
            <div v-if="dialog.mode === 'edit'" class="dialog-tip">
                修改界面不显示密码。如需调整密码,请关闭当前窗口后使用列表里的“重置密码”。
            </div>
        </el-form>
        <div slot="footer" class="dialog-footer">
            <el-button @click="dialog.visible = false">取消</el-button>
            <el-button type="primary" :loading="dialog.submitting" @click="submitDialog">保存</el-button>
        </div>
        <span slot="footer" class="dialog-footer">
            <el-button @click="closeDialog">取消</el-button>
            <el-button type="primary" :loading="dialogSubmitting" @click="submitDialog">保存</el-button>
        </span>
    </el-dialog>
    <el-dialog
        class="dialog-panel"
        title="重置密码"
        :visible.sync="resetDialog.visible"
        width="420px"
        :close-on-click-modal="false"
        append-to-body>
        <div class="reset-user">当前用户:{{ resetDialog.username || '--' }}</div>
        <el-form
            ref="resetForm"
            :model="resetDialog"
            :rules="resetRules"
            label-width="72px"
            class="dialog-form"
            @submit.native.prevent>
            <el-form-item label="新密码" prop="password">
                <el-input v-model.trim="resetDialog.password" show-password maxlength="32" placeholder="请输入新密码"></el-input>
            </el-form-item>
        </el-form>
        <span slot="footer" class="dialog-footer">
            <el-button @click="closeResetDialog">取消</el-button>
            <el-button type="primary" :loading="resetSubmitting" @click="submitResetPassword">确定</el-button>
        </span>
    </el-dialog>
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/user/user.js?v=20260310" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/tools/md5.js"></script>
<script type="text/javascript" src="../../static/js/user/user.js?v=20260311_user_restore1" charset="utf-8"></script>
</body>
</html>
src/main/webapp/views/watch/console.html
@@ -508,7 +508,7 @@
                </style>
                <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
                <script type="text/javascript" src="../../static/js/handlebars/handlebars-v4.5.3.js"></script>
            <script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1"></script>
            <script type="text/javascript" src="../../static/js/common.js"></script>
            <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
            <script type="text/javascript" src="../../static/vue/element/element.js"></script>
            <script src="../../static/js/gsap.min.js"></script>
@@ -553,7 +553,7 @@
        <script src="../../components/WatchDualCrnCard.js"></script>
        <script src="../../components/DevpCard.js"></script>
        <script src="../../components/WatchRgvCard.js"></script>
        <script src="../../components/MapCanvas.js"></script>
        <script src="../../components/MapCanvas.js?v=20260311_resize_debounce1"></script>
        <script>
            let ws;
            var app = new Vue({
src/main/webapp/views/watch/console_html.html
@@ -9,7 +9,7 @@
        <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
        <script type="text/javascript" src="../../static/layui/layui.js"></script>
        <script type="text/javascript" src="../../static/js/handlebars/handlebars-v4.5.3.js"></script>
        <script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1"></script>
        <script type="text/javascript" src="../../static/js/common.js"></script>
        <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
        <script type="text/javascript" src="../../static/vue/element/element.js"></script>
    </head>
src/main/webapp/views/watch/console_pixijs.html
@@ -11,7 +11,7 @@
  <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
  <script type="text/javascript" src="../../static/layui/layui.js"></script>
  <script type="text/javascript" src="../../static/js/handlebars/handlebars-v4.5.3.js"></script>
  <script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1"></script>
  <script type="text/javascript" src="../../static/js/common.js"></script>
  <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
  <script type="text/javascript" src="../../static/vue/element/element.js"></script>
  <script src="../../static/js/gsap.min.js"></script>
src/main/webapp/views/watch/stationColorConfig.html
@@ -284,7 +284,7 @@
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/watch/stationColorConfig.js" charset="utf-8"></script>
</body>
</html>
src/main/webapp/views/wrkMast/wrkMast.html
@@ -1,425 +1,421 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>任务管理</title>
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <link rel="stylesheet" href="../../static/vue/element/element.css">
    <link rel="stylesheet" href="../../static/css/cool.css">
    <style>
        :root {
            --card-bg: rgba(255, 255, 255, 0.94);
            --card-border: rgba(216, 226, 238, 0.95);
            --text-main: #243447;
        }
    <head>
        <meta charset="UTF-8">
        <title>任务管理</title>
        <link rel="stylesheet" href="../../static/vue/element/element.css">
        <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
        <script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1"></script>
        <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
        <script type="text/javascript" src="../../static/vue/element/element.js"></script>
        <style>
            [v-cloak] {
                display: none;
            }
            .el-table .success-row {
                background: #b6ff8e;
            }
        </style>
    </head>
        [v-cloak] {
            display: none;
        }
    <body>
        <div id="app" v-cloak style="display: flex;justify-content: center;flex-wrap: wrap;">
            <div style="width: 100%;">
                <el-card class="box-card">
                    <el-form :inline="true" :model="tableSearchParam" class="demo-form-inline">
                        <el-form-item label="">
                            <el-input v-model="tableSearchParam.wrk_no" placeholder="工作号"></el-input>
                        </el-form-item>
                        <el-form-item label="">
                            <el-input v-model="tableSearchParam.wms_wrk_no" placeholder="WMS工作号"></el-input>
                        </el-form-item>
                        <el-form-item label="">
                            <el-input v-model="tableSearchParam.source_loc_no" placeholder="源库位"></el-input>
                        </el-form-item>
                        <el-form-item label="">
                            <el-input v-model="tableSearchParam.loc_no" placeholder="目标库位"></el-input>
                        </el-form-item>
                        <el-form-item label="">
                            <el-input v-model="tableSearchParam.crn_no" placeholder="堆垛机"></el-input>
                        </el-form-item>
                        <el-form-item label="">
                            <el-input v-model="tableSearchParam.dual_crn_no" placeholder="双工位堆垛机"></el-input>
                        </el-form-item>
                        <el-form-item>
                            <el-button type="primary" @click="getTableData">查询</el-button>
                            <el-button type="primary" @click="resetParam">重置</el-button>
                        </el-form-item>
                    </el-form>
                    <el-table ref="singleTable" :data="tableData" style="width: 100%;">
                        <el-table-column property="wrkNo" label="工作号">
                        </el-table-column>
                        <el-table-column property="wmsWrkNo" label="WMS任务号">
                        </el-table-column>
                        <el-table-column property="wrkSts$" label="工作状态">
                        </el-table-column>
                        <el-table-column property="ioType$" label="任务类型">
                        </el-table-column>
                        <el-table-column property="ioPri" label="优先级">
                        </el-table-column>
                        <el-table-column property="sourceStaNo" label="源站">
                        </el-table-column>
                        <el-table-column property="staNo" label="目标站">
                        </el-table-column>
                        <el-table-column property="sourceLocNo" label="源库位">
                        </el-table-column>
                        <el-table-column property="locNo" label="目标库位">
                        </el-table-column>
                        <el-table-column property="modiTime$" label="时间">
                        </el-table-column>
                        <el-table-column property="barcode" label="托盘码">
                        </el-table-column>
                        <el-table-column property="crnNo" label="堆垛机">
                        </el-table-column>
                        <el-table-column property="dualCrnNo" label="双工位堆垛机">
                        </el-table-column>
                        <el-table-column property="batch" label="批次">
                        </el-table-column>
                        <el-table-column property="batchSeq" label="批次序列">
                        </el-table-column>
                        <el-table-column property="systemMsg" label="系统消息">
                        </el-table-column>
                        <el-table-column label="操作" width="100">
                            <template slot-scope="scope">
                                <el-dropdown @command="(command)=>{handleCommand(command, scope.row)}">
                                    <el-button icon="el-icon-more" size="mini" type="primary"></el-button>
                                    <el-dropdown-menu slot="dropdown">
<!--                                        <el-dropdown-item command="change">修改</el-dropdown-item>-->
                                        <el-dropdown-item command="complete">完成</el-dropdown-item>
                                        <el-dropdown-item command="cancel">取消</el-dropdown-item>
<!--                                        <el-dropdown-item command="shuttleCommand">穿梭车指令</el-dropdown-item>-->
                                    </el-dropdown-menu>
                                </el-dropdown>
                            </template>
                        </el-table-column>
                    </el-table>
        html,
        body {
            margin: 0;
            min-height: 100%;
            color: var(--text-main);
            font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
            background:
                radial-gradient(1000px 420px at 0% -10%, rgba(44, 107, 193, 0.12), transparent 56%),
                radial-gradient(900px 400px at 100% 0%, rgba(28, 150, 126, 0.10), transparent 58%),
                linear-gradient(180deg, #f2f6fb 0%, #f8fafc 100%);
        }
                    <div style="margin-top: 10px;">
                        <el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange"
                            :current-page="currentPage" :page-sizes="pageSizes" :page-size="pageSize"
                            layout="total, sizes, prev, pager, next, jumper" :total="pageTotal">
                        </el-pagination>
                    </div>
                </el-card>
            </div>
        .page-shell {
            max-width: 1800px;
            margin: 0 auto;
            padding: 14px;
            box-sizing: border-box;
        }
            <el-dialog :title="shuttleCommandTitle" :visible.sync="shuttleCommandVisible">
                <el-table ref="singleTable" :data="shuttleCommandData.commands" style="width: 100%;" :row-class-name="tableRowClassName">
                    <el-table-column property="mode$" label="命令类型">
                    </el-table-column>
                    <el-table-column property="start" label="起点">
                        <template slot-scope="scope">
                            <div v-if="scope.row.nodes">
                                x:{{ scope.row.nodes[0].x }}
                                y:{{ scope.row.nodes[0].y }}
                                z:{{ scope.row.nodes[0].z }}
                            </div>
                        </template>
                    </el-table-column>
                    <el-table-column property="target" label="终点">
                        <template slot-scope="scope">
                            <div v-if="scope.row.nodes">
                                x:{{ scope.row.nodes[scope.row.nodes.length-1].x }}
                                y:{{ scope.row.nodes[scope.row.nodes.length-1].y }}
                                z:{{ scope.row.nodes[scope.row.nodes.length-1].z }}
                            </div>
                        </template>
                    </el-table-column>
                    <el-table-column property="taskNo" label="任务号">
                    </el-table-column>
                    <el-table-column property="shuttleNo" label="穿梭车">
                    </el-table-column>
                    <el-table-column property="complete" label="是否完成">
                        <template slot-scope="scope">
                            <el-switch
                                    v-model="scope.row.complete"
                                    active-color="#13ce66"
                                    @change="changeComplete(scope)">
                            </el-switch>
                        </template>
                    </el-table-column>
                    <el-table-column label="操作" width="100">
                        <template slot-scope="scope">
                            <el-button @click="shuttleCommandRollback(scope)" size="mini">回退指令</el-button>
                        </template>
                    </el-table-column>
                </el-table>
                <div slot="footer" class="dialog-footer">
                    <el-button @click="shuttleCommandVisible = false">关闭</el-button>
                </div>
            </el-dialog>
        .card-shell {
            position: relative;
            border-radius: 24px;
            border: 1px solid var(--card-border);
            background:
                radial-gradient(760px 220px at -8% 0%, rgba(43, 117, 196, 0.05), transparent 55%),
                radial-gradient(680px 200px at 108% 10%, rgba(24, 150, 129, 0.05), transparent 58%),
                var(--card-bg);
            box-shadow: 0 16px 32px rgba(44, 67, 96, 0.08);
            overflow: hidden;
        }
        </div>
        <script>
            var app = new Vue({
                el: '#app',
                data: {
                    tableData: [],
                    currentPage: 1,
                    pageSizes: [16, 30, 50, 100, 150, 200],
                    pageSize: 30,
                    pageTotal: 0,
                    tableSearchParam: {
                        wrk_no: null,
                        wms_wrk_no: null,
                    },
                    shuttleCommandVisible: false,
                    shuttleCommandData: {
                        assignCommand: {
                            commands: []
                        },
                        commandStep: 0
                    },
                    shuttleCommandWrkNo: null,
                    shuttleCommandLabelWidth: '80px',
                    shuttleCommandTitle: ''
                },
                created() {
                    this.init()
                },
                methods: {
                    init() {
                        this.getTableData()
                    },
                    getTableData() {
                        let that = this;
                        let data = JSON.parse(JSON.stringify(this.tableSearchParam))
                        data.curr = this.currentPage
                        data.limit = this.pageSize
                        if (this.tableSearchParam.datetime != null) {
                            data.datetime = null
                            data.create_time = this.tableSearchParam.datetime[0] + " - " + this.tableSearchParam.datetime[1]
                        }
                        $.ajax({
                            url: baseUrl + "/wrkMast/list/auth",
                            headers: {
                                'token': localStorage.getItem('token')
                            },
                            data: data,
                            dataType: 'json',
                            contentType: 'application/json;charset=UTF-8',
                            method: 'GET',
                            success: function(res) {
                                if (res.code == 200) {
                                    that.tableData = res.data.records
                                    that.pageTotal = res.data.total
                                } else if (res.code === 403) {
                                    top.location.href = baseUrl + "/";
                                } else {
                                    that.$message({
                                        message: res.msg,
                                        type: 'error'
                                    });
                                }
                            }
                        });
                    },
                    handleSizeChange(val) {
                        console.log(`每页 ${val} 条`);
                        this.pageSize = val
                        this.getTableData()
                    },
                    handleCurrentChange(val) {
                        console.log(`当前页: ${val}`);
                        this.currentPage = val
                        this.getTableData()
                    },
                    resetParam() {
                        this.tableSearchParam = {
                            task_no: null,
                            status: null,
                            wrk_no: null
                        }
                        this.getTableData()
                    },
                    handleCommand(command, row) {
                        switch (command) {
                            case "complete":
                                this.completeTask(row)
                                break;
                            case "cancel":
                                this.cancelTask(row)
                                break;
                            case "shuttleCommand":
                                this.showShuttleCommand(row.wrkNo)
                                break;
                        }
                    },
                    showShuttleCommand(wrkNo){
                        let that = this;
                        $.ajax({
                            url: baseUrl + "/shuttle/command/query",
                            headers: {
                                'token': localStorage.getItem('token')
                            },
                            data: {
                                wrkNo: wrkNo
                            },
                            method: 'GET',
                            success: function(res) {
                                if (res.code == 200) {
                                    console.log(res)
                                    that.shuttleCommandVisible = true;
                                    that.shuttleCommandData = res.data;
                                    that.shuttleCommandWrkNo = wrkNo;
                                } else if (res.code === 403) {
                                    top.location.href = baseUrl + "/";
                                } else {
                                    that.$message({
                                        message: res.msg,
                                        type: 'error'
                                    });
                                }
                            }
                        });
                    },
                    completeTask(row) {
                        let that = this
                        this.$confirm('确定完成该任务吗?', '提示', {
                            confirmButtonText: '确定',
                            cancelButtonText: '取消',
                            type: 'warning'
                        }).then(() => {
                            $.ajax({
                                url: baseUrl + "/openapi/completeTask",
                                contentType: 'application/json',
                                headers: {
                                    'token': localStorage.getItem('token')
                                },
                                data: JSON.stringify({
                                    wrkNo: row.wrkNo,
                                }),
                                method: 'POST',
                                success: function(res) {
                                    if (res.code == 200) {
                                        that.$message({
                                            message: "完成成功",
                                            type: 'success'
                                        });
                                        that.getTableData()
                                    } else if (res.code === 403) {
                                        top.location.href = baseUrl + "/";
                                    } else {
                                        that.$message({
                                            message: res.msg,
                                            type: 'error'
                                        });
                                    }
                                }
                            });
                        }).catch(() => {
                            // this.$message({
                            //     type: 'info',
                            //     message: '已取消删除'
                            // });
                        });
                    },
                    cancelTask(row) {
                        let that = this
                        this.$confirm('确定取消该任务吗?', '提示', {
                            confirmButtonText: '确定',
                            cancelButtonText: '取消',
                            type: 'warning'
                        }).then(() => {
                            $.ajax({
                                url: baseUrl + "/openapi/cancelTask",
                                contentType: 'application/json',
                                headers: {
                                    'token': localStorage.getItem('token')
                                },
                                data: JSON.stringify({
                                    wrkNo: row.wrkNo,
                                }),
                                method: 'POST',
                                success: function(res) {
                                    if (res.code == 200) {
                                        that.$message({
                                            message: "取消成功",
                                            type: 'success'
                                        });
                                        that.getTableData()
                                    } else if (res.code === 403) {
                                        top.location.href = baseUrl + "/";
                                    } else {
                                        that.$message({
                                            message: res.msg,
                                            type: 'error'
                                        });
                                    }
                                }
                            });
                        }).catch(() => {
                            // this.$message({
                            //     type: 'info',
                            //     message: '已取消删除'
                            // });
                        });
                    },
                    tableRowClassName({row, rowIndex}) {
                        if (rowIndex === this.shuttleCommandData.commandStep) {
                            return 'success-row';
                        }
                        return '';
                    },
                    shuttleCommandRollback(scope) {
                        let that = this;
                        let idx = scope.$index;
                        $.ajax({
                            url: baseUrl + "/shuttle/command/rollback",
                            headers: {
                                'token': localStorage.getItem('token')
                            },
                            data: {
                                wrkNo: that.shuttleCommandWrkNo,
                                commandStep: idx
                            },
                            method: 'GET',
                            success: function(res) {
                                if (res.code == 200) {
                                    that.showShuttleCommand(that.shuttleCommandWrkNo)
                                } else if (res.code === 403) {
                                    top.location.href = baseUrl + "/";
                                } else {
                                    that.$message({
                                        message: res.msg,
                                        type: 'error'
                                    });
                                }
                            }
                        });
                    },
                    changeComplete(scope) {
                        let that = this;
                        let idx = scope.$index;
                        $.ajax({
                            url: baseUrl + "/shuttle/command/completeSwitch",
                            headers: {
                                'token': localStorage.getItem('token')
                            },
                            data: {
                                wrkNo: that.shuttleCommandWrkNo,
                                commandStep: idx,
                                complete: scope.row.complete ? 1 : 0
                            },
                            method: 'GET',
                            success: function(res) {
                                if (res.code == 200) {
                                    that.showShuttleCommand(that.shuttleCommandWrkNo)
                                } else if (res.code === 403) {
                                    top.location.href = baseUrl + "/";
                                } else {
                                    that.$message({
                                        message: res.msg,
                                        type: 'error'
                                    });
                                }
                            }
                        });
                    }
                },
            })
        </script>
    </body>
        .list-toolbar {
            padding: 12px 16px 10px;
            border-bottom: 1px solid rgba(222, 230, 239, 0.92);
        }
        .toolbar-main {
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 8px;
            flex-wrap: wrap;
        }
        .toolbar-left {
            flex: 1 1 980px;
            display: flex;
            align-items: center;
            gap: 8px;
            flex-wrap: wrap;
        }
        .toolbar-search {
            flex: 1 1 auto;
            display: flex;
            align-items: center;
            gap: 8px;
            flex-wrap: wrap;
        }
        .toolbar-search-item {
            flex: 0 0 148px;
            min-width: 148px;
        }
        .toolbar-search-item.keyword {
            flex: 0 0 220px;
            min-width: 220px;
        }
        .toolbar-query-actions,
        .toolbar-ops {
            display: flex;
            gap: 8px;
            flex-wrap: wrap;
        }
        .toolbar-ops {
            justify-content: flex-end;
        }
        .list-toolbar .el-input__inner,
        .advanced-panel .el-input__inner {
            height: 32px;
            line-height: 32px;
        }
        .list-toolbar .el-input__icon,
        .advanced-panel .el-input__icon {
            line-height: 32px;
        }
        .list-toolbar .el-button,
        .advanced-panel .el-button {
            padding: 8px 12px;
            border-radius: 8px;
        }
        .advanced-panel {
            padding: 10px 16px 12px;
            border-bottom: 1px solid rgba(222, 230, 239, 0.92);
            background: rgba(248, 251, 254, 0.78);
        }
        .advanced-grid {
            display: grid;
            grid-template-columns: repeat(4, minmax(0, 1fr));
            gap: 8px;
        }
        .advanced-item {
            min-width: 0;
        }
        .table-wrap {
            padding: 10px 16px;
        }
        .table-shell {
            border-radius: 20px;
            overflow: hidden;
            border: 1px solid rgba(217, 227, 238, 0.98);
            background: rgba(255, 255, 255, 0.95);
        }
        .table-shell .el-table {
            border-radius: 20px;
            overflow: hidden;
        }
        .table-shell .el-table th {
            background: #f7fafc;
            color: #53677d;
            font-weight: 700;
        }
        .table-shell .el-table .success-row > td {
            background: rgba(101, 198, 141, 0.14);
        }
        .payload-cell {
            display: inline-block;
            max-width: 280px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .pager-bar {
            padding: 0 16px 16px;
            display: flex;
            align-items: center;
            justify-content: flex-end;
        }
        .column-popover {
            max-width: 320px;
        }
        .column-popover-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            margin-bottom: 10px;
            font-size: 13px;
            font-weight: 700;
            color: var(--text-main);
        }
        .column-list {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 8px 10px;
            max-height: 280px;
            overflow: auto;
            padding-right: 4px;
        }
        .row-action-trigger {
            min-width: 72px;
            display: inline-flex;
            align-items: center;
            justify-content: center;
        }
        @media (max-width: 1600px) {
            .advanced-grid {
                grid-template-columns: repeat(3, minmax(0, 1fr));
            }
        }
        @media (max-width: 1280px) {
            .advanced-grid {
                grid-template-columns: repeat(2, minmax(0, 1fr));
            }
        }
        @media (max-width: 900px) {
            .page-shell {
                padding: 10px;
            }
            .list-toolbar,
            .advanced-panel,
            .table-wrap,
            .pager-bar {
                padding-left: 12px;
                padding-right: 12px;
            }
            .toolbar-search-item,
            .toolbar-search-item.keyword {
                flex: 1 1 100%;
                min-width: 100%;
            }
            .advanced-grid {
                grid-template-columns: 1fr;
            }
        }
    </style>
</head>
<body>
<div id="app" class="page-shell" v-cloak>
    <section class="card-shell">
        <div class="list-toolbar">
            <div class="toolbar-main">
                <div class="toolbar-left">
                    <div class="toolbar-search">
                        <div class="toolbar-search-item keyword">
                            <el-input
                                v-model.trim="searchForm.condition"
                                clearable
                                size="small"
                                placeholder="请输入关键字"
                                @keyup.enter.native="handleSearch">
                            </el-input>
                        </div>
                        <div class="toolbar-search-item">
                            <el-input
                                v-model.trim="searchForm.wrk_no"
                                clearable
                                size="small"
                                placeholder="工作号"
                                @keyup.enter.native="handleSearch">
                            </el-input>
                        </div>
                        <div class="toolbar-search-item">
                            <el-input
                                v-model.trim="searchForm.wms_wrk_no"
                                clearable
                                size="small"
                                placeholder="WMS任务号"
                                @keyup.enter.native="handleSearch">
                            </el-input>
                        </div>
                        <div class="toolbar-search-item">
                            <el-input
                                v-model.trim="searchForm.loc_no"
                                clearable
                                size="small"
                                placeholder="目标库位"
                                @keyup.enter.native="handleSearch">
                            </el-input>
                        </div>
                        <div class="toolbar-query-actions">
                            <el-button size="small" type="primary" icon="el-icon-search" @click="handleSearch">搜索</el-button>
                            <el-button size="small" plain icon="el-icon-refresh-left" @click="handleReset">重置</el-button>
                            <el-button size="small" plain :icon="advancedVisible ? 'el-icon-arrow-up' : 'el-icon-arrow-down'" @click="toggleAdvanced">
                                {{ advancedVisible ? '收起' : '筛选' }}
                            </el-button>
                        </div>
                    </div>
                </div>
                <div class="toolbar-ops">
                    <el-popover
                        v-model="columnPopoverVisible"
                        placement="bottom-end"
                        width="320"
                        trigger="click"
                        popper-class="column-popover">
                        <div class="column-popover-head">
                            <span>列设置</span>
                            <div>
                                <el-button type="text" size="mini" @click="showAllColumns">全选</el-button>
                                <el-button type="text" size="mini" @click="resetColumns">重置</el-button>
                            </div>
                        </div>
                        <div class="column-list">
                            <el-checkbox
                                v-for="column in columnDefs"
                                :key="'column-' + column.key"
                                :value="isColumnVisible(column.key)"
                                @change="toggleColumn(column.key, $event)">
                                {{ column.label }}
                            </el-checkbox>
                        </div>
                        <el-button slot="reference" size="small" plain icon="el-icon-setting">列设置</el-button>
                    </el-popover>
                    <el-button size="small" plain icon="el-icon-refresh" :loading="loading" @click="loadList">刷新</el-button>
                </div>
            </div>
        </div>
        <div v-show="advancedVisible" class="advanced-panel">
            <div class="advanced-grid">
                <div class="advanced-item">
                    <el-input
                        v-model.trim="searchForm.source_loc_no"
                        clearable
                        size="small"
                        placeholder="源库位"
                        @keyup.enter.native="handleSearch">
                    </el-input>
                </div>
                <div class="advanced-item">
                    <el-input
                        v-model.trim="searchForm.crn_no"
                        clearable
                        size="small"
                        placeholder="堆垛机"
                        @keyup.enter.native="handleSearch">
                    </el-input>
                </div>
                <div class="advanced-item">
                    <el-input
                        v-model.trim="searchForm.dual_crn_no"
                        clearable
                        size="small"
                        placeholder="双工位堆垛机"
                        @keyup.enter.native="handleSearch">
                    </el-input>
                </div>
            </div>
        </div>
        <div class="table-wrap">
            <div class="table-shell">
                <el-table
                    ref="dataTable"
                    :key="tableRenderKey"
                    v-loading="loading"
                    :data="tableData"
                    border
                    stripe
                    :height="tableHeight"
                    @sort-change="handleSortChange">
                    <el-table-column
                        v-for="column in visibleColumns"
                        :key="column.key"
                        :prop="column.prop"
                        :label="column.label"
                        :width="column.width"
                        :min-width="column.minWidth"
                        :sortable="column.sortable ? 'custom' : false"
                        :show-overflow-tooltip="column.showOverflow !== false"
                        :align="column.align || 'left'">
                        <template v-if="column.key === 'systemMsg'" slot-scope="scope">
                            <span class="payload-cell">{{ scope.row.systemMsg || '--' }}</span>
                        </template>
                    </el-table-column>
                    <el-table-column label="操作" fixed="right" width="100" align="center">
                        <template slot-scope="scope">
                            <el-dropdown trigger="click" @command="handleRowCommand($event, scope.row)">
                                <el-button size="mini" plain class="row-action-trigger">
                                    操作<i class="el-icon-arrow-down el-icon--right"></i>
                                </el-button>
                                <el-dropdown-menu slot="dropdown">
                                    <el-dropdown-item command="complete">完成任务</el-dropdown-item>
                                    <el-dropdown-item command="cancel" divided>取消任务</el-dropdown-item>
                                </el-dropdown-menu>
                            </el-dropdown>
                        </template>
                    </el-table-column>
                </el-table>
            </div>
        </div>
        <div class="pager-bar">
            <el-pagination
                background
                :current-page="currentPage"
                :page-size="pageSize"
                :page-sizes="pageSizes"
                layout="total, sizes, prev, pager, next, jumper"
                :total="pageTotal"
                @size-change="handleSizeChange"
                @current-change="handleCurrentChange">
            </el-pagination>
        </div>
    </section>
</div>
</body>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/wrkMast/wrkMast.js?v=20260311_wrk_mast_vue"></script>
</html>