const $ = (s, root=document) => root.querySelector(s);
const $$ = (s, root=document) => [...root.querySelectorAll(s)];
const csrf = window.APP.csrf;
function toast(message, error=false) {
const node=document.createElement('div'); node.className=`toast ${error?'error':''}`;
node.innerHTML=`${error?'操作未完成':'操作成功'} ${escapeHtml(typeof message==='string'?message:JSON.stringify(message))} `;
$('#toasts').append(node); setTimeout(()=>node.remove(),4500);
}
// 自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
// 注意这挡不住内联事件属性里的 JS 字符串(HTML 解析器会先把 ' 解回单引号),那种地方一律改用 data-* + 监听器。
function escapeHtml(value) { return String(value??'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); }
function formatDate(value) { return value ? new Date(value).toLocaleString('zh-CN',{hour12:false}) : '—'; }
function formatBytes(bytes) { if(bytes<1024)return `${bytes} B`; if(bytes<1048576)return `${(bytes/1024).toFixed(1)} KB`; return `${(bytes/1048576).toFixed(1)} MB`; }
async function api(url, options={}) {
options.headers={...(options.headers||{}),'X-CSRF-Token':csrf};
if(options.json){options.body=JSON.stringify(options.json);options.headers['Content-Type']='application/json';delete options.json;}
const response=await fetch(url,options); let data;
try{data=await response.json();}catch{data={message:response.statusText};}
if(response.status===401){location.href='/login';throw new Error('登录已过期');}
if(!response.ok){const msg=typeof data.message==='object' ? `${data.message.summary}:${(data.message.errors||[]).slice(0,8).join(';')}` : data.message; throw new Error(msg||'请求失败');}
return data;
}
function showView(name) {
$$('.view').forEach(v=>v.classList.remove('active')); $$('.nav-item').forEach(v=>v.classList.toggle('active',v.dataset.view===name));
const view=$(`#view-${name}`); if(!view)return; view.classList.add('active');
$('#pageTitle').textContent=({overview:'数据概览',entry:'手动录入',records:'数据中心',fields:'数据字段配置',users:'用户与权限',backup:'备份管理',audit:'审计日志'})[name];
$('#sidebar').classList.remove('open'); history.replaceState(null,'',`#${name}`);
({overview:loadRecent,records:loadRecords,fields:loadFieldDefinitions,users:loadUsers,backup:loadBackups,audit:loadAudits}[name]||(()=>{}))();
}
$$('.nav-item').forEach(b=>b.onclick=()=>showView(b.dataset.view)); $$('[data-go]').forEach(b=>b.onclick=()=>showView(b.dataset.go));
$('#menuBtn').onclick=()=>$('#sidebar').classList.toggle('open');
async function loadRecent(){
const target=$('#recentRecords');if(!target)return;
try{const {data}=await api('/api/records?page_size=4'); target.innerHTML=data.length?data.map(x=>`
${escapeHtml(x.experiment_id)} · ${escapeHtml(x.material_name)} ${escapeHtml(x.material_category)} · ${x.actual_output_power} W · ${x.scanning_speed} mm/s ${x.test_date}
`).join(''):'暂无录入记录
';}catch(e){target.textContent=e.message;}
}
let recordPage=1;
async function loadRecords(page=1){
const body=$('#recordRows');if(!body)return;recordPage=page;body.innerHTML='正在加载… ';
const q=encodeURIComponent($('#recordSearch').value),category=encodeURIComponent($('#categorySearch').value);
try{const res=await api(`/api/records?page=${page}&page_size=20&q=${q}&category=${category}`);body.innerHTML=res.data.length?res.data.map(x=>`${escapeHtml(x.experiment_id)} ${x.test_date} ${escapeHtml(x.material_category)} · ${escapeHtml(x.material_name)} ${x.material_thickness} mm ${x.actual_output_power} W ${x.scanning_speed} mm/s ${x.is_cut_through?'是':'否'} ${x.pattern_clarity_score} ${window.APP.role==='viewer'?'查看':'编辑'} ${window.APP.role==='admin'?` 删除 `:''} `).join(''):'未找到数据 ';
body.querySelectorAll('[data-delete]').forEach(button=>button.onclick=()=>deleteRecord(Number(button.dataset.delete),button.dataset.no));
const pages=Math.max(1,Math.ceil(res.pagination.total/res.pagination.page_size));$('#recordPager').innerHTML=`第 ${page} / ${pages} 页,共 ${res.pagination.total} 条 上一页 =pages?'disabled':''} onclick="loadRecords(${page+1})">下一页 `;
}catch(e){toast(e.message,true);}
}
window.loadRecords=loadRecords; if($('#searchBtn'))$('#searchBtn').onclick=()=>loadRecords(1);
const recordForm=$('#recordForm');
let customFieldDefinitions=[];
async function loadCustomFields(values={},disabled=false){if(!recordForm)return;try{customFieldDefinitions=await DynamicFields.load();DynamicFields.render($('#customFieldsGrid'),customFieldDefinitions,values,disabled);}catch(error){toast(error.message,true);}}
function resetRecordForm(){if(!recordForm)return;recordForm.reset();$('#recordId').value='';$('#entryTitle').textContent='手动录入实验数据';$('#saveRecordBtn').textContent='保存实验数据';recordForm.querySelectorAll('input,select,textarea').forEach(x=>x.disabled=false);DynamicFields.render($('#customFieldsGrid'),customFieldDefinitions,{});ImageUpload.reset();ImageUpload.setReadonly(window.APP.role==='viewer');}
$('#resetRecordBtn')?.addEventListener('click',resetRecordForm);
function recordPayload(form){
const data={}; const numeric=new Set(['apparent_density','uv_absorption_355nm','material_thickness','moisture_content','thermal_conductivity','initial_decomposition_temp','melting_vaporization_temp','specific_heat_capacity','carbon_residue_rate','surface_roughness','hardness','actual_output_power','display_current','scanning_speed','pulse_frequency','pulse_width','defocus_amount','scan_line_spacing','carbonized_edge_width','etching_depth','pattern_clarity_score','presentation_balance_score']);
for(const [key,value] of new FormData(form)){if(numeric.has(key))data[key]=value===''?null:Number(value);else if(['is_cut_through','is_fire_smolder'].includes(key))data[key]=value==='true';else data[key]=value;}
data.custom_fields=DynamicFields.collect(form);return {...data,...ImageUpload.collect()};
}
if(recordForm)recordForm.onsubmit=async event=>{event.preventDefault();if(ImageUpload.pending()){toast('图片还在上传,请稍候再保存',true);return;}const id=$('#recordId').value,button=$('#saveRecordBtn');button.disabled=true;button.textContent='正在保存…';try{const data=await api(id?`/api/records/${id}`:'/api/records',{method:id?'PUT':'POST',json:recordPayload(event.target)});toast(data.message);resetRecordForm();showView('records');}catch(e){toast(e.message,true);}finally{button.disabled=false;button.textContent=id?'保存修改':'保存实验数据';}};
window.editRecord=async id=>{try{const {data}=await api(`/api/records/${id}`);resetRecordForm();await loadCustomFields(data.custom_fields||{},window.APP.role==='viewer');ImageUpload.load(data.images||[]);$('#recordId').value=id;for(const [key,value] of Object.entries(data)){const input=recordForm?.elements.namedItem(key);if(input&&value!==null)input.value=typeof value==='boolean'?String(value):value;}$('#entryTitle').textContent=window.APP.role==='viewer'?'查看实验数据':'编辑实验数据';$('#saveRecordBtn').textContent='保存修改';if(window.APP.role==='viewer')recordForm.querySelectorAll('input,select,textarea,button[type="submit"]').forEach(x=>x.disabled=true);showView('entry');}catch(e){toast(e.message,true);}};
window.deleteRecord=async(id,no)=>{if(!confirm(`确定删除实验记录 ${no} 吗?此操作会写入审计日志。`))return;try{const d=await api(`/api/records/${id}`,{method:'DELETE'});toast(d.message);loadRecords(recordPage);}catch(e){toast(e.message,true);}};
const fieldTypeLabels={text:'单行文本',long_text:'多行文本',number:'数字',integer:'整数',date:'日期',boolean:'是 / 否',select:'下拉选项'};
let fieldDefinitionCache=new Map();
async function loadFieldDefinitions(){const body=$('#fieldRows');if(!body)return;body.innerHTML='正在加载… ';try{const {data}=await api('/api/field-definitions');fieldDefinitionCache=new Map(data.map(field=>[field.id,field]));body.innerHTML=data.length?data.map(field=>`${escapeHtml(field.label)} ${field.unit?`单位:${escapeHtml(field.unit)} `:''}${fieldTypeLabels[field.input_type]} ${field.is_required?'必填':'可选'} ${field.is_public?'公开':'内部'} ${field.is_active?'启用':'停用'} ${field.usage_count} ${field.sort_order} 配置 `).join(''):'尚未添加扩展字段 ';}catch(error){toast(error.message,true);}}
function fieldDefinitionModal(field=null){const editing=Boolean(field);openModal(`DATA FIELD SCHEMA
${editing?'修改数据字段':'添加数据字段'} ${editing&&field.usage_count?`该字段已有 ${field.usage_count} 条数据。修改不会删除或改写原值,无法适配新类型的值会按历史格式显示。
`:'字段添加后会自动出现在管理录入和 Key 录入面板。
'}`);const form=$('#fieldDefinitionForm'),elements=form.elements;elements.input_type.value=field?.input_type||'text';const toggleOptions=()=>$('#fieldOptionsLabel').hidden=elements.input_type.value!=='select';elements.input_type.onchange=toggleOptions;toggleOptions();form.onsubmit=async event=>{event.preventDefault();const payload={label:elements.label.value,input_type:elements.input_type.value,unit:elements.unit.value||null,sort_order:Number(elements.sort_order.value),options:elements.options.value.split('\n').map(x=>x.trim()).filter(Boolean),is_required:elements.is_required.checked,is_public:elements.is_public.checked,is_active:elements.is_active.checked};if(editing&&field.usage_count&&payload.input_type!==field.input_type&&!confirm(`字段类型将从“${fieldTypeLabels[field.input_type]}”改为“${fieldTypeLabels[payload.input_type]}”。${field.usage_count} 条历史值会保留,确定继续吗?`))return;try{const result=await api(editing?`/api/field-definitions/${field.id}`:'/api/field-definitions',{method:editing?'PUT':'POST',json:payload});toast(result.message);closeModal();loadFieldDefinitions();loadCustomFields();}catch(error){toast(error.message,true);}};}
$('#newFieldBtn')?.addEventListener('click',()=>fieldDefinitionModal());window.editFieldDefinitionById=id=>fieldDefinitionModal(fieldDefinitionCache.get(id));
function openModal(html){$('#modalContent').innerHTML=html;$('#modal').hidden=false;}function closeModal(){$('#modal').hidden=true;}$$('[data-close],.modal-backdrop').forEach(x=>x.onclick=closeModal);
function passwordModal(){openModal(`ACCOUNT SECURITY
修改登录密码 新密码至少 10 位,且同时包含字母和数字。
当前密码 新密码 确认新密码 更新密码 `);$('#passwordForm').onsubmit=async e=>{e.preventDefault();const f=e.target;if(f.new.value!==f.confirm.value){toast('两次输入的新密码不一致',true);return;}try{const d=await api('/api/me/password',{method:'PUT',json:{old_password:f.old.value,new_password:f.new.value}});toast(d.message);closeModal();$('.notice')?.remove();}catch(err){toast(err.message,true);}};}
$('#passwordBtn').onclick=passwordModal;$('#noticePasswordBtn')?.addEventListener('click',passwordModal);
function entryKeyModal(){const configured=window.APP.entryKeyConfigured;openModal(`QUICK ENTRY ACCESS
四位录入 Key 当前状态:${configured?'已启用':'未设置'}。Key 全局唯一,修改后旧的录入授权立即失效。
四位数字 Key 再次输入 Key 当前登录密码 ${configured?'更换 Key':'启用 Key'} ${configured?'停用录入 Key ':''} `);const form=$('#entryKeyForm');form.querySelectorAll('input[inputmode="numeric"]').forEach(input=>input.oninput=()=>input.value=input.value.replace(/\D/g,'').slice(0,4));form.onsubmit=async event=>{event.preventDefault();if(form.key.value!==form.confirm_key.value){toast('两次输入的 Key 不一致',true);return;}try{const result=await api('/api/me/entry-key',{method:'PUT',json:{key:form.key.value,password:form.password.value}});window.APP.entryKeyConfigured=true;toast(result.message);closeModal();}catch(error){toast(error.message,true);}};$('#removeEntryKeyBtn')?.addEventListener('click',async()=>{if(!form.password.value){toast('请先输入当前登录密码',true);return;}if(!confirm('确定停用录入 Key 吗?现有录入会话将立即失效。'))return;try{const result=await api('/api/me/entry-key',{method:'DELETE',json:{password:form.password.value}});window.APP.entryKeyConfigured=false;toast(result.message);closeModal();}catch(error){toast(error.message,true);}});}
if($('#entryKeyBtn'))$('#entryKeyBtn').onclick=entryKeyModal;
$('#logoutBtn').onclick=async()=>{try{await api('/api/auth/logout',{method:'POST'});}finally{location.href='/login';}};
let userCache=new Map();async function loadUsers(){const body=$('#userRows');if(!body)return;try{const {data}=await api('/api/users');userCache=new Map(data.map(user=>[user.id,user]));body.innerHTML=data.map(x=>`${escapeHtml(x.display_name)} ${escapeHtml(x.username)} ${({admin:'管理员',uploader:'录入员',viewer:'只读'})[x.role]} ${x.is_active?'启用':'停用'} ${formatDate(x.created_at)} 管理 `).join('');}catch(e){toast(e.message,true);}}
$('#newUserBtn')?.addEventListener('click',()=>{openModal(`ACCESS CONTROL
新建用户 用户名 显示名称 初始密码 角色数据录入员 只读访客 系统管理员 创建账号 `);$('#newUserForm').onsubmit=async e=>{e.preventDefault();const f=new FormData(e.target);try{const d=await api('/api/users',{method:'POST',json:Object.fromEntries(f)});toast(d.message);closeModal();loadUsers();}catch(err){toast(err.message,true);}};});
window.editUserById=id=>{const x=userCache.get(id);openModal(`USER MANAGEMENT
管理 ${escapeHtml(x.display_name)} 角色数据录入员 只读访客 系统管理员 账号状态启用 停用 重置密码(不修改请留空) 保存设置 `);$('#editUserForm').onsubmit=async e=>{e.preventDefault();const f=Object.fromEntries(new FormData(e.target));f.is_active=f.is_active==='true';try{const d=await api(`/api/users/${x.id}`,{method:'PATCH',json:f});toast(d.message);closeModal();loadUsers();}catch(err){toast(err.message,true);}};};
async function loadBackups(){const body=$('#backupRows');if(!body)return;try{const {data}=await api('/api/backups');body.innerHTML=data.length?data.map(x=>`${escapeHtml(x.filename)} ${x.database_type} ${formatBytes(x.size_bytes)} ${escapeHtml(x.creator)} ${formatDate(x.created_at)} ${x.sha256} 下载 `).join(''):'暂无备份 ';}catch(e){toast(e.message,true);}}
$('#backupBtn')?.addEventListener('click',async e=>{e.target.disabled=true;try{const d=await api('/api/backups',{method:'POST'});toast(d.message);loadBackups();}catch(err){toast(err.message,true);}finally{e.target.disabled=false;}});
async function loadAudits(){const body=$('#auditRows');if(!body)return;try{const {data}=await api('/api/audits?page_size=100');body.innerHTML=data.length?data.map(x=>`${formatDate(x.created_at)} ${escapeHtml(x.user)} ${escapeHtml(x.action)} ${escapeHtml(x.resource_type)} ${escapeHtml(x.resource_id||'')} ${escapeHtml(x.ip_address||'—')} ${escapeHtml(x.detail||'—')} `).join(''):'暂无日志 ';}catch(e){toast(e.message,true);}}
if(recordForm)ImageUpload.mount($('#recordImages'),{endpoint:'/api/uploads/images',getCsrf:()=>csrf,readonly:window.APP.role==='viewer',onError:message=>toast(message,true)});
loadCustomFields();showView(location.hash.slice(1)||'overview');