吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1085|回复: 25
上一主题 下一主题
收起左侧

[其他原创] [HTML]适龄青年智能管理系统

[复制链接]
跳转到指定楼层
楼主
abslsp 发表于 2026-1-9 19:23 回帖奖励
本帖最后由 abslsp 于 2026-1-10 10:18 编辑

    工作原因,原来存放适龄青年数据都是表格,而且不统一,还到处放,这个文件夹有点,那个文件夹有点,查找起来很麻烦,所以整了个适龄青年智能管理系统,统一管理以前的数据。
    采用的是纯前端技术(HTML/CSS/JavaScript)构建,无需服务器支持,所有数据存储于本地浏览器IndexedDB。一个网页系统集成了数据录入、智能分类、统计分析、安全管理及体检人员管理等核心功能,旨在全方位提高人员信息管理的效率与准确性。
    包括数据总览(统计卡片、可视化图表)、人员明细查询(可以多维筛选、批量操作)、体检人员明细(快速筛选和组织上站体检人员,不影响其基本档案)、淘汰/删除人员管理(分成淘汰人员列表、已删除人员列表,在已删除人员列表里可以彻底删除)、数据维护与备份(状态逻辑配置、数据导入/导出、数据库备份与恢复等)、人员详情编辑,
适用于高效管理适龄青年信息
   

   具体功能介绍:人员详情编辑可以编辑除了身份证号以外的姓名、户主、村社、联系方式、常住地、学历,体检信息、沟通情况、备注等信息,在修改信息后还会记录信息修改(如初始导入、信息修改、体检信息修改等)。修改信息后会基于可配置的复合规则(身高、年龄、学历、意愿、政考、体检等)自动判定人员四色分类。利用规则自动判定人员(支持手动覆盖判定结果)分成类(红色预淘汰、橙色可争取、黄色一般、绿色意向)。通过CSV导入数据时对学历为高中毕业生、大专毕业生、大学毕业生等,自动转化为高中、大专、大学等显示出来,学历为大学大一、大专大二等,有或没有毕业年份的情况下,导入CSV数据时,将其转化为大学在校生、大专在校生同时补上毕业年份。导入时智能防止身份证号重复。可以分类筛选重点对象,批量放入淘汰列表和已删除列表(软删除),防止录错信息可以已删除列表彻底删除,也可以批量送到体检人员列表(不会删除数据库中的数据,仅标记)。
   

