Files
laser-data-hub/static/app.js
T
broccoliandClaude Fable 5 edfb216561 激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

图片相关:
- 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张
- 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG
- 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷
- 详情页缩略图宫格与全屏 lightbox,公开看板同样可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:06:02 +08:00

85 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=`<b>${error?'操作未完成':'操作成功'}</b><span>${escapeHtml(typeof message==='string'?message:JSON.stringify(message))}</span>`;
$('#toasts').append(node); setTimeout(()=>node.remove(),4500);
}
// 自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
// 注意这挡不住内联事件属性里的 JS 字符串(HTML 解析器会先把 &#39; 解回单引号),那种地方一律改用 data-* + 监听器。
function escapeHtml(value) { return String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
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=>`<div class="batch-mini"><b>${escapeHtml(x.experiment_id)} · ${escapeHtml(x.material_name)}</b><small>${escapeHtml(x.material_category)} · ${x.actual_output_power} W · ${x.scanning_speed} mm/s</small><span>${x.test_date}</span></div>`).join(''):'<div class="empty">暂无录入记录</div>';}catch(e){target.textContent=e.message;}
}
let recordPage=1;
async function loadRecords(page=1){
const body=$('#recordRows');if(!body)return;recordPage=page;body.innerHTML='<tr><td colspan="9" class="loading">正在加载…</td></tr>';
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=>`<tr><td><b>${escapeHtml(x.experiment_id)}</b></td><td>${x.test_date}</td><td>${escapeHtml(x.material_category)} · ${escapeHtml(x.material_name)}</td><td>${x.material_thickness} mm</td><td>${x.actual_output_power} W</td><td>${x.scanning_speed} mm/s</td><td><span class="tag ${x.is_cut_through?'':'off'}">${x.is_cut_through?'是':'否'}</span></td><td>${x.pattern_clarity_score}</td><td><button class="table-action" onclick="editRecord(${x.id})">${window.APP.role==='viewer'?'查看':'编辑'}</button>${window.APP.role==='admin'?` <button class="danger-link" data-delete="${x.id}" data-no="${escapeHtml(x.experiment_id)}">删除</button>`:''}</td></tr>`).join(''):'<tr><td colspan="9" class="empty">未找到数据</td></tr>';
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=`<span>第 ${page} / ${pages} 页,共 ${res.pagination.total} 条</span><button ${page<=1?'disabled':''} onclick="loadRecords(${page-1})">上一页</button><button ${page>=pages?'disabled':''} onclick="loadRecords(${page+1})">下一页</button>`;
}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='<tr><td colspan="8" class="loading">正在加载…</td></tr>';try{const {data}=await api('/api/field-definitions');fieldDefinitionCache=new Map(data.map(field=>[field.id,field]));body.innerHTML=data.length?data.map(field=>`<tr><td><b>${escapeHtml(field.label)}</b>${field.unit?`<small class="cell-note">单位:${escapeHtml(field.unit)}</small>`:''}</td><td>${fieldTypeLabels[field.input_type]}</td><td><span class="tag ${field.is_required?'':'off'}">${field.is_required?'必填':'可选'}</span></td><td>${field.is_public?'公开':'内部'}</td><td><span class="tag ${field.is_active?'':'off'}">${field.is_active?'启用':'停用'}</span></td><td>${field.usage_count}</td><td>${field.sort_order}</td><td><button class="table-action" onclick="editFieldDefinitionById(${field.id})">配置</button></td></tr>`).join(''):'<tr><td colspan="8" class="empty">尚未添加扩展字段</td></tr>';}catch(error){toast(error.message,true);}}
function fieldDefinitionModal(field=null){const editing=Boolean(field);openModal(`<p class="eyebrow">DATA FIELD SCHEMA</p><h2>${editing?'修改数据字段':'添加数据字段'}</h2>${editing&&field.usage_count?`<p class="schema-warning">该字段已有 <b>${field.usage_count}</b> 条数据。修改不会删除或改写原值,无法适配新类型的值会按历史格式显示。</p>`:'<p class="muted">字段添加后会自动出现在管理录入和 Key 录入面板。</p>'}<form id="fieldDefinitionForm"><label>字段名称<input name="label" maxlength="100" value="${escapeHtml(field?.label||'')}" required></label><label>填写形式<select name="input_type"><option value="text">单行文本</option><option value="long_text">多行文本</option><option value="number">数字</option><option value="integer">整数</option><option value="date">日期</option><option value="boolean">是 / 否</option><option value="select">下拉选项</option></select></label><label>单位(可留空)<input name="unit" maxlength="50" value="${escapeHtml(field?.unit||'')}" placeholder="例如 MPa、℃"></label><label>排序值<input type="number" name="sort_order" min="0" max="10000" value="${field?.sort_order??0}" required></label><label id="fieldOptionsLabel" hidden>下拉选项(每行一个)<textarea name="options" rows="5">${escapeHtml((field?.options||[]).join('\n'))}</textarea></label><div class="check-grid"><label><input type="checkbox" name="is_required" ${field?.is_required?'checked':''}> 必填字段</label><label><input type="checkbox" name="is_public" ${field?.is_public===false?'':'checked'}> 公开看板显示</label><label><input type="checkbox" name="is_active" ${field?.is_active===false?'':'checked'}> 启用字段</label></div><button class="primary">保存字段设置</button></form>`);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(`<p class="eyebrow">ACCOUNT SECURITY</p><h2>修改登录密码</h2><p class="muted">新密码至少 10 位,且同时包含字母和数字。</p><form id="passwordForm"><label>当前密码<input type="password" name="old" required></label><label>新密码<input type="password" name="new" minlength="10" required></label><label>确认新密码<input type="password" name="confirm" minlength="10" required></label><button class="primary">更新密码</button></form>`);$('#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(`<p class="eyebrow">QUICK ENTRY ACCESS</p><h2>四位录入 Key</h2><p class="muted">当前状态:${configured?'已启用':'未设置'}。Key 全局唯一,修改后旧的录入授权立即失效。</p><form id="entryKeyForm"><label>四位数字 Key<input name="key" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" required></label><label>再次输入 Key<input name="confirm_key" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" required></label><label>当前登录密码<input type="password" name="password" autocomplete="current-password" required></label><button class="primary">${configured?'更换 Key':'启用 Key'}</button>${configured?'<button class="danger-link" type="button" id="removeEntryKeyBtn">停用录入 Key</button>':''}</form>`);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=>`<tr><td><b>${escapeHtml(x.display_name)}</b></td><td>${escapeHtml(x.username)}</td><td>${({admin:'管理员',uploader:'录入员',viewer:'只读'})[x.role]}</td><td><span class="tag ${x.is_active?'':'off'}">${x.is_active?'启用':'停用'}</span></td><td>${formatDate(x.created_at)}</td><td><button class="table-action" onclick="editUserById(${x.id})">管理</button></td></tr>`).join('');}catch(e){toast(e.message,true);}}
$('#newUserBtn')?.addEventListener('click',()=>{openModal(`<p class="eyebrow">ACCESS CONTROL</p><h2>新建用户</h2><form id="newUserForm"><label>用户名<input name="username" required></label><label>显示名称<input name="display_name" required></label><label>初始密码<input type="password" name="password" minlength="10" required></label><label>角色<select name="role"><option value="uploader">数据录入员</option><option value="viewer">只读访客</option><option value="admin">系统管理员</option></select></label><button class="primary">创建账号</button></form>`);$('#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(`<p class="eyebrow">USER MANAGEMENT</p><h2>管理 ${escapeHtml(x.display_name)}</h2><form id="editUserForm"><label>角色<select name="role"><option value="uploader" ${x.role==='uploader'?'selected':''}>数据录入员</option><option value="viewer" ${x.role==='viewer'?'selected':''}>只读访客</option><option value="admin" ${x.role==='admin'?'selected':''}>系统管理员</option></select></label><label>账号状态<select name="is_active"><option value="true" ${x.is_active?'selected':''}>启用</option><option value="false" ${!x.is_active?'selected':''}>停用</option></select></label><label>重置密码(不修改请留空)<input type="password" name="new_password" minlength="10"></label><button class="primary">保存设置</button></form>`);$('#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=>`<tr><td><b>${escapeHtml(x.filename)}</b></td><td>${x.database_type}</td><td>${formatBytes(x.size_bytes)}</td><td>${escapeHtml(x.creator)}</td><td>${formatDate(x.created_at)}</td><td><span class="hash" title="${x.sha256}">${x.sha256}</span></td><td><a class="table-action button-link" href="/api/backups/${x.id}/download">下载</a></td></tr>`).join(''):'<tr><td colspan="7" class="empty">暂无备份</td></tr>';}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=>`<tr><td>${formatDate(x.created_at)}</td><td>${escapeHtml(x.user)}</td><td><b>${escapeHtml(x.action)}</b></td><td>${escapeHtml(x.resource_type)} ${escapeHtml(x.resource_id||'')}</td><td>${escapeHtml(x.ip_address||'—')}</td><td><span class="hash" title="${escapeHtml(x.detail||'')}">${escapeHtml(x.detail||'—')}</span></td></tr>`).join(''):'<tr><td colspan="6" class="empty">暂无日志</td></tr>';}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');