[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>