附上
script部分代码
[JavaScript] 纯文本查看 复制代码
<script>
        // --- 0. Utilities (新增) ---
        // 防止 XSS 攻击的 HTML 转义函数
        const escapeHtml = (unsafe) => {
            if (typeof unsafe !== 'string') return unsafe;
            return unsafe
                 .replace(/&/g, "&")
                 .replace(/</g, "<")
                 .replace(/>/g, ">")
                 .replace(/"/g, """)
                 .replace(/'/g, "'");
        };

        // 【新增】防抖函数:避免频繁触发渲染
        const debounce = (func, wait) => {
            let timeout;
            return function(...args) {
                clearTimeout(timeout);
                timeout = setTimeout(() => func.apply(this, args), wait);
            };
        };
    
        // --- 0. Configuration Manager ---
        const ConfigManager = {
            defaults: {
                heightMin: 160,
                politicalEnabled: true,
                willEnabled: true,
                lowEdu: '小学',
                medFailKeyword: '不可治',
                highEduList: ['大专','大学','研究生'],
                validWillList: ['愿意','一般'], 
                midEduList: ['初中','高中','中专'],
                curableKeyword: '可治'
            },
            current: {},
            init() {
                const saved = localStorage.getItem('cms_logic_config');
                if (saved) {
                    try {
                        this.current = JSON.parse(saved);
                    } catch(e) { this.current = {...this.defaults}; }
                } else {
                    this.current = {...this.defaults};
                }
            },
            save() {
                localStorage.setItem('cms_logic_config', JSON.stringify(this.current));
            },
            getList(str) {
                return str.split(/[,,]/).map(s=>s.trim()).filter(s=>s);
            },
            updateFromUI() {
                this.current.heightMin = parseInt(document.getElementById('cfg-height-min').value);
                this.current.politicalEnabled = document.getElementById('cfg-political-check').checked;
                this.current.willEnabled = document.getElementById('cfg-will-check').checked;
                this.current.lowEdu = document.getElementById('cfg-low-edu').value.trim();
                this.current.medFailKeyword = document.getElementById('cfg-med-keyword').value.trim();
                this.current.highEduList = this.getList(document.getElementById('cfg-high-edu').value);
                this.current.validWillList = this.getList(document.getElementById('cfg-valid-will').value);
                this.current.midEduList = this.getList(document.getElementById('cfg-mid-edu').value);
                this.current.curableKeyword = document.getElementById('cfg-cur-keyword').value.trim();
                this.save();
            },
            renderToUI() {
                document.getElementById('cfg-height-min').value = this.current.heightMin;
                document.getElementById('cfg-political-check').checked = this.current.politicalEnabled;
                document.getElementById('cfg-will-check').checked = this.current.willEnabled;
                document.getElementById('cfg-low-edu').value = this.current.lowEdu;
                document.getElementById('cfg-med-keyword').value = this.current.medFailKeyword;
                document.getElementById('cfg-high-edu').value = this.current.highEduList.join(',');
                document.getElementById('cfg-valid-will').value = this.current.validWillList.join(',');
                document.getElementById('cfg-mid-edu').value = this.current.midEduList.join(',');
                document.getElementById('cfg-cur-keyword').value = this.current.curableKeyword;
            }
        };

        // --- 1. IndexedDB Database Layer ---
        class ConscriptionDB {
            constructor() {
                this.dbName = 'ConscriptionDB_Final_Ver11'; // 版本号升级以支持新字段
                this.storeName = 'persons';
                this.db = null;
            }

            async init() {
                return new Promise((resolve, reject) => {
                    const request = indexedDB.open(this.dbName, 3); // 升级版本号以触发 onupgradeneeded (如果尚未运行过)
                    request.onupgradeneeded = (e) => {
                        const db = e.target.result;

                        if (!db.objectStoreNames.contains(this.storeName)) {
                            // 首次创建数据库
                            const newStore = db.createObjectStore(this.storeName, { keyPath: 'id' });
                            newStore.createIndex('name', 'name', { unique: false });
                            newStore.createIndex('deleted', 'deleted', { unique: false });
                            // --- 新增开始:创建身份证号索引 ---
                            if (!newStore.indexNames.contains('idCardIdx')) {
                                newStore.createIndex('idCardIdx', 'idCard', { unique: false });
                                console.log("新建数据库:索引 'idCardIdx' 已创建");
                            }
                        } else {
                            // --- 数据库升级逻辑 ---
                            const tx = e.target.transaction;
                            const store = tx.objectStore(this.storeName);                            
                            if (!store.indexNames.contains('idCardIdx')) {
                                store.createIndex('idCardIdx', 'idCard', { unique: false });
                                console.log("升级数据库:索引 'idCardIdx' 已创建");
                            }
                        }
                    };
                    
                    request.onsuccess = (e) => {
                        this.db = e.target.result;
                        resolve(this);
                    };
                    request.onerror = (e) => reject(e);
                });
            }

            async addBatch(dataArray) {
                return new Promise((resolve, reject) => {
                    const tx = this.db.transaction(this.storeName, 'readwrite');
                    const store = tx.objectStore(this.storeName);
                    dataArray.forEach(item => store.put(item));
                    tx.oncomplete = () => resolve(dataArray.length);
                    tx.onerror = () => reject(tx.error);
                });
            }

            async getAll(includeDeleted = false) {
                return new Promise((resolve) => {
                    const tx = this.db.transaction(this.storeName, 'readonly');
                    const store = tx.objectStore(this.storeName);
                    store.getAll().onsuccess = (e) => {
                        let list = e.target.result;
                        if (!includeDeleted) list = list.filter(i => !i.deleted);
                        resolve(list);
                    };
                });
            }

            // --- 新增方法:根据身份证号快速检查是否存在 ---
            async checkIdCardExists(idCard) {
                return new Promise((resolve) => {
                    const tx = this.db.transaction(this.storeName, 'readonly');
                    const store = tx.objectStore(this.storeName);
                    const index = store.index('idCardIdx'); 
                    const request = index.get(idCard);
                    request.onsuccess = (e) => {
                        resolve(e.target.result); 
                    };
                });
            }

            async getPage(page, pageSize, filters = {}) {
                return new Promise((resolve) => {
                    const tx = this.db.transaction(this.storeName, 'readonly');
                    const store = tx.objectStore(this.storeName);
                    store.getAll().onsuccess = (e) => {
                        let list = e.target.result;
                        
                        // --- 修改开始:支持三种状态过滤 ---
                        if (filters.showErased) {
                            list = list.filter(i => i.erased === true);
                        } 
                        else if (filters.showDeleted) {
                            list = list.filter(i => i.deleted === true && i.erased !== true);
                        } 
                        else {
                            list = list.filter(i => i.deleted !== true && i.erased !== true);
                        }
                        // --- 修改结束 ---

                        if (filters.search) {
                            const s = filters.search.toLowerCase();
                            list = list.filter(i => i.name.includes(s) || i.idCard.includes(s));
                        }
                        
                        if (filters.category) {
                            list = list.filter(i => {
                                if (i.cachedCategory) return i.cachedCategory === filters.category;
                                return logic.getCategory(i) === filters.category;
                            });
                        }
                                
                        if (filters.village) list = list.filter(i => i.village === filters.village);
                        if (filters.edu) {
                            if (filters.edu === '大学及以上') list = list.filter(i => ['大学', '研究生'].includes(i.education));
                            else if (filters.edu === '高中/中专') list = list.filter(i => ['高中', '中专'].includes(i.education));
                            else list = list.filter(i => i.education === filters.edu);
                        }
                        if (filters.gradYear) list = list.filter(i => String(i.gradYear) === filters.gradYear);
                        if (filters.will) list = list.filter(i => i.willingness === filters.will);
                        if (filters.contact) {
                            if (filters.contact === 'YES') list = list.filter(i => i.lastContactDate);
                            if (filters.contact === 'NO') list = list.filter(i => !i.lastContactDate);
                        }
                        if (filters.missing) {
                            if (filters.missing === 'ALL_MISSING') {
                                list = list.filter(i => 
                                    (!i.phone || i.phone.trim() === '') ||   
                                    (!i.education || !i.gradYear) ||         
                                    (!i.village || i.village.trim() === '') || 
                                    (!i.householder || i.householder.trim() === '')
                                );
                            }
                            if (filters.missing === 'PHONE') list = list.filter(i => !i.phone || i.phone.trim() === '');
                            if (filters.missing === 'EDU') list = list.filter(i => !i.education || !i.gradYear);
                            if (filters.missing === 'VILLAGE') list = list.filter(i => !i.village || i.village.trim() === '');
                            if (filters.missing === 'BASIC') list = list.filter(i => !i.householder || i.householder.trim() === '');
                        }

                        const isPriority = (filters.category === 'GREEN' || filters.category === 'ORANGE' || filters.edu === '大学及以上');
                        if (isPriority) {
                            list.sort((a, b) => (b.gradYear || 0) - (a.gradYear || 0));
                        } else {
                            list.sort((a, b) => b.id - a.id);
                        }

                        const start = (page - 1) * pageSize;
                        const end = start + pageSize;
                        resolve({ data: list.slice(start, end), total: list.length });
                    };
                });
            }

            async put(item) {
                return new Promise((resolve, reject) => {
                    const tx = this.db.transaction(this.storeName, 'readwrite');
                    tx.objectStore(this.storeName).put(item);
                    tx.oncomplete = () => resolve();
                    tx.onerror = () => reject();
                });
            }

            async clearAll() {
                return new Promise((resolve) => {
                    const tx = this.db.transaction(this.storeName, 'readwrite');
                    tx.objectStore(this.storeName).clear();
                    tx.oncomplete = () => resolve();
                });
            }

            async getById(id) {
                return new Promise(resolve => {
                    const tx = this.db.transaction(this.storeName, 'readonly');
                    tx.objectStore(this.storeName).get(id).onsuccess = e => resolve(e.target.result);
                });
            }

            async delete(id) {
                return new Promise((resolve, reject) => {
                    const tx = this.db.transaction(this.storeName, 'readwrite');
                    tx.objectStore(this.storeName).delete(id);
                    tx.oncomplete = () => resolve();
                    tx.onerror = () => reject();
                });
            }

            async generateMockData(count) {
                const families = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨", given = "伟刚勇毅俊峰冰婷旺祥湖珮超伦杰玲雯玉伯修臻韵毓瑜宏豪", villages = ["幸福社区", "和平村", "建设路", "光明里"];
                const mockData = [];
                for (let i = 0; i < count; i++) {
                    const edu = ["初中","高中","大专","大学","研究生"][i%5];
                    // 新增:生成随机体重
                    const weight = (50 + Math.random() * 40).toFixed(1);
                    mockData.push({
                        id: Date.now() + i,
                        name: families[i%families.length] + given[i%given.length],
                        idCard: "11010120020101" + String(i).padStart(4,'0'),
                        householder: i%5===0 ? "" : "户主" + i, 
                        village: i%6===0 ? "" : villages[i%villages.length], 
                        phone: i%4===0 ? "" : "138" + String(Math.random()).substr(2,8),
                        familyPhone: "010-" + String(i).padStart(8,'0'),
                        education: i%3===0 ? "" : edu, 
                        gradYear: i%3===0 ? "" : 2020 + (i%8), 
                        willingness: ["愿意","一般","拒绝"][i%3],
                        height: 155 + (i%30),
                        weight: parseFloat(weight), // 保存体重
                        visionLeft: 4.5 + Math.random(), visionRight: 4.5 + Math.random(),
                        politicalCheck: i%10 !== 0,
                        isQualified: (i%10 !== 0) ? 'true' : 'false',
                        medicalInfo: i%10===0 ? "不可治" : "无",
                        contactLogs: [],
                        remarks: "测试数据",
                        modificationHistory: [{date: new Date().toISOString(), note: "初始导入"}],
                        createdAt: new Date().toISOString(),
                        updatedAt: new Date().toISOString(),
                        deleted: false,
                        erased: false, 
                        eliminationReason: null,
                        manualCategory: null
                    });
                }
                await this.addBatch(mockData);
                return count;
            }
        }

        // --- 2. Business Logic (Config Driven) ---
        const logic = {
            evaluateDetailed: (person) => {
                const cfg = ConfigManager.current;
                const reasons = [];
                
                // --- 新增:BMI 逻辑 ---
                // 放在身高之后,政考之前,属于身体条件检查
                if (person.height && person.weight) {
                    const heightM = person.height / 100;
                    const bmi = person.weight / (heightM * heightM);
                    if (bmi < 17.5 || bmi > 30) {
                        reasons.push(`BMI异常(${bmi.toFixed(1)})`);
                    }
                }

                // --- 年龄判定逻辑 ---
                const idCard = person.idCard;
                let birthYear = null;
                if (idCard && idCard.length === 18) {
                    birthYear = parseInt(idCard.substring(6, 10));
                }
                if (birthYear) {
                    const currentYear = new Date().getFullYear();
                    const age = currentYear - birthYear; 
                    const edu = person.education || '';

                    const groupA = ['小学', '初中', '高中', '中专', '大学在校生']; 
                    const groupB = ['大专', '大学']; 
                    const groupC = ['研究生']; 
                    let minAge = 18; 
                    let maxAge = 22; 

                    if (groupA.includes(edu)) {
                        maxAge = 22;
                    } else if (groupB.includes(edu)) {
                        maxAge = 24; 
                    } else if (groupC.includes(edu)) {
                        maxAge = 26; 
                    } else {
                    }

                    if (groupA.includes(edu) || groupB.includes(edu) || groupC.includes(edu)) {
                        if (age < minAge || age > maxAge) {
                            reasons.push(`超龄(${age}周岁, 限${minAge}-${maxAge}岁)`);
                            return { category: 'RED', reason: reasons.join('; ') };
                        }
                    }
                }
                
                if (cfg.politicalEnabled && !person.politicalCheck) reasons.push("政考不合格");
                const isRefusal = (person.willingness === '拒绝');
                if (cfg.willEnabled && isRefusal) reasons.push("参军意愿拒绝");
                if (person.height && person.height < cfg.heightMin) reasons.push(`身高 ${person.height}cm < ${cfg.heightMin}cm`);
                if (person.education === cfg.lowEdu) reasons.push(`学历为 ${cfg.lowEdu}`);
                
                if (person.isQualified !== 'true') {
                    const med = person.medicalInfo || "";
                    if (med.includes(cfg.medFailKeyword)) reasons.push(`体检不合格/存疑且备注包含"${cfg.medFailKeyword}"`);
                    if (cfg.midEduList.includes(person.education)) reasons.push(`体检不合格/存疑且学历为中低(${cfg.midEduList.join('/')})`);
                }
                
                if (reasons.length > 0) return { category: 'RED', reason: reasons.join('; ') };

                const isHighEdu = cfg.highEduList.includes(person.education);
                const isValidWill = cfg.validWillList.includes(person.willingness);
                
                if (isHighEdu && isValidWill && person.isQualified) return { category: 'GREEN', reason: '高学历+有效意愿+体检合格' };

                const isMidEdu = cfg.midEduList.includes(person.education);
                if (isMidEdu) {
                    if (person.isQualified === 'true') return { category: 'ORANGE', reason: '中等学历+体检合格' };
                    if (person.isQualified !== 'true') {
                        const med = person.medicalInfo || "";
                        if (med.includes(cfg.curableKeyword)) return { category: 'ORANGE', reason: `中等学历+体检可治(包含"${cfg.curableKeyword}")` };
                    }
                }

                return { category: 'YELLOW', reason: '默认(一般人选)' };
            },

            getCategory: (person) => {
                if (person.manualCategory) return person.manualCategory;
                return logic.evaluateDetailed(person).category;
            },

            getCategoryLabel: (code) => {
                const map = { 'RED': { label: '预淘汰', cls: 'bg-red' }, 'GREEN': { label: '意向', cls: 'bg-green' }, 'ORANGE': { label: '可争取', cls: 'bg-orange' }, 'YELLOW': { label: '一般', cls: 'bg-yellow' } };
                return map[code];
            }
        };

        // --- 3. UI Controller ---
        const app = {
            pageStates: { 
                list: 1, 
                physical: 1, 
                recycleDeleted: 1, 
                recycleErased: 1
            },
            currentPage: 1,
            pageSize: 20, 
            recyclePageSize: 10, 
            currentEditLogs: [],
            selectedIds: new Set(),
            selectedPhysicalIds: new Set(), 
            selectedRecycleDeletedIds: new Set(),
            selectedRecycleErasedIds: new Set(),
            colors: ['#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6', '#34495e', '#1abc9c', '#e67e22', '#95a5a6', '#7f8c8d'],
            debouncedRenderList: debounce(function() {
                app.renderList();
            }, 300), 

            init: async () => {
                ConfigManager.init();
                await db.init();
                await app.checkAndConvertGraduates(); 
                await app.renderVillageOptions();
                
                const savedView = localStorage.getItem('cms_last_view');
                const savedPageStates = localStorage.getItem('cms_page_states');
                
                let startView = 'dashboard'; 
                
                if (savedView && ['dashboard', 'list', 'physical', 'recycle', 'maintenance'].includes(savedView)) {
                    startView = savedView;
                }

                if (savedPageStates) {
                    try {
                        const parsed = JSON.parse(savedPageStates);
                        if (parsed.list) {
                            app.pageStates.list = parseInt(parsed.list);
                            app.currentPage = app.pageStates.list; 
                        }
                        if (parsed.physical) app.pageStates.physical = parseInt(parsed.physical);
                        if (parsed.recycleDeleted) app.pageStates.recycleDeleted = parseInt(parsed.recycleDeleted);
                        if (parsed.recycleErased) app.pageStates.recycleErased = parseInt(parsed.recycleErased);
                    } catch (e) {
                        console.error("解析页码状态失败", e);
                    }
                }
                
                const buttons = document.querySelectorAll('aside button');
                buttons.forEach(btn => {
                    if (btn.getAttribute('onclick').includes(`'${startView}'`)) {
                        btn.classList.add('active');
                    } else {
                        btn.classList.remove('active');
                    }
                });

                document.querySelectorAll('.content-area > div').forEach(d => d.classList.add('hidden'));
                const activeDiv = document.getElementById(`view-${startView}`);
                if (activeDiv) activeDiv.classList.remove('hidden');

                const titles = { 'dashboard': '数据总览', 'list': '人员明细查询','physical': '体检人员明细','recycle': '淘汰/删除人员', 'maintenance': '数据维护与备份' };
                document.getElementById('page-title').innerText = titles[startView];

                const recycleHeaderControls = document.getElementById('header-recycle-controls');
                if (startView === 'recycle') {
                    recycleHeaderControls.classList.remove('hidden'); 
                } else {
                    recycleHeaderControls.classList.add('hidden');    
                }
                
                if (startView === 'dashboard') app.renderDashboard();
                if (startView === 'list') app.renderList();
                if (startView === 'physical') app.renderPhysicalList(); 
                if (startView === 'recycle') app.renderRecycle();
                if (startView === 'maintenance') ConfigManager.renderToUI();
                
                document.addEventListener('click', (e) => {
                    const btn = e.target.closest('.page-btn');
                    if (btn) {
                        const p = parseInt(btn.dataset.page);
                        if (p) {
                            const container = btn.closest('.pagination-container');
                            let view = 'list';
                            if (container) {
                                if (container.id.includes('recycle-deleted')) {
                                    app.pageStates.recycleDeleted = p;
                                    view = 'recycle-deleted';
                                } else if (container.id.includes('recycle-erased')) {
                                    app.pageStates.recycleErased = p;
                                    view = 'recycle-erased';
                                } else {
                                    const activeBtn = document.querySelector('aside button.active');
                                    if(activeBtn) {
                                        const match = activeBtn.getAttribute('onclick').match(/'([^']+)'/);
                                        if(match) view = match[1];
                                    }
                                    app.pageStates.list = p;
                                    app.currentPage = p; 
                                }
                            }
                            if (view === 'list') {
                                localStorage.setItem('cms_last_view', 'list');
                                localStorage.setItem('cms_page_states', JSON.stringify(app.pageStates));
                                app.renderList();
                            }
                            if (view === 'physical') {
                                localStorage.setItem('cms_last_view', 'physical');
                                localStorage.setItem('cms_page_states', JSON.stringify(app.pageStates));
                                app.renderPhysicalList();
                            }
                            if (view === 'recycle-deleted' || view === 'recycle-erased') {
                                localStorage.setItem('cms_last_view', 'recycle');
                                localStorage.setItem('cms_page_states', JSON.stringify(app.pageStates));
                                app.renderRecycle();
                            }
                        }
                    }
                    
                    const actionBtn = e.target.closest('button[data-action]');
                    if (actionBtn) {
                        const action = actionBtn.dataset.action;
                        const id = parseFloat(actionBtn.dataset.id); 
                        
                        if (action === 'edit') app.editPerson(id);
                        if (action === 'eliminate') app.eliminatePerson(id);
                        if (action === 'restore') app.restorePerson(id);
                        if (action === 'wipe') app.permanentlyErasePerson(id); 
                    }
                                        
                });
            },


            changePageSize: () => {
                const select = document.getElementById('page-size-select');
                app.pageSize = parseInt(select.value);
                app.currentPage = 1; 
                app.renderList();
            },

            renderPagination: (total, currentPage, containerId, controlsId, infoId, pageSize) => {
                const currentPageSize = pageSize || app.pageSize;
                
                const totalPages = Math.ceil(total / currentPageSize);
                const container = document.getElementById(containerId);
                const controls = document.getElementById(controlsId);
                const info = document.getElementById(infoId);
                
                if (total === 0) {
                    container.classList.add('hidden');
                } else {
                    container.classList.remove('hidden');
                }
                if (totalPages <= 1) {
                    controls.classList.add('hidden');
                } else {
                    controls.classList.remove('hidden');
                }

                const start = total === 0 ? 0 : (currentPage - 1) * currentPageSize + 1;
                const end = Math.min(currentPage * currentPageSize, total);
                info.innerText = `显示 ${start} -${end} 条,共 ${total} 条`;

                let html = '';
                
                html += `<button class="page-btn" data-page="${currentPage - 1}" ${currentPage === 1 ? 'disabled' : ''}>上一页</button>`;

                let pagesToShow = [];
                if (totalPages <= 7) {
                    for(let i=1; i<=totalPages; i++) pagesToShow.push(i);
                } else {
                    if (currentPage <= 4) {
                        pagesToShow = [1, 2, 3, 4, 5, '...', totalPages];
                    } else if (currentPage >= totalPages - 3) {
                        pagesToShow = [1, '...', totalPages-4, totalPages-3, totalPages-2, totalPages-1, totalPages];
                    } else {
                        pagesToShow = [1, '...', currentPage-1, currentPage, currentPage+1, '...', totalPages];
                    }
                }

                pagesToShow.forEach(p => {
                    if (p === '...') {
                        html += `<span style="padding:4px;">...</span>`;
                    } else {
                        html += `<button class="page-btn ${p === currentPage ? 'active' : ''}" data-page="${p}">${p}</button>`;
                    }
                });

                html += `<button class="page-btn" data-page="${currentPage + 1}" ${currentPage === totalPages ? 'disabled' : ''}>下一页</button>`;

                controls.innerHTML = html;
            },

            checkAndConvertGraduates: async () => {
                const currentYear = new Date().getFullYear();
                const all = await db.getAll(false);
                const updates = [];
                
                all.forEach(p => {
                    if (p.education === '大学在校生' && p.gradYear && p.gradYear <= currentYear) {
                        let targetEdu = '大学';
                        const remarks = (p.remarks || '');
                        
                        if (remarks.includes('大专在校生')) targetEdu = '大专';
                        else if (remarks.includes('研究生在校生')) targetEdu = '研究生';
                        else targetEdu = '大学';

                        p.education = targetEdu;
                        const now = new Date().toISOString();
                        p.updatedAt = now;
                        p.modificationHistory = p.modificationHistory || [];
                        p.modificationHistory.push({
                            date: now,
                            note: `系统自动转换: 大学在校生 -> ${targetEdu} (毕业年份: ${p.gradYear})`
                        });
                        
                        const logMsg = `[${new Date().toLocaleDateString()}] 系统自动转换: 大学在校生 -> ${targetEdu}`;
                        p.remarks = remarks ? `${remarks}\n${logMsg}` : logMsg;
                        
                        updates.push(p);
                    }
                });

                if (updates.length > 0) {
                    for(let p of updates) {
                        await db.put(p);
                    }
                    console.log(`自动更新了 ${updates.length} 条在校生数据`);
                }
            },

            parseImportGrade: (rawEdu) => {
                const match = rawEdu.match(/^(大专|大学|研究生)(大一|大二|大三|大四|研一|研二|研三)$/);
                if (!match) return null;

                const type = match[1]; 
                const gradeStr = match[2]; 
                
                const gradeMap = { 
                    '大一': 1, '大二': 2, '大三': 3, '大四': 4,
                    '研一': 1, '研二': 2, '研三': 3 
                };
                const grade = gradeMap[gradeStr];

                if (!grade) return null; 

                let duration = 0;
                if (type === '大专') duration = 3;
                else if (type === '大学') duration = 4;
                else if (type === '研究生') duration = 3;

                const now = new Date();
                const year = now.getFullYear();
                const month = now.getMonth() + 1;

                let academicStartYear = year;
                if (month < 9) {
                    academicStartYear = year - 1;
                }

                const gradYear = academicStartYear + duration - (grade - 1);

                let displayEdu = '大学在校生'; 
                if (type === '大专') displayEdu = '大专在校生';
                else if (type === '研究生') displayEdu = '研究生在校生';

                return {
                    displayEdu: displayEdu,
                    targetEdu: type, 
                    gradYear: gradYear
                };
            },

            navigate: (view) => {
                document.querySelectorAll('aside button').forEach(b => b.classList.remove('active'));
                event.currentTarget.classList.add('active');
                document.querySelectorAll('.content-area > div').forEach(d => d.classList.add('hidden'));
                document.getElementById(`view-${view}`).classList.remove('hidden');
                const titles = { 'dashboard': '数据总览', 'list': '人员明细查询','physical': '体检人员明细', 'recycle': '淘汰/删除人员', 'maintenance': '数据维护与备份' };
                document.getElementById('page-title').innerText = titles[view];
                
                const recycleHeaderControls = document.getElementById('header-recycle-controls');
                if (view === 'recycle') {
                    recycleHeaderControls.classList.remove('hidden'); 
                } else {
                    recycleHeaderControls.classList.add('hidden'); 
                }
                
                localStorage.setItem('cms_last_view', view); 
                localStorage.setItem('cms_page_states', JSON.stringify(app.pageStates)); 
                
                app.currentPage = 1;
                if (view === 'list') app.renderList();
                if (view === 'physical') app.renderPhysicalList(); 
                if (view === 'recycle') app.renderRecycle();
                if (view === 'dashboard') app.renderDashboard();
                if (view === 'maintenance') ConfigManager.renderToUI();
            },

            renderDashboard: async () => {
                const all = await db.getAll(false);
                const total = all.length;
                document.getElementById('count-total').innerText = total;
                const counts = { RED: 0, GREEN: 0, ORANGE: 0, YELLOW: 0 };
                all.forEach(p => counts[logic.getCategory(p)]++);
                document.getElementById('count-green').innerText = counts.GREEN;
                document.getElementById('count-orange').innerText = counts.ORANGE;
                document.getElementById('count-yellow').innerText = counts.YELLOW;
                document.getElementById('count-red').innerText = counts.RED;

                if (total === 0) return;
                const processChartData = (mapFunc) => {
                    const stats = {};
                    all.forEach(p => {
                        const key = mapFunc(p);
                        stats[key] = (stats[key] || 0) + 1;
                    });
                    const sorted = Object.entries(stats).sort((a,b) => b[1] - a[1]);
                    let top5 = sorted;
                    if (sorted.length > 5) {
                        top5 = sorted.slice(0, 5);
                        const otherCount = sorted.slice(5).reduce((sum, item) => sum + item[1], 0);
                        top5.push(['其他', otherCount]);
                    }
                    return top5;
                };
                app.renderPieChart('chart-village', processChartData(p => p.village || '未填'), total);
                app.renderPieChart('chart-edu', processChartData(p => p.education || '未填'), total);
                app.renderPieChart('chart-will', processChartData(p => p.willingness), total);
                const commStats = { '已沟通': 0, '未沟通': 0 };
                all.forEach(p => commStats[p.lastContactDate ? '已沟通' : '未沟通']++);
                app.renderPieChart('chart-contact', Object.entries(commStats).sort((a,b) => b[1] - a[1]), total);
                const missingStats = {
                    '缺联系电话': all.filter(p => !p.phone).length,
                    '缺村社信息': all.filter(p => !p.village).length,
                    '缺学历/年份': all.filter(p => !p.education || !p.gradYear).length,
                    '缺户主信息': all.filter(p => !p.householder).length
                };
                app.renderBarChart('chart-missing', missingStats);
            },

            renderPieChart: (containerId, data, total) => {
                const container = document.getElementById(containerId);
                if (!container) return;
                const size = 32;
                const center = size / 2;
                const radius = 16;
                let cumulativePercent = 0;
                let pathsHtml = '';
                let legendHtml = '<div class="chart-legend">';
                data.forEach((item, index) => {
                    const label = item[0];
                    const value = item[1];
                    const percent = value / total;
                    const color = app.colors[index % app.colors.length];
                    const startX = center + radius * Math.cos(2 * Math.PI * cumulativePercent);
                    const startY = center + radius * Math.sin(2 * Math.PI * cumulativePercent);
                    cumulativePercent += percent;
                    const endX = center + radius * Math.cos(2 * Math.PI * cumulativePercent);
                    const endY = center + radius * Math.sin(2 * Math.PI * cumulativePercent);
                    const largeArcFlag = percent > 0.5 ? 1 : 0;
                    const pathData = [`M ${center} ${center}`, `L ${startX} ${startY}`, `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`, `Z`].join(' ');
                    pathsHtml += `<path d="${pathData}" fill="${color}" stroke="white" stroke-width="0.5" class="pie-slice"><title>${label}: ${value} (${(percent*100).toFixed(1)}%)</title></path>`;
                    const pctStr = (percent * 100).toFixed(1) + '%';
                    legendHtml += `<div class="legend-item"><div class="legend-left"><div class="legend-color" style="background-color: ${color}"></div><div class="legend-label" title="${label}">${label}</div></div><div class="legend-values">${value} (${pctStr})</div></div>`;
                });
                legendHtml += '</div>';
                container.innerHTML = `<svg class="pie-chart" viewBox="0 0 ${size} ${size}">${pathsHtml}</svg>${legendHtml}`;
            },

            renderBarChart: (containerId, dataObj) => {
                const container = document.getElementById(containerId);
                if (!container) return;
                const entries = Object.entries(dataObj).sort((a, b) => b[1] - a[1]);
                if (entries.length === 0) { container.innerHTML = '<div style="color:#999; width:100%; text-align:center;">暂无明显缺项</div>'; return; }
                const maxVal = Math.max(...entries.map(e => e[1]));
                let html = '<div class="bar-chart-wrapper">';
                entries.forEach(([label, count]) => {
                    const pct = maxVal > 0 ? (count / maxVal) * 100 : 0;
                    html += `<div class="bar-item"><div class="bar-label" title="${label}">${label}</div><div class="bar-track"><div class="bar-fill" style="width: ${pct}%"></div></div><div class="bar-value">${count}</div></div>`;
                });
                html += '</div>';
                container.innerHTML = html;
            },

            renderVillageOptions: async () => {
                const all = await db.getAll(false);
                const villages = new Set();
                all.forEach(p => { if (p.village) villages.add(p.village); });
                const sortedVillages = Array.from(villages).sort();
                const select = document.getElementById('list-village');
                const currentVal = select.value;
                select.innerHTML = '<option value="">所有村社</option>';
                sortedVillages.forEach(v => { select.innerHTML += `<option value="${v}">${v}</option>`; });
                select.value = currentVal;
            },

            quickFilter: (category) => {
                document.getElementById('list-category').value = category;
                app.renderList();
            },

            toggleSelectAll: () => {
                const checked = document.getElementById('select-all').checked;
                const checkboxes = document.querySelectorAll('.row-select');
                
                if (checked) {
                    checkboxes.forEach(cb => app.selectedIds.add(parseFloat(cb.value)));
                } else {
                    checkboxes.forEach(cb => app.selectedIds.delete(parseFloat(cb.value)));
                }
                
                checkboxes.forEach(cb => cb.checked = checked);
                app.updateBatchSelection(); 
            },

            updateBatchSelection: () => {
                const checkboxes = document.querySelectorAll('.row-select');
                checkboxes.forEach(cb => {
                    const id = parseFloat(cb.value); 
                    if (cb.checked) {
                        app.selectedIds.add(id); 
                     } else {
                        app.selectedIds.delete(id); 
                     }
                 });
                
                document.querySelectorAll('.row-select:checked').forEach(cb => app.selectedIds.add(parseFloat(cb.value)));
                const bar = document.getElementById('batch-bar');
                const count = app.selectedIds.size;
                document.getElementById('batch-count-text').innerText = `已选 ${count} 人`;
                if (count > 0) bar.classList.add('show'); else bar.classList.remove('show');
            },

            clearBatchSelection: () => {
                document.querySelectorAll('.row-select').forEach(cb => cb.checked = false);
                document.getElementById('select-all').checked = false;
                app.updateBatchSelection();
            },

            batchEliminate: async () => {
                if (!confirm(`确定要淘汰选中的 ${app.selectedIds.size} 人吗?\n淘汰原因将统一记录为“批量淘汰”。`)) return;
                const all = await db.getAll(false);
                const targets = all.filter(p => app.selectedIds.has(p.id));
                const now = new Date().toISOString();
                let count = 0;
                for(let p of targets) {
                    p.deleted = true;
                    p.eliminationReason = "批量淘汰";
                    p.deletedAt = now;
                    p.modificationHistory.push({ date: now, note: "批量淘汰" });
                    p.updatedAt = now;
                    await db.put(p);
                    count++;
                }
                app.clearBatchSelection();
                app.showToast(`已淘汰 ${count} 人`);
                app.renderList();
                app.renderDashboard(); 
            },

            batchErase: async () => {
                if (!confirm(`确定要彻底删除选中的 ${app.selectedIds.size} 人吗?\n删除后可在“已删除人员列表”中恢复。`)) return;
                const all = await db.getAll(false); 
                const fullList = await db.getAll(true); 
                const targets = fullList.filter(p => app.selectedIds.has(p.id));
                const now = new Date().toISOString();
                let count = 0;
                for(let p of targets) {
                    p.erased = true; 
                    p.erasedReason = "批量删除";
                    p.erasedAt = now;
                    p.modificationHistory = p.modificationHistory || [];
                    p.modificationHistory.push({ date: now, note: "批量彻底删除" });
                    p.updatedAt = now;
                    await db.put(p);
                    count++;
                }
                app.clearBatchSelection();
                app.showToast(`已删除 ${count} 人`);
                app.renderList();
                app.renderDashboard(); 
            },

            renderList: async () => {
                const search = document.getElementById('list-search').value;
                const cat = document.getElementById('list-category').value;
                const village = document.getElementById('list-village').value;
                const edu = document.getElementById('list-edu').value;
                const gradYear = document.getElementById('list-grad-year').value;
                const will = document.getElementById('list-will').value;
                const contact = document.getElementById('list-contact').value;
                const missing = document.getElementById('list-missing').value;
                
                const res = await db.getPage(app.currentPage, app.pageSize, { 
                    search: search, category: cat === "" ? null : cat, village: village === "" ? null : village,
                    edu: edu === "" ? null : edu, gradYear: gradYear === "" ? null : gradYear,
                    will: will === "" ? null : will, contact: contact === "" ? null : contact,
                    missing: missing === "" ? null : missing 
                });
                
                const maxPage = Math.ceil(res.total / app.pageSize) || 1;
                if (app.currentPage > maxPage && res.total > 0) {
                    app.currentPage = 1;
                    app.renderList();
                    return;
                }
                    
                const tbody = document.getElementById('list-tbody');
                tbody.innerHTML = '';
                
                document.getElementById('select-all').checked = false;

                const fragment = document.createDocumentFragment();
                
                res.data.forEach(p => {
                    const cat = logic.getCategory(p);
                    const badge = logic.getCategoryLabel(cat);
                    const created = app.formatDate(p.createdAt);
                    const updated = app.formatDate(p.updatedAt);
                    const contactStatus = p.lastContactDate ? `<span style="color:green;font-size:11px;">${p.lastContactDate}</span>` : `<span style="color:#ccc;font-size:11px;">-</span>`;
                    const isSelected = app.selectedIds.has(p.id);
                    const isMissing = (!p.phone || !p.education || !p.village); 
                    const manualIndicator = p.manualCategory ? `<span title="手动设置" style="color:#f39c12">★</span>` : '';
                    
                    let eduDisplay = p.education || '';
                    let star = '';
                    
                    if (p.education !== '大学在校生' && p.modificationHistory) {
                        const isConverted = p.modificationHistory.some(h => h.note && h.note.includes('系统自动转换'));
                        if (isConverted) {
                            star = '★';
                        }
                    }
                    eduDisplay += star;

                    const tr = document.createElement('tr');
                    if (isMissing) tr.style.background = '#fff5f5';
                    
                    tr.innerHTML = `
                            <td class="col-chk"><input type="checkbox" class="row-select" value="${p.id}" ${isSelected ? 'checked' : ''}></td>
                            <td class="col-status"><span class="badge ${badge.cls}">${badge.label}</span>${manualIndicator}</td>
                            <td class="col-name"><b>${escapeHtml(p.name)}</b></td>
                            <td class="col-id">${escapeHtml(p.idCard)}</td>
                            <td class="col-house">${escapeHtml(p.householder)  || '<span style="color:red">?</span>'}</td>
                            <td class="col-vill">${escapeHtml(p.village) || '<span style="color:red">?</span>'}</td>
                            <td class="col-phone">${escapeHtml(p.phone) || '<span style="color:red">?</span>'}</td>
                            <td class="col-phone">${escapeHtml(p.familyPhone) || '<span style="color:red">?</span>'}</td>
                            <td class="col-edu">${escapeHtml(eduDisplay) || '<span style="color:red">?</span>'}</td>
                            <td class="col-year">${p.gradYear || '<span style="color:red">?</span>'}</td>
                            <td class="col-will">${escapeHtml(p.willingness)}</td>
                            <td class="col-contact">${contactStatus}</td>
                            <td style="font-size:11px;color:#999;">${created}</td>
                            <td style="font-size:11px;color:#999;">${updated}</td>
                            <td class="col-action">
                                <button class="btn btn-blue btn-sm" data-action="edit" data-id="${p.id}">编辑</button>
                                <button class="btn btn-red btn-sm" data-action="eliminate" data-id="${p.id}">淘汰</button>
                            </td>
                    `;
                    fragment.appendChild(tr);
                });
                
                tbody.innerHTML = ''; 
                tbody.appendChild(fragment);

                app.renderPagination(res.total, app.currentPage, 'list-pagination-container', 'list-pagination-controls', 'list-pagination-info');
            },

            renderPhysicalList: async () => {
                const search = document.getElementById('physical-search').value;
                
                const all = await db.getAll(false);
                let list = all.filter(i => i.inPhysicalList === true);

                if (search) {
                    const s = search.toLowerCase();
                    list = list.filter(i => i.name.includes(s) || i.idCard.includes(s));
                }

                const pageSize = 20;
                const currentPage = app.pageStates.physical;
                const totalPages = Math.ceil(list.length / pageSize);
                const maxPage = totalPages || 1;
                
                if (currentPage > maxPage && list.length > 0) {
                    app.pageStates.physical = 1;
                    app.renderPhysicalList();
                    return;
                }

                const start = (currentPage - 1) * pageSize;
                const end = start + pageSize;
                const dataToShow = list.slice(start, end);

                const tbody = document.getElementById('physical-tbody');
                tbody.innerHTML = '';
                document.getElementById('select-all-physical').checked = false;

                const fragment = document.createDocumentFragment();
                
                dataToShow.forEach(p => {
                    const cat = logic.getCategory(p);
                    const badge = logic.getCategoryLabel(cat);
                    const isSelected = app.selectedPhysicalIds.has(p.id);

                    const tr = document.createElement('tr');
                    tr.innerHTML = `
                        <td class="col-chk"><input type="checkbox" class="row-select-physical" value="${p.id}" ${isSelected ? 'checked' : ''}></td>
                        <td class="col-status"><span class="badge ${badge.cls}">${badge.label}</span></td>
                        <td class="col-name"><b>${escapeHtml(p.name)}</b></td>
                        <td class="col-id">${escapeHtml(p.idCard)}</td>
                        <td class="col-house">${escapeHtml(p.householder)  || '<span style="color:red">?</span>'}</td>
                        <td class="col-vill">${escapeHtml(p.village) || '<span style="color:red">?</span>'}</td>
                        <td class="col-phone">${escapeHtml(p.phone) || '<span style="color:red">?</span>'}</td>
                        <td class="col-phone">${escapeHtml(p.familyPhone) || '<span style="color:red">?</span>'}</td>
                        <td class="col-edu">${escapeHtml(p.education)}</td>
                        <td class="col-year">${p.gradYear || '<span style="color:red">?</span>'}</td>
                        <td class="col-year" style="color:var(--accent-color);font-weight:bold;">${p.physicalYear || '<span style="color:#999;font-weight:normal">未填年份</span>'}</td>
                        <td class="col-action">
                            <button class="btn btn-blue btn-sm" data-action="edit" data-id="${p.id}">详情</button>
                            <button class="btn btn-red btn-sm">移除</button>
                        </td>
                    `;
                    fragment.appendChild(tr);
                });
                
                tbody.innerHTML = '';
                tbody.appendChild(fragment);

                app.renderPagination(list.length, app.pageStates.physical, 'physical-pagination-container', 'physical-pagination-controls', 'physical-pagination-info', pageSize);
                
                const batchBar = document.getElementById('batch-bar-physical');
                const count = app.selectedPhysicalIds.size;
                document.getElementById('batch-physical-count-text').innerText = `已选 ${count} 人`;
                batchBar.style.display = count > 0 ? 'flex' : 'none';
            },

            changePhysicalPageSize: () => {
                app.pageStates.physical = 1;
                app.renderPhysicalList();
            },

            toggleSelectAllPhysical: () => {
                const checked = document.getElementById('select-all-physical').checked;
                const checkboxes = document.querySelectorAll('.row-select-physical');
                if (checked) {
                    checkboxes.forEach(cb => app.selectedPhysicalIds.add(parseFloat(cb.value)));
                } else {
                    checkboxes.forEach(cb => app.selectedPhysicalIds.delete(parseFloat(cb.value)));
                }
                checkboxes.forEach(cb => cb.checked = checked);
                app.updatePhysicalSelection();
            },

            updatePhysicalSelection: () => {
                const checkboxes = document.querySelectorAll('.row-select-physical');
                checkboxes.forEach(cb => {
                    const id = parseFloat(cb.value);
                    if (cb.checked) app.selectedPhysicalIds.add(id);
                    else app.selectedPhysicalIds.delete(id);
                });
                
                const count = app.selectedPhysicalIds.size;
                document.getElementById('batch-physical-count-text').innerText = `已选 ${count} 人`;
                document.getElementById('batch-bar-physical').style.display = count > 0 ? 'flex' : 'none';
            },

            clearPhysicalSelection: () => {
                document.querySelectorAll('.row-select-physical').forEach(cb => cb.checked = false);
                document.getElementById('select-all-physical').checked = false;
                app.selectedPhysicalIds.clear();
                app.updatePhysicalSelection();
            },

            removeFromPhysical: async (id) => {
                if (!confirm("确定要将此人从体检名单中移除吗?(不会删除基本信息)")) return;
                const p = await db.getById(id);
                if (p) {
                    p.inPhysicalList = false; 
                    p.updatedAt = new Date().toISOString();
                    await db.put(p);
                    app.renderPhysicalList();
                }
            },

            batchRemovePhysical: async () => {
                const count = app.selectedPhysicalIds.size;
                if (count === 0) return;
                if (!confirm(`确定要移除选中的 ${count} 人吗?`)) return;

                const all = await db.getAll(true);
                const targets = all.filter(p => app.selectedPhysicalIds.has(p.id));
                const now = new Date().toISOString();

                for (let p of targets) {
                    p.inPhysicalList = false; 
                    p.updatedAt = now;
                    await db.put(p);
                }
                
                app.clearPhysicalSelection();
                app.showToast(`已移除 ${count} 人`);
                app.renderPhysicalList();
            },

            batchAddToPhysical: async () => {
                const count = app.selectedIds.size;
                if (count === 0) return;
                
                const yearInput = prompt(`请输入体检年份(例如:2024秋、2025春):\n选中人数:${count} 人`, "2026春");
                
                const all = await db.getAll(true);
                const targets = all.filter(p => app.selectedIds.has(p.id));
                const now = new Date().toISOString();
                let successCount = 0;

                for (let p of targets) {
                    p.inPhysicalList = true;
                    p.physicalYear = yearInput ? yearInput.trim() : ''; 
                    
                    p.updatedAt = now;
                    await db.put(p);
                    successCount++;
                }

                app.clearBatchSelection();
                app.showToast(`已成功将 ${successCount} 人增加到体检名单`);
                app.renderList(); 
            },
            
            debouncedRenderPhysical: debounce(function() {
                app.renderPhysicalList();
            }, 300),

            renderRecycle: async () => {
                const allDeleted = (await db.getAll(true)).filter(i => i.deleted === true && i.erased !== true);
                    document.getElementById('count-recycle-deleted').innerText = allDeleted.length;

                const resDeleted = await db.getPage(app.pageStates.recycleDeleted, app.recyclePageSize, { showDeleted: true });
                    const maxDeletedPage = Math.ceil(resDeleted.total / app.recyclePageSize) || 1;
                    if (app.pageStates.recycleDeleted > maxDeletedPage && resDeleted.total > 0) {
                        app.pageStates.recycleDeleted = 1;
                        app.renderRecycle(); 
                        return;
                    }
                
                const tbodyDel = document.getElementById('recycle-deleted-tbody');
                tbodyDel.innerHTML = '';

                const currentDeletedIds = resDeleted.data.map(p => p.id);
                const allDeletedSelected = currentDeletedIds.length > 0 && currentDeletedIds.every(id => app.selectedRecycleDeletedIds.has(id));
                document.getElementById('select-all-recycle-deleted').checked = allDeletedSelected;
                
                const htmlDeleted = [];
                
                resDeleted.data.forEach(p => {
                    const badge = logic.getCategoryLabel(logic.getCategory({...p, deleted: false, erased: false}));
                    const isSelected = app.selectedRecycleDeletedIds.has(p.id);
                    htmlDeleted.push(`
                        <tr>
                            <td class="col-chk">
                                <input type="checkbox" class="row-select-recycle" data-type="deleted" value="${p.id}" ${isSelected ? 'checked' : ''}>
                            </td>
                            <td>${escapeHtml(p.name)}</td>
                            <td>${escapeHtml(p.idCard)}</td>
                            <td><span class="badge ${badge.cls}">${badge.label}</span></td>
                            <td style="color:#e67e22;font-weight:bold;">${p.eliminationReason || '-'}</td>
                            <td>${app.formatDate(p.deletedAt || p.updatedAt)}</td>
                            <td>
                                <button class="btn btn-blue btn-sm" data-action="edit" data-id="${p.id}">详情</button>
                                <button class="btn btn-green btn-sm" data-action="eliminate" data-id="${p.id}">恢复</button>
                            </td>
                        </tr>
                    `);
                });
                tbodyDel.innerHTML = htmlDeleted.join('');
                
                app.renderPagination(resDeleted.total, app.pageStates.recycleDeleted, 'recycle-deleted-pagination-container', 'recycle-deleted-pagination-controls', 'recycle-deleted-pagination-info', app.recyclePageSize);

                const allErased = (await db.getAll(true)).filter(i => i.erased === true);
                document.getElementById('count-recycle-erased').innerText = allErased.length;

                const resErased = await db.getPage(app.pageStates.recycleErased, app.recyclePageSize, { showErased: true });
                
                    const maxErasedPage = Math.ceil(resErased.total / app.recyclePageSize) || 1;
                    if (app.pageStates.recycleErased > maxErasedPage && resErased.total > 0) {
                        app.pageStates.recycleErased = 1;
                        app.renderRecycle(); 
                        return;
                    }
                
                const tbodyEra = document.getElementById('recycle-erased-tbody');
                tbodyEra.innerHTML = '';

                const currentErasedIds = resErased.data.map(p => p.id);
                const allErasedSelected = currentErasedIds.length > 0 && currentErasedIds.every(id => app.selectedRecycleErasedIds.has(id));
                document.getElementById('select-all-recycle-erased').checked = allErasedSelected;

                const htmlErased = [];
                
                resErased.data.forEach(p => {
                    const isSelected = app.selectedRecycleErasedIds.has(p.id);
                    htmlErased.push(`
                        <tr>
                            <td class="col-chk">
                                <input type="checkbox" class="row-select-recycle" data-type="erased" value="${p.id}" ${isSelected ? 'checked' : ''}>
                            </td>
                            <td>${escapeHtml(p.name)}</td>
                            <td>${escapeHtml(p.idCard)}</td>
                            <td style="color:#c0392b;font-weight:bold;">${p.erasedReason || '-'}</td>
                            <td>${app.formatDate(p.erasedAt || p.updatedAt)}</td>
                            <td>
                                <button class="btn btn-blue btn-sm" data-action="edit" data-id="${p.id}">详情</button>
                                <button class="btn btn-green btn-sm" data-action="restore" data-id="${p.id}">恢复</button>
                                <button class="btn btn-red btn-sm" data-action="wipe" data-id="${p.id}">彻底删除</button>
                            </td>
                        </tr>
                    `);
                });
                tbodyEra.innerHTML = htmlErased.join('');
                
                app.renderPagination(resErased.total, app.pageStates.recycleErased, 'recycle-erased-pagination-container', 'recycle-erased-pagination-controls', 'recycle-erased-pagination-info', app.recyclePageSize);

                if (app.selectedRecycleDeletedIds.size > 0) {
                    document.getElementById('btn-batch-restore-deleted').classList.add('show');
                    document.getElementById('btn-batch-erase-deleted').classList.add('show');
                } else {
                    document.getElementById('btn-batch-restore-deleted').classList.remove('show');
                    document.getElementById('btn-batch-erase-deleted').classList.remove('show');
                }
                if (app.selectedRecycleErasedIds.size > 0) document.getElementById('btn-batch-restore-erased').classList.add('show');
                else document.getElementById('btn-batch-restore-erased').classList.remove('show');

                const delSelSpan = document.getElementById('count-recycle-deleted-selected');
                const delCanBtn = document.getElementById('btn-cancel-select-deleted');
                if (app.selectedRecycleDeletedIds.size > 0) {
                    delSelSpan.style.display = 'inline';
                    delSelSpan.innerText = `已选 ${app.selectedRecycleDeletedIds.size} 人`;
                    delCanBtn.style.display = 'inline-block';
                } else {
                    delSelSpan.style.display = 'none';
                    delCanBtn.style.display = 'none';
                }
                    
                const eraSelSpan = document.getElementById('count-recycle-erased-selected');
                const eraCanBtn = document.getElementById('btn-cancel-select-erased');
                if (app.selectedRecycleErasedIds.size > 0) {
                    eraSelSpan.style.display = 'inline';
                    eraSelSpan.innerText = `已选 ${app.selectedRecycleErasedIds.size} 人`;
                    eraCanBtn.style.display = 'inline-block';
                } else {
                    eraSelSpan.style.display = 'none';
                    eraCanBtn.style.display = 'none';
                }               
            },    

            changeRecyclePageSize: () => {
                const select = document.getElementById('recycle-page-size-select');
                app.recyclePageSize = parseInt(select.value);
                app.pageStates.recycleDeleted = 1;
                app.pageStates.recycleErased = 1;
                app.renderRecycle();
            },

            updateRecycleSelection: () => {
                const checkboxes = document.querySelectorAll('.row-select-recycle');
                
                checkboxes.forEach(cb => {
                	const id = parseFloat(cb.value); 
                	const type = cb.dataset.type;
                	
                	if (type === 'deleted') {
                		if (cb.checked) {
                			app.selectedRecycleDeletedIds.add(id); 
                		} else {
                			app.selectedRecycleDeletedIds.delete(id); 
                		}
                	} else if (type === 'erased') {
                		if (cb.checked) {
                			app.selectedRecycleErasedIds.add(id);
                		} else {
                			app.selectedRecycleErasedIds.delete(id);
                		}
                	}
                });

                app.renderRecycle();
            },

            toggleSelectAllRecycle: (type) => {
                const checkboxId = type === 'deleted' ? 'select-all-recycle-deleted' : 'select-all-recycle-erased';
                const isChecked = document.getElementById(checkboxId).checked;

                const selector = `.row-select-recycle[data-type="${type}"]`;
                document.querySelectorAll(selector).forEach(cb => {
                    cb.checked = isChecked;
                });

                app.updateRecycleSelection();
            },

            restoreRecycleSelected: async (type) => {
                const ids = type === 'deleted' ? app.selectedRecycleDeletedIds : app.selectedRecycleErasedIds;
                const count = ids.size;

                if (count === 0) return;
                if (!confirm(`确定要恢复选中的 ${count} 人吗?`)) return;

                const all = await db.getAll(true);
                const targets = all.filter(p => ids.has(p.id));
                const now = new Date().toISOString();

                let successCount = 0;
                for (let p of targets) {
                    let note = "";
                    
                    if (p.erased) {
                        p.erased = false; p.erasedReason = null; p.erasedAt = null;
                        p.deleted = true; 
                        note = "批量恢复:从已删除列表恢复(回退至淘汰状态)";
                    } else if (p.deleted) {
                        p.deleted = false; p.eliminationReason = null; p.deletedAt = null;
                        note = "批量恢复:从淘汰列表恢复(回退至正常状态)";
                    }

                    p.updatedAt = now;
                    p.modificationHistory = p.modificationHistory || [];
                    p.modificationHistory.push({ date: now, note: note });
                    
                    await db.put(p);
                    successCount++;
                }

                if (type === 'deleted') app.selectedRecycleDeletedIds.clear();
                else app.selectedRecycleErasedIds.clear();

                app.showToast(`已恢复 ${successCount} 人`);
                app.renderDashboard(); 
                app.renderRecycle(); 
            },

            batchEraseDeleted: async () => {
                const ids = app.selectedRecycleDeletedIds;
                const count = ids.size;

                if (count === 0) return;
                if (!confirm(`确定要将选中的 ${count} 人从“淘汰人员”移至“已删除人员”吗?\n注意:这是彻底删除操作。`)) return;

                const all = await db.getAll(true);
                const targets = all.filter(p => ids.has(p.id));
                const now = new Date().toISOString();

                let successCount = 0;
                for (let p of targets) {
                    p.erased = true; 
                    p.erasedReason = "批量删除";
                    p.erasedAt = now;
                    
                    p.updatedAt = now;
                    p.modificationHistory = p.modificationHistory || [];
                    p.modificationHistory.push({ date: now, note: "批量删除:从淘汰列表移至已删除列表" });
                    
                    await db.put(p);
                    successCount++;
                }

                app.selectedRecycleDeletedIds.clear();

                app.showToast(`已删除 ${successCount} 人`);
                app.renderDashboard(); 
                app.renderRecycle(); 
            },

            clearRecycleSelection: (type) => {
                if (type === 'deleted') {
                    app.selectedRecycleDeletedIds.clear();
                } else if (type === 'erased') {
                    app.selectedRecycleErasedIds.clear();
                }
                app.renderRecycle();
            },
                        
            handleEduChange: () => {
                const val = document.getElementById('edit-edu').value;
                const targetGroup = document.getElementById('target-edu-group');
                const remarks = document.getElementById('edit-remarks');
                
                if (val === '大学在校生') {
                    targetGroup.classList.remove('hidden');
                    if (!remarks.value.includes('大专在校生') && !remarks.value.includes('研究生在校生')) {
                        document.getElementById('edit-target-edu').value = '大学';
                    }
                } else {
                    targetGroup.classList.add('hidden');
                }
            },

            // --- 新增:BMI 实时计算 ---
            updateBMIDisplay: () => {
                const h = parseFloat(document.getElementById('edit-height').value);
                const w = parseFloat(document.getElementById('edit-weight').value);
                const display = document.getElementById('bmi-display');
                
                if (!h || !w || isNaN(h) || isNaN(w) || h <= 0 || w <= 0) {
                    display.innerText = '';
                    return;
                }
                
                const bmi = w / ((h / 100) * (h / 100));
                let bmiStr = bmi.toFixed(1);
                if (bmiStr.endsWith('.0')) {
                    bmiStr = bmiStr.slice(0, -2);
                }
                display.innerText = `(BMI: ${bmiStr})`;
            },

            openAddModal: () => {
                document.getElementById('edit-id').value = '';
                document.getElementById('edit-category-override').value = '';
                document.getElementById('logic-reason-text').innerText = '新建人员暂无判定';
                ['edit-name','edit-idcard','edit-householder','edit-village','edit-phone','edit-family-phone','edit-address','edit-physical-year-text','edit-height','edit-weight','edit-vision','edit-medical-info','edit-remarks','edit-grad-year'].forEach(id=>document.getElementById(id).value='');
                document.getElementById('edit-edu').value = '高中';
                document.getElementById('edit-will').value = '愿意'; 
                document.getElementById('edit-address').value = '';
                document.getElementById('edit-physical-year-text').value = '';
                document.getElementById('edit-in-physical-list').checked = false;
                
                document.getElementById('edit-political').value = 'true';
                document.getElementById('edit-qualified').value = 'true'; 
                document.getElementById('view-created-at').value = '新建时自动生成';
                document.getElementById('view-updated-at').value = '';
                document.getElementById('view-history').value = '';
                app.currentEditLogs = [];
                app.renderContactLogsUI();
                document.getElementById('target-edu-group').classList.add('hidden'); 
                document.getElementById('edit-idcard').readOnly = false; 
                
                // 清空 BMI 显示
                document.getElementById('bmi-display').innerText = '';
                
                document.getElementById('modal-edit').style.display = 'flex';
            },

            editPerson: async (id) => {
                const p = await db.getById(parseFloat(id));
                if(!p) return;
                document.getElementById('edit-id').value = p.id;
                document.getElementById('edit-id-display').innerText = 'ID: ' + p.id;  
                document.getElementById('edit-deleted').value = p.deleted ? 'true' : 'false';
                document.getElementById('edit-name').value = p.name;
                document.getElementById('edit-idcard').value = p.idCard;
                document.getElementById('edit-householder').value = p.householder || '';
                document.getElementById('edit-village').value = p.village || '';
                document.getElementById('edit-phone').value = p.phone || '';
                document.getElementById('edit-family-phone').value = p.familyPhone || '';
                document.getElementById('edit-address').value = p.address || '';                 
                document.getElementById('edit-edu').value = p.education;
                document.getElementById('edit-grad-year').value = p.gradYear || '';
                document.getElementById('edit-will').value = p.willingness;                
                document.getElementById('edit-in-physical-list').checked = !!p.inPhysicalList;
                document.getElementById('edit-physical-year-text').value = p.physicalYear || '';
                
                document.getElementById('edit-height').value = p.height;
                document.getElementById('edit-weight').value = p.weight || ''; // 填充体重
                app.updateBMIDisplay(); // 触发 BMI 计算
                
                document.getElementById('edit-vision').value = `${(p.visionLeft || 0).toFixed(1)}/${(p.visionRight || 0).toFixed(1)}`;
                document.getElementById('edit-political').value = p.politicalCheck.toString();
                document.getElementById('edit-qualified').value = p.isQualified; 
                document.getElementById('edit-medical-info').value = p.medicalInfo || '';
                document.getElementById('edit-remarks').value = p.remarks || '';
                document.getElementById('view-created-at').value = app.formatDate(p.createdAt);
                document.getElementById('view-updated-at').value = app.formatDate(p.updatedAt);
                
                const historyField = document.getElementById('view-history');
                if (p.modificationHistory && p.modificationHistory.length > 0) {
                    historyField.value = p.modificationHistory.map(h => `[${app.formatDate(h.date)}] ${h.note}`).join('\n');
                } else {
                    historyField.value = "暂无修改记录";
                }

                const manualCat = p.manualCategory || "";
                document.getElementById('edit-category-override').value = manualCat;
                const overrideSelect = document.getElementById('edit-category-override');
                const reasonBox = document.getElementById('logic-reason-text');
                
                if (manualCat) {
                    overrideSelect.disabled = false;
                    reasonBox.innerText = "当前为手动强制设置,系统建议: " + logic.evaluateDetailed(p).reason;
                    reasonBox.style.color = "#999";
                    reasonBox.style.textDecoration = "line-through";
                } else {
                    overrideSelect.disabled = false;
                    const result = logic.evaluateDetailed(p);
                    const sysLabel = logic.getCategoryLabel(result.category);
                    reasonBox.innerHTML = `<span class="badge ${sysLabel.cls}" style="font-size:10px">${sysLabel.label}</span> 原因: ${result.reason}`;
                    reasonBox.style.color = "#555";
                    reasonBox.style.textDecoration = "none";
                }

                app.handleEduChange();

                const inputs = document.querySelectorAll('#edit-edu, #edit-height, #edit-weight, #edit-will, #edit-political, #edit-qualified, #edit-medical-info');
                inputs.forEach(inp => {
                    inp.oninput = () => {
                        app.updateBMIDisplay(); // 监听输入以更新 BMI
                        if(!document.getElementById('edit-category-override').value) {
                            const tempP = app.getFormDataFromModal();
                            const res = logic.evaluateDetailed(tempP);
                            const sysLabel = logic.getCategoryLabel(res.category);
                            reasonBox.innerHTML = `<span class="badge ${sysLabel.cls}" style="font-size:10px">${sysLabel.label}</span> 预判: ${res.reason}`;
                        }
                    };
                });

                app.currentEditLogs = p.contactLogs || [];
                document.getElementById('edit-idcard').readOnly = true;   
                app.renderContactLogsUI();
                document.getElementById('modal-edit').style.display = 'flex';
             
            },

            getFormDataFromModal: () => {
                const visionStr = document.getElementById('edit-vision').value;
                const vParts = visionStr.split('/');
                return {
                    education: document.getElementById('edit-edu').value,
                    height: parseInt(document.getElementById('edit-height').value) || 0,
                    weight: parseFloat(document.getElementById('edit-weight').value) || 0, // 新增
                    willingness: document.getElementById('edit-will').value,
                    politicalCheck: document.getElementById('edit-political').value === 'true',
                    isQualified: document.getElementById('edit-qualified').value, 
                    medicalInfo: document.getElementById('edit-medical-info').value,
                    visionLeft: parseFloat(vParts[0]) || 0,
                    visionRight: parseFloat(vParts[1]) || 0
                };
            },

            addContactLog: () => {
                const date = document.getElementById('new-contact-date').value;
                const person = document.getElementById('new-contact-person').value;
                const content = document.getElementById('new-contact-content').value;
                if(!date || !content) return alert('请填写日期和内容');
                app.currentEditLogs.push({ date, person, content });
                document.getElementById('new-contact-content').value = '';
                document.getElementById('new-contact-person').value = '';
                app.renderContactLogsUI();
            },

            renderContactLogsUI: () => {
                const container = document.getElementById('contact-logs-list');
                container.innerHTML = '';
                app.currentEditLogs.forEach((log, idx) => {
                    container.innerHTML += `
                        <div style="border-bottom:1px solid #eee; padding:3px;">
                            <span style="color:#666;font-size:11px;">${log.date}</span> 
                            <span style="font-weight:bold;font-size:11px;">${log.person || '-'}</span>: 
                            <span style="font-size:11px;">${log.content}</span>
                            <span style="color:red;cursor:pointer;float:right;font-size:11px;">×</span>
                        </div>
                    `;
                });
            },

            removeLog: (idx) => { app.currentEditLogs.splice(idx, 1); app.renderContactLogsUI(); },

            saveLogicConfig: () => {
                ConfigManager.updateFromUI();
                app.showToast('逻辑配置已保存,正在刷新数据...');
                setTimeout(async () => {
                    const all = await db.getAll(true);
                    for(let p of all) {
                        p.cachedCategory = logic.getCategory(p);
                        await db.put(p); 
                    }
                    app.renderDashboard();
                    app.renderList();
                    app.showToast('逻辑配置与数据状态已同步更新');
                }, 500);
            },

            saveEdit: async () => {
                const id = document.getElementById('edit-id').value;
                const idCard = document.getElementById('edit-idcard').value;
                const isEdit = !!id; 
                
                if (!isEdit && (!idCard || idCard.trim() === '')) {
                    alert('请填写身份证号'); 
                    return; 
                }
                const now = new Date().toISOString();
                const logs = app.currentEditLogs;
                const lastContact = logs.length > 0 ? logs[logs.length-1].date : null;
                const visionStr = document.getElementById('edit-vision').value;
                const vParts = visionStr.split('/');
                const currentDeleted = document.getElementById('edit-deleted').value === 'true';
                let manualOverride = document.getElementById('edit-category-override').value;
                if (manualOverride === '') manualOverride = null;
                
                let remarks = document.getElementById('edit-remarks').value;
                const edu = document.getElementById('edit-edu').value;
                const targetEdu = document.getElementById('edit-target-edu').value;

                if (edu === '大学在校生') {
                    remarks = remarks.replace(/\(在校类型: (大专|大学|研究生)\)/g, '');
                    if (!remarks.includes('大专在校生') && !remarks.includes('大学在校生') && !remarks.includes('研究生在校生')) {
                        remarks = remarks.trim() ? `${remarks} (在校类型: ${targetEdu})` : `(在校类型: ${targetEdu})`;
                    }
                }

                const newData = {
                    id: parseFloat(id) || (Date.now() + Math.random()), 
                    name: document.getElementById('edit-name').value,
                    idCard: idCard,
                    householder: document.getElementById('edit-householder').value,
                    village: document.getElementById('edit-village').value,
                    phone: document.getElementById('edit-phone').value,
                    familyPhone: document.getElementById('edit-family-phone').value,
                    address: document.getElementById('edit-address').value, 
                    education: edu,
                    gradYear: parseInt(document.getElementById('edit-grad-year').value) || null,
                    willingness: document.getElementById('edit-will').value,
                    inPhysicalList: document.getElementById('edit-in-physical-list').checked,
                    physicalYear: document.getElementById('edit-physical-year-text').value || '',
                                        
                    height: parseInt(document.getElementById('edit-height').value) || 0,
                    weight: parseFloat(document.getElementById('edit-weight').value) || null, // 新增保存体重
                    visionLeft: parseFloat(vParts[0]) || 0,
                    visionRight: parseFloat(vParts[1]) || 0,
                    politicalCheck: document.getElementById('edit-political').value === 'true',
                    isQualified: document.getElementById('edit-qualified').value, 
                    medicalInfo: document.getElementById('edit-medical-info').value,
                    remarks: remarks,
                    contactLogs: logs,
                    lastContactDate: lastContact,
                    updatedAt: now,
                    deleted: currentDeleted,
                    manualCategory: manualOverride ? manualOverride : null, 
                };
                
                newData.cachedCategory = logic.getCategory(newData);    
                
                if (idCard && idCard.trim() !== '') {                    
                    const duplicate = await db.checkIdCardExists(idCard); 
                    if (duplicate) {
                        if (!isEdit) {
                            alert('新增失败:该身份证号已存在于数据库中!\n姓名:' + duplicate.name);
                            return; 
                        } 
                        else {
                            if (duplicate.id !== parseFloat(id)) {
                                alert('保存失败:该身份证号已存在!\n占用者:' + duplicate.name);
                                return; 
                            }
                        }
                    }
                }
                
                if (isEdit) {
                    const old = await db.getById(parseFloat(id)); 
                    if (old) {
                        newData.createdAt = old.createdAt;
                        newData.deleted = old.deleted;
                        newData.eliminationReason = old.eliminationReason;
                        newData.deletedAt = old.deletedAt;
                        newData.modificationHistory = old.modificationHistory || [];
                        
                        const isSemanticEqual = (v1, v2) => {
                            if (v1 === v2) return true;  
                            const isNull = (val) => val === null || val === undefined; 
                            if (isNull(v1) && isNull(v2)) return true;  
                            if (v1 === false && isNull(v2)) return true;
                            if (v2 === false && isNull(v1)) return true;
                            if (Array.isArray(v1) && v1.length === 0 && isNull(v2)) return true;
                            if (Array.isArray(v2) && v2.length === 0 && isNull(v1)) return true;
                            if (v1 === 0 && isNull(v2)) return true;
                            if (v2 === 0 && isNull(v1)) return true;
                            if (v1 === '' && isNull(v2)) return true;
                            if (v2 === '' && isNull(v1)) return true;
                            if (typeof v1 === typeof v2) {
                                if (Array.isArray(v1) || Array.isArray(v2) || typeof v1 === 'object' || typeof v2 === 'object') {
                                    try {
                                        return JSON.stringify(v1) === JSON.stringify(v2);
                                    } catch (e) {
                                        return false; 
                                    }
                                }
                            }
                            return false; 
                        };                       
                        
                        const infoFields = ['name', 'householder', 'village', 'phone', 'familyPhone', 'address', 'education', 'gradYear', 'willingness', 'remarks', 'contactLogs'];
                        let infoChanged = false;
                        for (let k of infoFields) {
                            if (!isSemanticEqual(newData[k], old[k])) {
                                infoChanged = true;
                                break;
                            }
                        }
                        
                        // --- 修改:将 'weight' 加入体检字段,以便正确记录脚注 ---
                        const physFields = ['physicalYear', 'height', 'weight', 'visionLeft', 'visionRight', 'isQualified', 'medicalInfo']; 
                        let physChanged = false;
                        physFields.forEach(field => {
                            if (!isSemanticEqual(newData[field], old[field])) {
                                physChanged = true;
                            } 
                        });

                        const inListNow = (newData.inPhysicalList === true);
                        
                        if (infoChanged || physChanged) {
                            if (inListNow && physChanged) {
                                newData.modificationHistory.push({ 
                                    date: now, 
                                    note: `${newData.physicalYear}体检,体检信息修改`
                                });
                            } else {
                                newData.modificationHistory.push({ date: now, note: "信息修改" }); 
                            }
                        }

                        const statusChangeNote = [];
                        if (old.manualCategory !== manualOverride) {
                            if (manualOverride) statusChangeNote.push(`状态手动强制改为: ${manualOverride}`);
                            else statusChangeNote.push(`状态恢复自动计算`);
                            if (!infoChanged && !physChanged && statusChangeNote.length > 0) {
                                newData.modificationHistory.push({ date: now, note: "状态强制修改" + (statusChangeNote.length ? ` (${statusChangeNote.join(', ')})` : '') });
                            }
                        }
                    }
                } else {
                    newData.createdAt = now;
                    newData.modificationHistory = [{ date: now, note: "初始单个新增" }];
                }

                await db.put(newData);
                await app.renderVillageOptions();
                app.closeModal();
                app.showToast('保存成功');
                app.renderDashboard();
                app.renderList();
            },




            eliminatePerson: async (id) => {
                const reason = prompt("请输入淘汰原因:", "手动淘汰");
                if (reason === null) return;
                const p = await db.getById(id);
                if(p) {
                    p.deleted = true;
                    p.eliminationReason = reason;
                    p.deletedAt = new Date().toISOString();
                    p.modificationHistory.push({ date: p.deletedAt, note: "被淘汰: " + reason });
                    p.updatedAt = p.deletedAt;
                    await db.put(p);
                    app.showToast('已淘汰');
                    app.renderDashboard();
                    app.renderList();
                }
            },

            restorePerson: async (id) => {
                const p = await db.getById(id);
                if(p) {
                    const now = new Date().toISOString();
                    let note = "";
                    
                    if (p.erased) {
                        p.erased = false;
                        p.erasedReason = null;
                        p.erasedAt = null;
                        p.deleted = true; 
                        note = "从已删除列表恢复(回退至淘汰状态)";
                    } else if (p.deleted) {
                        p.deleted = false;
                        p.eliminationReason = null;
                        p.deletedAt = null;
                        note = "从淘汰列表恢复(回退至正常状态)";
                    }

                    p.updatedAt = now;
                    p.modificationHistory.push({ date: now, note: note });
                    await db.put(p);
                    app.showToast('已恢复');
                    app.renderDashboard();
                    app.renderRecycle(); 
                }
            },

            permanentlyErasePerson: async (id) => {
                if (!confirm("确定要彻底删除这条数据吗?此操作不可恢复!")) return;
                await db.delete(id); 
                app.showToast('已彻底删除');
                app.renderDashboard(); 
                app.renderRecycle(); 
            },

            closeModal: () => { document.getElementById('modal-edit').style.display = 'none'; },
            formatDate: (isoStr) => {
                if(!isoStr) return '';
                const d = new Date(isoStr);
                return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2,'0')}-${d.getDate().toString().padStart(2,'0')} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
            },
            showToast: (msg) => {
                const t = document.getElementById('toast');
                t.innerText = msg; t.style.opacity = 1;
                setTimeout(() => t.style.opacity = 0, 2000);
            },
            safeExit: async () => {
                if(confirm("确定退出并清除所有数据吗?")) {
                    await db.clearAll();
                    location.reload();
                }
            },

            exportCSV: async () => {
                const data = await db.getAll(false);
                // 修改表头,增加体重
                const headers = "姓名,身份证号,户主姓名,村社,联系电话,家庭电话,家庭住址,学历,毕业年份,参军意愿,身高,体重,视力,政考情况,体检是否合格,体检详情,最近沟通,备注,初次导入,最近修改\n"; 
                let csv = "\uFEFF" + headers;
                data.forEach(r => {
                    let qualText = '不合格';
                    if (r.isQualified === 'true') qualText = '合格';
                    else if (r.isQualified === 'suspect') qualText = '存疑';
                    
                    csv += `"${r.name}","${r.idCard}","${r.householder||''}","${r.village||''}","${r.phone||''}","${r.familyPhone||''}","${r.address||''}","${r.education}","${r.gradYear||''}","${r.willingness}","${r.height}","${r.weight||''}","${r.visionLeft}/${r.visionRight}","${r.politicalCheck?'是':'否'}","${qualText}","${r.medicalInfo||''}","${r.lastContactDate||''}","${r.remarks||''}","${app.formatDate(r.createdAt)}","${app.formatDate(r.updatedAt)}"\n`; 
                });
                const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
                const link = document.createElement("a");
                link.href = URL.createObjectURL(blob);
                link.download = `人员数据_${new Date().toLocaleDateString()}.csv`;
                link.click();
            },
            
            exportSelected: async (viewType) => {
                let targetIds = new Set();
                if (viewType === 'list') {
                    targetIds = app.selectedIds;
                } else if (viewType === 'recycle') {
                    targetIds = new Set([...app.selectedRecycleDeletedIds, ...app.selectedRecycleErasedIds]);
                }
                if (targetIds.size === 0) {
                    app.showToast('请先选择要导出的数据');
                    return;
                }
                const all = await db.getAll(true);
                const selectedData = all.filter(p => targetIds.has(p.id));
                if (selectedData.length === 0) {
                    app.showToast('未找到对应的数据');
                    return;
                }
                const headers = "姓名,身份证号,户主姓名,村社,联系电话,家庭电话,家庭住址,学历,毕业年份,参军意愿,身高,体重,视力,政考情况,体检是否合格,体检详情,最近沟通,备注,初次导入,最近修改,数据状态\n"; 
                let csv = "\uFEFF" + headers;

                selectedData.forEach(r => {
                    let qualText = '不合格';
                    if (r.isQualified === 'true') qualText = '合格';
                    else if (r.isQualified === 'suspect') qualText = '存疑';
                    
                    let statusText = '正常';
                    if (r.erased) statusText = '已删除';
                    else if (r.deleted) statusText = '已淘汰';
                    
                    csv += `"${r.name}","${r.idCard}","${r.householder||''}","${r.village||''}","${r.phone||''}","${r.familyPhone||''}","${r.address||''}","${r.education}","${r.gradYear||''}","${r.willingness}","${r.height}","${r.weight||''}","${r.visionLeft}/${r.visionRight}","${r.politicalCheck?'是':'否'}","${qualText}","${r.medicalInfo||''}","${r.lastContactDate||''}","${r.remarks||''}","${app.formatDate(r.createdAt)}","${app.formatDate(r.updatedAt)}","${statusText}"\n`; 
                });
                const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
                const link = document.createElement("a");
                link.href = URL.createObjectURL(blob);
                link.download = `选中数据_${selectedData.length}条_${new Date().toLocaleDateString()}.csv`;
                link.click();
                
                app.showToast(`已导出 ${selectedData.length} 条数据`);
            },

            downloadTemplate: () => {
                const note = "注意:\n1. 学历栏可选标准值:小学,初中,高中,中专,大专,大学,研究生,大学在校生。\n2. 支持智能识别在校年级:大专大一/大二/大三、大学大一/二/三/四、研究生研一/二/三。\n3. 参军意愿:愿意、一般、拒绝。\n4. 体检是否合格:合格、存疑、不合格。\n";
                // 修改模板,增加体重
                const csv = "\uFEFF姓名,身份证号,户主姓名,村社,联系电话,家庭电话,家庭住址,学历,毕业年份,参军意愿,身高,体重,视力,政考情况,体检是否合格,体检详情,最近沟通,备注\n"; 
                const blob = new Blob([note + csv], { type: 'text/csv;charset=utf-8;' });
                const link = document.createElement("a");
                link.href = URL.createObjectURL(blob);
                link.download = "导入模板.csv";
                link.click();
            },

             importCSV: async () => {
                const fileInput = document.getElementById('csv-file-input');
                const file = fileInput.files[0];
                if (!file) return alert("请选择文件");
                const reader = new FileReader();
                reader.onload = async (e) => {
                    const text = e.target.result;
                    const rows = text.split('\n');
                    let headerIndex = 0;
                    let headerCols = [];
                    
                    for(let i=0; i<rows.length; i++) {
                        if(rows[i].includes('姓名,身份证号')) {
                            headerIndex = i;
                            const processed = rows[i].replace(/"([^"]*)"/g, (match, p1) => {
                                return p1.replace(/,/g, '\0');
                            });
                            headerCols = processed.split(',').map(col => {
                                let val = col.replace(/\0/g, ',').trim();
                                if (val.startsWith('"') && val.endsWith('"')) {
                                    val = val.slice(1, -1);
                                }
                                return val;
                            });
                            break;
                        }
                    }
                    
                    let idxName = 0, idxIdCard = 1, idxEdu = 7, idxGradYear = 8, idxAddress = -1; 
                    let idxWill = 9, idxHeight = 10, idxWeight = 11, idxVision = 12, idxPol = 13, idxQual = 14, idxMed = 15, idxContact = 16, idxRemarks = 17;
                    
                    headerCols.forEach((h, i) => {
                        if (h.includes('姓名')) idxName = i;
                        if (h.includes('身份证')) idxIdCard = i;
                        if (h.includes('学历')) idxEdu = i;      
                        if (h.includes('毕业年份')) idxGradYear = i; 
                        if (h.includes('家庭住址')) idxAddress = i;
                        if (h.includes('意愿')) idxWill = i;
                        if (h.includes('身高')) idxHeight = i;
                        if (h.includes('体重')) idxWeight = i; // 新增:动态查找体重列
                        if (h.includes('视力')) idxVision = i;
                        if (h.includes('政考情况')) idxPol = i;
                        if (h.includes('体检是否合格')) idxQual = i;
                        if (h.includes('体检详情')) idxMed = i;
                        if (h.includes('最近沟通')) idxContact = i;
                        if (h.includes('备注')) idxRemarks = i;
                    });
                    
                    
                    const dataRows = rows.slice(headerIndex + 1);
                    const processedIdCardsInCSV = new Set();
                    const duplicates = [];
                    const importData = [];
                    const now = new Date().toISOString();

                    const parseCSVRow = (text) => {
                        const processed = text.replace(/"([^"]*)"/g, (match, p1) => {
                            return p1.replace(/,/g, '\0');
                        });
                        const cols = processed.split(',');
                        return cols.map(col => {
                            let val = col.replace(/\0/g, ',').trim();
                            if (val.startsWith('"') && val.endsWith('"')) {
                                val = val.slice(1, -1);
                            }
                            return val;
                        });
                    };

                    const clean = (c) => c ? c.replace(/^"|"$/g, '').trim() : '';

                    for (let row of dataRows) {
                        if (!row.trim()) continue;
                        
                        const cols = parseCSVRow(row);
                        
                        if (cols.length >= 2) {
                            const idCard = clean(cols[idxIdCard]); 
                            const name = clean(cols[idxName]);
                            
                            if (processedIdCardsInCSV.has(idCard)) {
                                duplicates.push(name);
                                continue;
                            }
                            
                            const duplicate = await db.checkIdCardExists(idCard);
                            if (duplicate) {
                                duplicates.push(name);
                                continue;
                            }
                            
                            processedIdCardsInCSV.add(idCard);
                            
                            let edu = clean(cols[idxEdu]) || '高中';                            
                            let gradYear = parseInt(clean(cols[idxGradYear]))||null; 
                            let address = cols[idxAddress] ? clean(cols[idxAddress]) : ''; 
                                                        
                            edu = edu.replace(/毕业\S*$/, '')
                                .replace(/^本科|学士/, '大学')
                                .replace(/^专科/, '大专')
                                .replace(/^(本科|学士|大学)$/, '大学')
                                .replace(/^(专科|大专)$/, '大专')
                                .replace(/^(硕士研究生)$/, '研究生'); 

                            let remarks = clean(cols[idxRemarks]) || ''; 
                            const gradeInfo = app.parseImportGrade(edu);
                            if (gradeInfo) {
                                edu = gradeInfo.displayEdu;
                                gradYear = gradeInfo.gradYear;
                                if (!remarks.includes('大专在校生') && !remarks.includes('大学在校生') && !remarks.includes('研究生在校生')) {
                                    remarks = remarks.trim() ? `${remarks} (在校类型: ${gradeInfo.targetEdu})` : `(在校类型: ${gradeInfo.targetEdu})`;
                                }
                            }

                            let will = clean(cols[idxWill]) || '一般'; 
                            const rawPol = cols[idxPol] ? cols[idxPol].trim() : '';
                            let politicalCheck = true; 
                            if (rawPol !== '') {
                                const negativeKeywords = ['否', '不合格', 'false', '0', 'no', 'n'];
                                if (negativeKeywords.some(k => rawPol.toLowerCase().includes(k))) {
                                    politicalCheck = false;
                                }
                            }

                            const qualRaw = clean(cols[idxQual]) || '合格'; 
                            let isQualified = 'true';
                            if (qualRaw === '不合格' || qualRaw === '否') isQualified = 'false';
                            if (qualRaw === '存疑') isQualified = 'suspect';
                            
                            let rawPhone = clean(cols[4]);
                            let rawFamilyPhone = clean(cols[5]);

                            const visionRaw =  clean(cols[idxVision]); 
                            let vLeft = 5.5; 
                            let vRight = 5.5; 
                            if (visionRaw) {
                                const vParts = visionRaw.split('/');
                                if(vParts.length >= 1) vLeft = parseFloat(vParts[0]) || 5.0;
                                if(vParts.length >= 2) vRight = parseFloat(vParts[1]) || 5.0;
                            }
                            let height = parseInt(clean(cols[idxHeight])) || 205; 
                            
                            // --- 新增:解析体重 ---
                            let weight = parseFloat(clean(cols[idxWeight])) || null;

							const tempItem = {
								id: Date.now() + Math.random(),
								name: clean(cols[0]), 
								idCard: idCard, 
								householder: clean(cols[2]), 
								village: clean(cols[3]),
								phone: (rawPhone === '0' || rawPhone === '') ? '' : rawPhone, 
								familyPhone: (rawFamilyPhone === '0' || rawFamilyPhone === '') ? '' : rawFamilyPhone,
								address: address, 
								education: edu, 
								gradYear: gradYear, 
								willingness: will,
								height: height, 
								weight: weight, // 保存体重
								visionLeft: vLeft, 
								visionRight: vRight,
								politicalCheck: politicalCheck, 
								isQualified: isQualified, 
								medicalInfo: clean(cols[idxMed] || ''), 
								lastContactDate: clean(cols[idxContact] || ''),
								remarks: remarks,
								createdAt: now, 
								updatedAt: now, 
								deleted: false, 
								contactLogs: [], 
								modificationHistory: [{date:now, note:"CSV导入"}],
								erased: false, 
								eliminationReason: null, 
								manualCategory: null
							};

							tempItem.cachedCategory = logic.getCategory(tempItem); // 2. 基于临时对象计算并缓存分类状态 (此时包含体重,BMI会生效)
							importData.push(tempItem); 
                        }
                    }
                    if (importData.length > 0) {
                        await db.addBatch(importData);
                        await app.renderVillageOptions();
                        let msg = `成功导入 ${importData.length} 条`;
                        if (duplicates.length > 0) msg += `\n跳过重复 ${duplicates.length} 人`;
                        alert(msg);
                        fileInput.value = ""; 
                        app.renderDashboard();
                    } else {
                        alert(duplicates.length > 0 ? `全部数据重复 (${duplicates.length}条)。` : "未解析到有效数据");
                    }
                };
                reader.readAsText(file, 'GBK');
            },

            backupDB: async () => {
                const data = await db.getAll(true);
                const jsonStr = JSON.stringify(data, null, 2);
                const blob = new Blob([jsonStr], { type: 'application/json' });
                const link = document.createElement("a");
                link.href = URL.createObjectURL(blob);
                link.download = `人员数据库备份_${new Date().toISOString().slice(0,10)}.json`;
                link.click();
            },

            restoreDB: async () => {
                const fileInput = document.getElementById('json-file-input');
                const file = fileInput.files[0];
                if (!file) return alert("请选择备份文件");
                const reader = new FileReader();
                reader.onload = async (e) => {
                    try {
                        const data = JSON.parse(e.target.result);
                        if (!Array.isArray(data)) throw new Error("格式错误");
                        if(confirm(`确认恢复 ${data.length} 条数据?`)) {
                            await db.clearAll();
                            await db.addBatch(data);
                            await app.renderVillageOptions();
                            app.showToast("数据库恢复成功");
                            app.renderDashboard();
                        }
                    } catch (err) { alert("恢复失败:文件格式不正确"); }
                };
                reader.readAsText(file);
            }
        };

        const db = new ConscriptionDB();
        window.onload = app.init;
    </script>

