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==='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} 条`; }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 全局唯一,修改后旧的录入授权立即失效。

${configured?'':''}
`);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');