PersonalMS代码.rar (28.61 KB, 下载次数: 136)



免费评分

参与人数 3吾爱币 +9 热心值 +2 收起 理由
苏紫方璇 + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
等待穿山乙 + 1 街道办事处?注意数据安全啊!
shiys8 + 1 + 1 下载的是txt

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
苏紫方璇 发表于 2026-1-10 01:09
请在帖子中插入部分关键代码
本版块仅限分享编程技术和源码相关内容,发布帖子必须带上关键代码和具体功能介绍
推荐
炫迈 发表于 2026-1-10 14:22
老哥这个适龄青年管理系统我仔细看完了,用纯前端IndexedDB做本地存储确实方便,不用搭服务器,但有几个安全隐患必须提醒你,第一IndexedDB数据存在浏览器里,用户随便清个缓存或者换台电脑数据就全没了,第二这种敏感个人信息完全本地存储不符合数据安全法要求,街道或者武装部用这个系统万一出事要担责任的,建议至少加个本地加密功能,用CryptoJS把身份证号这些关键字段加密后再存,第三你那个导出CSV功能没做权限控制,任何人拿到电脑都能导出全部人员信息,太危险了,我以前在政务部门做过类似系统,这种涉及公民个人信息的必须上审计日志,记录谁在什么时候导出了什么数据,另外最好把核心数据同步到内网服务器做备份,别全靠浏览器存储,虽然你代码写得很完善,功能也很强大,但数据安全这根弦一定要绷紧
3#
bjliu 发表于 2026-1-10 07:00
4#
hubaoshu 发表于 2026-1-10 07:42
厉害了我的神
5#
天天哈皮 发表于 2026-1-10 08:25
所有数据存储于本地浏览器IndexedDB
清除浏览器缓存会清空数据吗?
6#
bachelor66 发表于 2026-1-10 09:00
这个可以看看,除了管人好像也可以管别的               
7#
kangroo99 发表于 2026-1-10 09:19
bjliu 发表于 2026-1-10 07:00
啊这,这不就是相亲备忘录

哈哈,这意想不到的附加功能
8#
gonga 发表于 2026-1-10 09:21
现在的大学生村官有能力开发这种系统了
9#
rhci 发表于 2026-1-10 09:23
建议使用SQL3这种轻量的数据库进行管理,或者BD也可以,浏览器的话,清理下浏览器就啥都没了。
10#
mzhsohu 发表于 2026-1-10 09:53
这个系统很实用。不知道数据库文件安不安全~!
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-1-13 11:16

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表