激光材料数据平台 1.3.0

公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:06:02 +08:00
co-authored by Claude Fable 5
commit edfb216561
49 changed files with 3922 additions and 0 deletions
+18
View File
File diff suppressed because one or more lines are too long
+84
View File
@@ -0,0 +1,84 @@
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');
+20
View File
@@ -0,0 +1,20 @@
(function(){
const escapeHtml=value=>{const node=document.createElement('div');node.textContent=value??'';return node.innerHTML;};
async function load(){const response=await fetch('/api/form-fields');const data=await response.json();if(!response.ok)throw new Error(data.message||'扩展字段加载失败');return data.data;}
function inputHtml(definition,raw,disabled,preserveIfEmpty=false){
const name=escapeHtml(definition.field_key),value=raw??'',required=definition.is_required&&!preserveIfEmpty?'required':'',off=disabled?'disabled':'',preserve=preserveIfEmpty?'data-preserve-if-empty="true"':'',hint=preserveIfEmpty?`placeholder="原值:${escapeHtml(raw)}(输入新值可替换)"`:'';
if(definition.input_type==='long_text')return `<textarea data-custom-key="${name}" rows="4" ${required} ${off} ${preserve} ${hint}>${escapeHtml(value)}</textarea>`;
if(definition.input_type==='boolean')return `<select data-custom-key="${name}" ${required} ${off} ${preserve}><option value="">请选择</option><option value="true" ${String(value)==='true'?'selected':''}>是</option><option value="false" ${String(value)==='false'?'selected':''}>否</option></select>`;
if(definition.input_type==='select')return `<select data-custom-key="${name}" ${required} ${off} ${preserve}><option value="">请选择</option>${definition.options.map(option=>`<option value="${escapeHtml(option)}" ${String(value)===option?'selected':''}>${escapeHtml(option)}</option>`).join('')}</select>`;
const types={number:'number',integer:'number',date:'date',text:'text'};const step=definition.input_type==='number'?'step="any"':definition.input_type==='integer'?'step="1"':'';
return `<input type="${types[definition.input_type]||'text'}" data-custom-key="${name}" value="${escapeHtml(value)}" ${step} ${required} ${off} ${preserve} ${hint}>`;
}
function render(container,definitions,values={},disabled=false){
const section=container.closest('.custom-fields-section'),activeKeys=new Set(definitions.map(item=>item.field_key));
const active=definitions.map(definition=>{const saved=values[definition.field_key],incompatible=saved&&typeof saved==='object'&&saved.compatible===false,raw=saved&&typeof saved==='object'?saved.raw_value:saved,inputValue=incompatible?'':raw,warning=incompatible?`<span class="field-format-warning">历史值“${escapeHtml(raw)}”与当前类型不兼容;留空会保留原值,填写新值后替换</span>`:'';return `<label>${escapeHtml(definition.label)}${definition.unit?` (${escapeHtml(definition.unit)})`:''} ${definition.is_required?'<em>*</em>':'<small>可选</small>'}${inputHtml(definition,inputValue,disabled,incompatible)}${warning}</label>`;}).join('');
const historical=Object.entries(values).filter(([key,value])=>!activeKeys.has(key)&&value&&typeof value==='object').map(([,value])=>`<div class="historical-custom-value"><small>已停用字段 · ${escapeHtml(value.label)}</small><b>${escapeHtml(value.display_value||value.raw_value||'未填写')}</b>${value.compatible===false?'<span>历史格式</span>':''}</div>`).join('');
container.innerHTML=active+historical;section.hidden=!definitions.length&&!historical;
}
function collect(form){const result={};form.querySelectorAll('[data-custom-key]').forEach(input=>{if(input.value===''&&input.dataset.preserveIfEmpty==='true')return;result[input.dataset.customKey]=input.value===''?null:input.value;});return result;}
window.DynamicFields={load,render,collect};
})();
+62
View File
@@ -0,0 +1,62 @@
const $ = (selector, root=document) => root.querySelector(selector);
const $$ = (selector, root=document) => [...root.querySelectorAll(selector)];
let csrf=document.body.dataset.csrf;
let pendingPayload=null;
function escapeHtml(value){return String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
function toast(message,error=false){const node=document.createElement('div');node.className=`toast ${error?'error':''}`;node.innerHTML=`<b>${error?'操作未完成':'保存成功'}</b><span>${escapeHtml(message)}</span>`;$('#toasts').append(node);setTimeout(()=>node.remove(),4500);}
class EntryExpired extends Error{constructor(){super('录入授权已过期');}}
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);
const data=await response.json().catch(()=>({})); // 反代返回 HTML 的 413/502 时不能让 JSON 解析吞掉真实状态码
if(response.status===401&&url!=='/api/entry/auth')throw new EntryExpired();
if(!response.ok)throw new Error(data.message||`请求失败(${response.status}`);
return data;
}
// 录入授权只有 30 分钟,而这张表有 30 多个必填项和最多 6 张照片,超时是常态。
// 过期后绝不能刷新页面:那会把填了半天的表单和已上传的图一起清空,只就地重新验证 Key。
async function reauthorize(){
const key=prompt('录入授权已过期(30 分钟)。请重新输入四位录入 Key,已填写的内容和已上传的图片都会保留:');
if(!key)return false;
try{
await api('/api/entry/auth',{method:'POST',json:{key:key.trim()}});
const session=await (await fetch('/api/entry/session')).json();
if(!session.authorized||!session.csrf_token)return false;
csrf=session.csrf_token; // cookie 换了,CSRF 是它的 HMAC,必须一起换掉
return true;
}catch(exception){toast(exception.message,true);return false;}
}
$('#keyForm')?.addEventListener('submit',async event=>{event.preventDefault();const button=event.target.querySelector('button'),error=$('#keyError');button.disabled=true;error.hidden=true;try{await api('/api/entry/auth',{method:'POST',json:{key:$('#entryKey').value}});location.reload();}catch(exception){error.textContent=exception.message;error.hidden=false;$('#entryKey').select();}finally{button.disabled=false;}});
$('#entryKey')?.addEventListener('input',event=>{event.target.value=event.target.value.replace(/\D/g,'').slice(0,4)});
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']);
function formPayload(form){const data={};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);Object.assign(data,ImageUpload.collect());return data;}
$('#publicRecordForm')?.addEventListener('submit',event=>{event.preventDefault();if(ImageUpload.pending()){toast('图片还在上传,请稍候再提交',true);return;}pendingPayload=formPayload(event.target);$('#confirmModal').hidden=false;setTimeout(()=>$('#confirmForm input').focus(),50);});
$('#confirmForm')?.addEventListener('submit',async event=>{
event.preventDefault();
const button=event.target.querySelector('button'),username=event.target.username.value.trim();
button.disabled=true;
const submit=()=>api('/api/entry/records',{method:'POST',json:{...pendingPayload,confirm_username:username}});
try{
let result;
try{result=await submit();}
catch(exception){
if(!(exception instanceof EntryExpired))throw exception;
if(!await reauthorize())throw new Error('未重新验证 Key,数据尚未保存;表单内容仍然保留');
result=await submit();
}
toast(`${result.data.experiment_id} 已写入数据库`);
$('#confirmModal').hidden=true;event.target.reset();$('#publicRecordForm').reset();ImageUpload.reset();
pendingPayload=null;window.scrollTo({top:0,behavior:'smooth'});
}catch(exception){toast(exception.message,true);}
finally{button.disabled=false;}
});
$$('[data-close]').forEach(node=>node.onclick=()=>{$('#confirmModal').hidden=true;});
$('#entryLogoutBtn')?.addEventListener('click',async()=>{try{await api('/api/entry/logout',{method:'POST'});}catch{}finally{location.reload();}});
if($('#publicRecordForm')){
DynamicFields.load().then(definitions=>DynamicFields.render($('#customFieldsGrid'),definitions)).catch(error=>toast(error.message,true));
ImageUpload.mount($('#recordImages'),{endpoint:'/api/entry/uploads/images',getCsrf:()=>csrf,onError:message=>toast(message,true),onUnauthorized:reauthorize});
}
+279
View File
@@ -0,0 +1,279 @@
(function(){
const MAX_IMAGES=6,MAX_EDGE=1600,THUMB_EDGE=320,QUALITY=.82,THUMB_QUALITY=.72,MAX_UPLOAD_BYTES=8*1024*1024,PNG_PASSTHROUGH_BYTES=1.5*1024*1024,DECODE_TIMEOUT=20000,UPLOAD_TIMEOUT=120000,IOS_CANVAS_LIMIT=16777216;
// 必须自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
const escapeHtml=value=>String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const GENERIC_NAMES=new Set(['image.jpg','image.jpeg','image.png','image.heic','photo.jpg','photo.jpeg','未命名.jpg']);
// 桌面浏览器会静默忽略 capture,摄像头按钮在那里只会打开同样的文件框,所以只在触摸设备渲染。
const CAN_CAPTURE=matchMedia('(pointer: coarse)').matches&&navigator.maxTouchPoints>0;
let config={endpoint:'',getCsrf:()=>'',onChange:null},container=null,items=[],readonly=false,serial=0,queue=Promise.resolve(),loadedExisting=false;
const releaseCanvas=canvas=>{canvas.width=canvas.height=0;};
const toJpeg=(canvas,quality)=>new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',quality));
// 只用 load/error 事件:img.decode() 在部分 Chromium 上对未插入文档的图片会永不 resolve,卡死上传队列。
function loadElement(url){
return new Promise((resolve,reject)=>{
const image=new Image();image.decoding='async';
const timer=setTimeout(()=>reject(new Error('decode-timeout')),DECODE_TIMEOUT);
image.onload=()=>{clearTimeout(timer);image.naturalWidth&&image.naturalHeight?resolve(image):reject(new Error('decode'));};
image.onerror=()=>{clearTimeout(timer);reject(new Error('decode'));};
image.src=url;
});
}
async function decodeImage(file){
const url=URL.createObjectURL(file);
try{
// <img> 是主路径:现代浏览器会自动按 EXIF 摆正,iOS Safari 还能直接解 HEIC。
const image=await loadElement(url);
return {source:image,width:image.naturalWidth,height:image.naturalHeight,release(){URL.revokeObjectURL(url);}};
}catch(error){
URL.revokeObjectURL(url);
if(typeof createImageBitmap!=='function')throw error;
const bitmap=await createImageBitmap(file,{imageOrientation:'from-image'});
return {source:bitmap,width:bitmap.width,height:bitmap.height,release(){bitmap.close?.();}};
}
}
function drawScaled(source,sourceWidth,sourceHeight,maxEdge){
const scale=Math.min(1,maxEdge/Math.max(sourceWidth,sourceHeight));
const width=Math.max(1,Math.round(sourceWidth*scale)),height=Math.max(1,Math.round(sourceHeight*scale));
let current=source,currentWidth=sourceWidth,currentHeight=sourceHeight;
while(currentWidth>width*2&&currentHeight>height*2){ // 大比例一次缩到底会产生锯齿,逐级折半更稳
currentWidth=Math.max(width,Math.round(currentWidth/2));currentHeight=Math.max(height,Math.round(currentHeight/2));
const step=document.createElement('canvas');step.width=currentWidth;step.height=currentHeight;
const stepContext=step.getContext('2d');stepContext.imageSmoothingQuality='high';
stepContext.drawImage(current,0,0,currentWidth,currentHeight);
if(current!==source)releaseCanvas(current);
current=step;
}
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
const context=canvas.getContext('2d');context.imageSmoothingQuality='high';
context.fillStyle='#ffffff';context.fillRect(0,0,width,height); // PNG 透明区直接转 JPEG 会变黑
context.drawImage(current,0,0,width,height);
if(current!==source)releaseCanvas(current);
return canvas;
}
function looksBlank(canvas){
try{
const probe=document.createElement('canvas');probe.width=probe.height=8;
const context=probe.getContext('2d',{willReadFrequently:true});
context.drawImage(canvas,0,0,8,8);
const {data}=context.getImageData(0,0,8,8);
let sum=0,squares=0;
for(let index=0;index<data.length;index+=4){
const luma=(data[index]*299+data[index+1]*587+data[index+2]*114)/1000;
sum+=luma;squares+=luma*luma;
}
releaseCanvas(probe);
return squares/64-(sum/64)**2<0.5; // 方差趋近 0 说明整张是纯色,正常照片不会这样
}catch{return false;}
}
async function prepare(file){
if(!file.type.startsWith('image/')&&!/\.(jpe?g|png|webp|heic|heif)$/i.test(file.name||''))throw new Error('只能上传图片文件');
let decoded;
try{decoded=await decodeImage(file);}
catch(error){
const isApple=/iP(hone|ad|od)/.test(navigator.userAgent);
throw new Error(/heic|heif/i.test(`${file.type} ${file.name}`)
? (isApple?'这张是 HEIC 高效格式且本机无法转换,请到「设置 → 相机 → 格式」选「兼容性最佳」后重拍':'这张是 HEIC/HEIF 格式,当前浏览器无法解码,请改用 JPEG 拍摄或先导出为 JPEG')
: '图片无法读取,请换一张或重新拍摄');
}
try{
// PNG 小图原样上传,线稿和截图重编码成 JPEG 会有振铃;PNG 没有 EXIF 方向问题,尺寸依然可信。
if(file.type==='image/png'&&file.size<=PNG_PASSTHROUGH_BYTES&&Math.max(decoded.width,decoded.height)<=MAX_EDGE){
const thumbCanvas=drawScaled(decoded.source,decoded.width,decoded.height,THUMB_EDGE);
const thumb=await toJpeg(thumbCanvas,THUMB_QUALITY);releaseCanvas(thumbCanvas);
return {blob:file,name:file.name||'image.png',thumb};
}
const canvas=drawScaled(decoded.source,decoded.width,decoded.height,MAX_EDGE);
// 超过 iOS 画布像素上限的源图,Safari 不报错、直接给一张空白画布,会静默存进一张纯白"成品图"。
if(decoded.width*decoded.height>IOS_CANVAS_LIMIT&&looksBlank(canvas)){
releaseCanvas(canvas);
throw new Error('这张照片分辨率过高,本机无法压缩,请把相机分辨率调低后重拍');
}
const blob=await toJpeg(canvas,QUALITY);
if(!blob){releaseCanvas(canvas);throw new Error('图片压缩失败,请换一张试试');}
if(blob.size>MAX_UPLOAD_BYTES){releaseCanvas(canvas);throw new Error('图片压缩后仍然过大,请换一张');}
let thumb=null;
// 从已经缩到 1600 的画布再缩,而不是回到原图重跑一遍——那是整个流程里最吃内存的一段。
try{const thumbCanvas=drawScaled(canvas,canvas.width,canvas.height,THUMB_EDGE);thumb=await toJpeg(thumbCanvas,THUMB_QUALITY);releaseCanvas(thumbCanvas);}catch{thumb=null;}
releaseCanvas(canvas);
return {blob,name:(file.name||'photo.jpg').replace(/\.(heic|heif|png|webp)$/i,'.jpg'),thumb};
}finally{decoded.release();}
}
function send(prepared,onProgress,item){
return new Promise((resolve,reject)=>{
const form=new FormData();
form.append('file',prepared.blob,prepared.name);
if(prepared.thumb)form.append('thumbnail',prepared.thumb,'thumb.jpg');
const request=new XMLHttpRequest();
request.open('POST',config.endpoint);
// 必须显式设置:XHR 默认永不超时,移动网络下的半开连接会让这张图永远停在"上传中",从而一直挡住提交。
request.timeout=UPLOAD_TIMEOUT;
if(item)item.xhr=request;
request.setRequestHeader('X-CSRF-Token',config.getCsrf()||'');
request.upload.onprogress=event=>{if(event.lengthComputable)onProgress(event.loaded/event.total);};
request.onload=()=>{
let data=null;try{data=JSON.parse(request.responseText);}catch{}
if(request.status>=200&&request.status<300&&data?.ok)resolve(data.data);
else if(request.status===401)reject(Object.assign(new Error('录入授权已过期,请重新验证后再上传'),{unauthorized:true}));
else if(request.status===413)reject(new Error(data?.message||'图片超出大小限制'));
else reject(new Error(data?.message||`上传失败(${request.status}`));
};
request.onerror=()=>reject(new Error('网络中断,图片未上传'));
request.ontimeout=()=>reject(new Error('上传超时,请重试'));
request.send(form);
});
}
function discard(token){
// 尽力而为:撤回失败也不打扰用户,服务端 2 小时后会自动清理。
fetch(`${config.endpoint}/${encodeURIComponent(token)}`,{method:'DELETE',headers:{'X-CSRF-Token':config.getCsrf()||''}}).catch(()=>{});
}
function render(){
if(!container)return;
const remaining=MAX_IMAGES-items.length;
const cards=items.map(item=>{
const busy=item.status==='uploading',failed=item.status==='error';
const preview=item.thumb_url||item.url||item.localUrl||'';
const label=escapeHtml(item.original_name||'待上传图片');
// 已上传的用 <button> 包起来,键盘和读屏才进得去;上传中/失败的还没有大图可看,保持纯展示。
const preview_markup=preview
? (item.status==='ready'
? `<button type="button" class="image-card-open" data-open="${item.key}" aria-label="放大查看 ${label}"><img src="${escapeHtml(preview)}" alt="${label}"></button>`
: `<img src="${escapeHtml(preview)}" alt="${label}">`)
: '<span class="image-card-blank">…</span>';
return `<figure class="image-card ${item.status}" data-key="${item.key}">
${preview_markup}
${busy?`<span class="image-card-progress"><i style="width:${Math.round((item.progress||0)*100)}%"></i></span>`:''}
${failed?`<span class="image-card-error" title="${escapeHtml(item.message||'')}">${escapeHtml(item.message||'上传失败')}</span>`:''}
<figcaption>${escapeHtml(item.original_name||item.name||'')}</figcaption>
${readonly?'':`<button type="button" class="image-card-remove" data-remove="${item.key}" aria-label="移除这张图片">×</button>`}
${failed&&!readonly?`<button type="button" class="image-card-retry" data-retry="${item.key}">重试</button>`:''}
</figure>`;
}).join('');
const dropzone=readonly?'':`<div class="image-drop ${items.length?'compact':''}" data-drop>
<span class="image-drop-icon">▣</span>
<p><b>添加成品图</b><small>JPG / PNG,最多 ${MAX_IMAGES} 张,上传前自动压缩</small></p>
<span class="image-drop-actions">
<button type="button" class="secondary" data-pick ${remaining<=0?'disabled':''}>选择图片</button>
${CAN_CAPTURE?`<button type="button" class="secondary" data-capture ${remaining<=0?'disabled':''}>拍照</button>`:''}
</span>
<small class="image-drop-hint">${remaining>0?`还可添加 ${remaining}`:'已达数量上限'}</small>
</div>`;
container.innerHTML=`${dropzone}<div class="image-card-grid">${cards||(readonly?'<div class="gallery-empty">暂无图片</div>':'')}</div>`;
wire();
config.onChange?.(items.slice());
}
function wire(){
const drop=container.querySelector('[data-drop]');
if(drop){
container.querySelector('[data-pick]')?.addEventListener('click',()=>openPicker(false));
container.querySelector('[data-capture]')?.addEventListener('click',()=>openPicker(true));
['dragenter','dragover'].forEach(name=>drop.addEventListener(name,event=>{event.preventDefault();drop.classList.add('drag');}));
['dragleave','drop'].forEach(name=>drop.addEventListener(name,event=>{event.preventDefault();drop.classList.remove('drag');}));
drop.addEventListener('drop',event=>accept([...(event.dataTransfer?.files||[])]));
}
container.querySelectorAll('[data-remove]').forEach(button=>button.onclick=()=>{
const removed=items.find(item=>item.key===button.dataset.remove);
removed?.xhr?.abort(); // 传到一半就移除的,别让请求继续跑
if(removed?.localUrl)URL.revokeObjectURL(removed.localUrl);
if(removed?.token)discard(removed.token); // 传完又删掉的,通知服务端撤回,及时腾出待提交名额
items=items.filter(item=>item.key!==button.dataset.remove);render();
});
container.querySelectorAll('[data-retry]').forEach(button=>button.onclick=()=>{
const item=items.find(entry=>entry.key===button.dataset.retry);
if(item?.file){items=items.filter(entry=>entry.key!==item.key);render();accept([item.file]);}
});
container.querySelectorAll('[data-open]').forEach(button=>button.onclick=()=>{
const ready=items.filter(item=>item.status==='ready');
window.ImageViewer?.open(ready,ready.findIndex(item=>item.key===button.dataset.open));
});
}
function openPicker(capture){
const input=document.createElement('input');
input.type='file';input.accept='image/*';
if(capture)input.setAttribute('capture','environment');else input.multiple=true;
input.style.display='none';
input.onchange=()=>{accept([...input.files]);input.remove();};
document.body.append(input);input.click();
}
function accept(files){
if(readonly)return;
const usable=files.filter(file=>file&&(file.type.startsWith('image/')||/\.(jpe?g|png|webp|heic|heif)$/i.test(file.name||'')));
if(!usable.length)return;
const room=MAX_IMAGES-items.length;
if(room<=0){config.onError?.(`每条记录最多 ${MAX_IMAGES} 张图片`);return;}
if(usable.length>room)config.onError?.(`最多还能添加 ${room} 张,多余的已忽略`);
usable.slice(0,room).forEach(file=>{
const item={key:`u${++serial}`,status:'uploading',progress:0,name:file.name||'照片',original_name:file.name||'照片',file};
items.push(item);
// 串行处理:多张大图并行解码会在手机上直接把页面挤爆内存
queue=queue.then(()=>process(item)).catch(()=>{});
});
render();
}
async function process(item){
if(!items.includes(item))return;
try{
const prepared=await prepare(item.file);
if(!items.includes(item))return;
item.localUrl=URL.createObjectURL(prepared.thumb||prepared.blob);
render();
const report=fraction=>{item.progress=fraction;const bar=container?.querySelector(`[data-key="${item.key}"] .image-card-progress i`);if(bar)bar.style.width=`${Math.round(fraction*100)}%`;};
let data;
try{data=await send(prepared,report,item);}
catch(error){
// 录入授权过期时就地重新验证再传一次,别让用户为此刷新页面丢掉整张表单。
if(!error.unauthorized||!config.onUnauthorized||!await config.onUnauthorized())throw error;
data=await send(prepared,report,item);
}
if(!items.includes(item)){return;}
Object.assign(item,data,{status:'ready',file:null});
if(item.localUrl){URL.revokeObjectURL(item.localUrl);item.localUrl=null;}
autoFillFilename(data);
}catch(error){
if(!items.includes(item))return;
item.status='error';item.message=error.message;
config.onError?.(error.message);
}finally{render();}
}
function autoFillFilename(data){
const field=document.querySelector('input[name="finished_image_filename"]');
if(!field||field.value.trim())return;
const original=(data.original_name||'').trim();
if(original&&!GENERIC_NAMES.has(original.toLowerCase())){field.value=original.slice(0,255);return;}
const experiment=document.querySelector('input[name="experiment_id"]')?.value.trim();
if(experiment)field.value=`${experiment}.jpg`.slice(0,255);
}
function mount(root,options={}){
container=root;config={...config,...options};items=[];readonly=Boolean(options.readonly);loadedExisting=false;
if(container)render();
}
function load(images=[]){
items=images.map((image,position)=>({...image,key:`s${image.id??position}`,status:'ready'}));
loadedExisting=true;render();
}
function reset(){items.forEach(item=>{if(item.localUrl)URL.revokeObjectURL(item.localUrl);item.xhr?.abort();});items=[];loadedExisting=false;render();}
function setReadonly(value){readonly=Boolean(value);render();}
function collect(){
const ready=items.filter(item=>item.status==='ready');
// 没载入过既有图片就回 null,让后端保持原样,避免新建表单的空列表被当成"删光全部图片"。
return {image_ids:loadedExisting?ready.filter(item=>item.id&&!item.token).map(item=>item.id):null,image_tokens:ready.filter(item=>item.token).map(item=>item.token)};
}
function pending(){return items.some(item=>item.status==='uploading');}
window.ImageUpload={mount,load,reset,setReadonly,collect,pending,MAX_IMAGES};
})();
+80
View File
@@ -0,0 +1,80 @@
(function(){
// 必须自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
const escapeHtml=value=>String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
let overlay=null,items=[],index=0,restoreFocus=null,touchStartX=null;
function build(){
overlay=document.createElement('div');overlay.className='image-viewer';overlay.hidden=true;
overlay.setAttribute('role','dialog');overlay.setAttribute('aria-modal','true');overlay.setAttribute('aria-label','图片预览');
overlay.innerHTML='<div class="viewer-backdrop" data-viewer-close></div><figure class="viewer-stage"><img alt=""><p class="viewer-state" hidden></p><figcaption></figcaption></figure><button class="viewer-close" data-viewer-close aria-label="关闭预览">×</button><button class="viewer-nav prev" aria-label="上一张"></button><button class="viewer-nav next" aria-label="下一张"></button><span class="viewer-counter"></span>';
document.body.append(overlay);
overlay.querySelectorAll('[data-viewer-close]').forEach(node=>node.onclick=close);
overlay.querySelector('.viewer-nav.prev').onclick=()=>step(-1);
overlay.querySelector('.viewer-nav.next').onclick=()=>step(1);
overlay.addEventListener('touchstart',event=>{touchStartX=event.changedTouches[0].clientX;},{passive:true});
overlay.addEventListener('touchend',event=>{if(touchStartX===null)return;const delta=event.changedTouches[0].clientX-touchStartX;touchStartX=null;if(Math.abs(delta)>60)step(delta<0?1:-1);},{passive:true});
document.addEventListener('keydown',event=>{
if(overlay.hidden)return;
if(event.key==='Escape'){event.preventDefault();close();}
else if(event.key==='ArrowLeft')step(-1);
else if(event.key==='ArrowRight')step(1);
else if(event.key==='Tab'){
// aria-modal 要求焦点留在对话框内,否则 Tab 会走到被遮罩盖住的背景控件上。
const focusable=[...overlay.querySelectorAll('button')].filter(node=>!node.hidden&&node.offsetParent!==null);
if(!focusable.length)return;
const first=focusable[0],last=focusable[focusable.length-1];
if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}
else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}
else if(!overlay.contains(document.activeElement)){event.preventDefault();first.focus();}
}
});
}
function show(){
const item=items[index]||{},image=overlay.querySelector('img'),state=overlay.querySelector('.viewer-state');
image.removeAttribute('src'); // 置空字符串会让部分浏览器重新请求当前页面地址
image.alt=item.original_name?`成品图:${item.original_name}`:'实验成品图';
image.hidden=true;state.hidden=false;state.textContent='正在载入…';
image.onload=()=>{image.hidden=false;state.hidden=true;};
image.onerror=()=>{image.hidden=true;state.hidden=false;state.textContent='图片加载失败,可能已被删除';};
if(item.url)image.src=item.url;else image.onerror();
overlay.querySelector('figcaption').textContent=[item.original_name,item.width&&item.height?`${item.width}×${item.height}`:''].filter(Boolean).join(' · ');
overlay.querySelector('.viewer-counter').textContent=items.length>1?`${index+1} / ${items.length}`:'';
overlay.querySelectorAll('.viewer-nav').forEach(node=>node.hidden=items.length<2);
}
function step(direction){if(items.length<2)return;index=(index+direction+items.length)%items.length;show();}
function open(list,start=0){
if(!list?.length)return;
if(!overlay)build();
items=list;index=Math.max(0,Math.min(start,list.length-1));
restoreFocus=document.activeElement;
document.documentElement.classList.add('viewer-open');
overlay.hidden=false;show();
overlay.querySelector('.viewer-close').focus();
}
function close(){
if(!overlay||overlay.hidden)return;
overlay.hidden=true;overlay.querySelector('img').removeAttribute('src');
document.documentElement.classList.remove('viewer-open');
if(restoreFocus?.focus)restoreFocus.focus();
restoreFocus=null;
}
function galleryHtml(images,emptyText='本条记录暂未上传图片'){
if(!images?.length)return `<div class="gallery-empty">${escapeHtml(emptyText)}</div>`;
// 不用 loading="lazy":缩略图是在隐藏的详情弹窗里解析的,惰性加载会一直不触发;单张只有几 KB,最多 6 张。
return `<div class="image-gallery">${images.map((item,position)=>`<button type="button" class="gallery-thumb" data-viewer-index="${position}" aria-label="查看第 ${position+1} 张图片"><img src="${escapeHtml(item.thumb_url||item.url)}" alt="${escapeHtml(item.original_name||`成品图 ${position+1}`)}"><span class="gallery-fallback" hidden>图片加载失败</span></button>`).join('')}</div>`;
}
function wire(root,images){
if(!root||!images?.length)return;
root.querySelectorAll('[data-viewer-index]').forEach(button=>{
button.onclick=()=>open(images,Number(button.dataset.viewerIndex));
const image=button.querySelector('img');
if(image)image.onerror=()=>{image.hidden=true;button.querySelector('.gallery-fallback').hidden=false;};
});
}
function render(root,images,emptyText){if(!root)return;root.innerHTML=galleryHtml(images,emptyText);wire(root,images);}
window.ImageViewer={open,close,galleryHtml,wire,render};
})();
+4
View File
@@ -0,0 +1,4 @@
.public-body{min-height:100vh;background:#f3f6f6}.public-nav{height:70px;padding:0 max(24px,calc((100vw - 1320px)/2));background:#142c36;color:#fff;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:30}.public-brand{display:flex;align-items:center;gap:11px;color:#fff;text-decoration:none}.public-brand .brand-mark{width:34px;height:34px;border-radius:9px;font-size:15px}.public-brand>span:last-child{display:grid}.public-brand b{font-size:12px;letter-spacing:.1em}.public-brand small{font-size:9px;color:#7f999f}.public-nav nav{display:flex;align-items:stretch;height:100%}.public-nav nav a{display:flex;align-items:center;padding:0 17px;color:#9fb1b6;text-decoration:none;font-size:11px;border-bottom:2px solid transparent}.public-nav nav a:hover,.public-nav nav a.active{color:#fff;border-color:#ef6b4d}.public-main,.entry-main{width:min(1320px,calc(100% - 48px));margin:0 auto;padding:44px 0 70px}.data-heading,.entry-heading{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:28px}.data-heading h1,.entry-heading h1{font-size:31px;margin:0 0 7px}.data-heading>div>p:last-child,.entry-heading>div>p:last-child{color:#718087;font-size:12px;margin:0}.public-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}.public-metrics article{background:#fff;border-radius:10px;padding:20px;display:flex;align-items:center;gap:14px;border:1px solid #e7ecee}.public-metrics article>span{width:42px;height:42px;border-radius:9px;background:#eaf2f1;color:#3f7477;display:grid;place-items:center;font-size:9px;font-weight:800}.public-metrics article:nth-child(2)>span{background:#fff0ed;color:#d35d43}.public-metrics article:nth-child(3)>span{background:#eaf4fb;color:#397ca9}.public-metrics article:nth-child(4)>span{background:#fff7e7;color:#b47b27}.public-metrics article div{display:grid}.public-metrics small{font-size:9px;color:#829095}.public-metrics strong{font-size:24px;margin-top:2px}.public-table-panel{background:#fff;border:1px solid #e5ebed;border-radius:12px;padding:24px}.public-panel-head{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:19px}.public-panel-head h2{font-size:18px;margin:0}.public-filters{display:flex;gap:8px}.public-filters input,.public-filters select{border:1px solid #dfe6e8;background:#fbfcfc;border-radius:8px;padding:9px 11px;font-size:10px;min-width:180px}.public-filters .primary{padding:9px 16px}.public-table-panel tbody tr{cursor:pointer}.public-table-panel tbody tr:hover{background:#fafcfc}.detail-card{width:min(780px,100%);max-height:86vh;overflow:auto}.detail-title{border-bottom:1px solid #e7ecee;padding-bottom:15px;margin-bottom:15px}.detail-title h2{margin:0 0 4px}.detail-title p{margin:0;color:#718087;font-size:11px}.detail-groups{display:grid;gap:18px}.detail-group h3{font-size:11px;color:#ef6b4d;text-transform:uppercase;letter-spacing:.1em;margin:0 0 9px}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:#e7ecee;border:1px solid #e7ecee}.detail-grid div{background:#fff;padding:10px;display:grid;gap:3px}.detail-grid small{font-size:8px;color:#829095}.detail-grid b{font-size:11px;font-weight:600}.key-gate{width:min(430px,calc(100% - 32px));margin:10vh auto 0;background:#fff;border:1px solid #e3e9eb;border-radius:14px;padding:38px;text-align:center;box-shadow:0 18px 50px rgba(20,44,54,.08)}.key-symbol{margin:0 auto 24px;width:64px;height:64px;border-radius:16px;background:#142c36;color:#ef8065;display:grid;place-items:center;letter-spacing:.18em;font-weight:800}.key-gate h1{font-size:23px;margin:0 0 8px}.key-gate>p:not(.eyebrow){font-size:11px;color:#718087}.key-gate form{display:grid;gap:12px;margin-top:25px}.key-gate input{width:100%;border:1px solid #dfe6e8;border-radius:10px;padding:13px;text-align:center;font-size:25px;letter-spacing:.55em;text-indent:.55em;outline:none}.key-gate input:focus{border-color:#ef6b4d;box-shadow:0 0 0 3px rgba(239,107,77,.1)}.entry-main{width:min(1120px,calc(100% - 48px))}.entry-body .form-section{border:1px solid #e5ebed;box-shadow:none}.entry-body .modal form{display:grid;gap:13px}.entry-body .modal label{display:grid;gap:7px;font-size:11px;font-weight:700}.entry-body .modal input{border:1px solid #dfe6e8;border-radius:8px;padding:10px 12px}.entry-body .form-error{margin:0}
@media(max-width:900px){.public-main,.entry-main{width:calc(100% - 32px);padding-top:28px}.public-metrics{grid-template-columns:1fr 1fr}.public-panel-head{align-items:stretch;display:grid;gap:15px}.public-filters{display:grid;grid-template-columns:1fr 1fr}.public-filters input{grid-column:1/-1}.data-heading,.entry-heading{align-items:flex-start}.detail-grid{grid-template-columns:1fr 1fr}}
@media(max-width:560px){.public-nav{padding:0 14px;height:62px}.public-brand small{display:none}.public-nav nav a{padding:0 9px;font-size:9px}.public-main,.entry-main{width:calc(100% - 24px);padding-top:22px}.data-heading{display:grid;gap:17px}.data-heading h1,.entry-heading h1{font-size:24px}.public-metrics{grid-template-columns:1fr 1fr;gap:8px}.public-metrics article{padding:13px;gap:9px}.public-metrics article>span{width:34px;height:34px}.public-metrics strong{font-size:20px}.public-table-panel{padding:15px}.public-filters{grid-template-columns:1fr}.public-filters input,.public-filters select{grid-column:auto;min-width:0}.detail-grid{grid-template-columns:1fr}.key-gate{padding:27px 20px}.entry-heading{align-items:flex-start;gap:12px}}
.history-badge{font-size:7px;color:#b76926;background:#fff7e7;border-radius:12px;padding:2px 5px;width:max-content}
+42
View File
@@ -0,0 +1,42 @@
const $ = (selector, root=document) => root.querySelector(selector);
// 自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
const escapeHtml = value => String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
let currentPage=1;
async function getJson(url) {
const response=await fetch(url); const data=await response.json();
if(!response.ok) throw new Error(data.message||'数据加载失败'); return data;
}
async function loadPublicData(page=1) {
currentPage=page; const body=$('#publicRows'); body.innerHTML='<tr><td colspan="10" class="loading">正在加载数据…</td></tr>';
const q=encodeURIComponent($('#publicSearch').value.trim()), category=encodeURIComponent($('#publicCategory').value);
try {
const result=await getJson(`/api/public/dashboard?page=${page}&page_size=20&q=${q}&category=${category}`);
$('#statTotal').textContent=result.stats.total; $('#statCategories').textContent=result.stats.category_count;
$('#statRecent').textContent=result.stats.recent_count; $('#statClarity').textContent=result.stats.average_clarity??'—';
const select=$('#publicCategory'), selected=select.value;
select.innerHTML='<option value="">全部材料分类</option>'+result.categories.map(x=>`<option value="${escapeHtml(x)}">${escapeHtml(x)}</option>`).join(''); select.value=selected;
body.innerHTML=result.data.length?result.data.map(row=>`<tr data-id="${row.id}"><td><b>${escapeHtml(row.experiment_id)}</b></td><td>${row.test_date}</td><td>${escapeHtml(row.material_category)}</td><td>${escapeHtml(row.material_name)}</td><td>${row.material_thickness} mm</td><td>${row.actual_output_power} W</td><td>${row.scanning_speed} mm/s</td><td><span class="tag ${row.is_cut_through?'':'off'}">${row.is_cut_through?'是':'否'}</span></td><td>${row.pattern_clarity_score}</td><td><button class="table-action">详情</button></td></tr>`).join(''):'<tr><td colspan="10" class="empty">未找到符合条件的数据</td></tr>';
body.querySelectorAll('tr[data-id]').forEach(row=>row.onclick=()=>showDetail(row.dataset.id));
const pages=Math.max(1,Math.ceil(result.pagination.total/result.pagination.page_size));
$('#publicPager').innerHTML=`<span>第 ${page} / ${pages} 页,共 ${result.pagination.total} 条</span><button ${page<=1?'disabled':''} data-page="${page-1}">上一页</button><button ${page>=pages?'disabled':''} data-page="${page+1}">下一页</button>`;
$('#publicPager').querySelectorAll('button:not([disabled])').forEach(button=>button.onclick=()=>loadPublicData(Number(button.dataset.page)));
} catch(error) { body.innerHTML=`<tr><td colspan="10" class="empty">${escapeHtml(error.message)}</td></tr>`; }
}
const labels={
apparent_density:'表观密度 (g/cm³)',uv_absorption_355nm:'355nm 紫外吸收率 (%)',material_thickness:'材料厚度 (mm)',moisture_content:'含水率 (%)',thermal_conductivity:'热导率 (W/(m·K))',initial_decomposition_temp:'初始分解温度 (℃)',melting_vaporization_temp:'熔点 / 气化温度 (℃)',specific_heat_capacity:'比热容 (J/(kg·K))',carbon_residue_rate:'残炭率 (%)',surface_roughness:'表面粗糙度 (μm)',hardness:'硬度 (邵氏/HV)',actual_output_power:'实际输出功率 (W)',display_current:'设备显示电流 (A)',scanning_speed:'扫描速度 (mm/s)',pulse_frequency:'脉冲频率 (kHz)',pulse_width:'脉冲脉宽 (μs)',defocus_amount:'离焦量 (mm)',scan_line_spacing:'扫描线间距 (mm)',filling_method:'填充方式',processing_size:'加工尺寸',is_cut_through:'是否切透',carbonized_edge_width:'碳化边宽度 (mm)',etching_depth:'蚀刻深度 (μm)',is_fire_smolder:'是否起火 / 阴燃',pattern_clarity_score:'图案清晰度评分',presentation_balance_score:'呈现均衡性评分'
};
const physical=['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'];
const laser=['actual_output_power','display_current','scanning_speed','pulse_frequency','pulse_width','defocus_amount','scan_line_spacing','filling_method','processing_size'];
const resultFields=['is_cut_through','carbonized_edge_width','etching_depth','is_fire_smolder','pattern_clarity_score','presentation_balance_score'];
function renderGroup(title,fields,data){return `<section class="detail-group"><h3>${title}</h3><div class="detail-grid">${fields.map(key=>{let value=data[key];if(typeof value==='boolean')value=value?'是':'否';if(value===null||value==='')value='未填写';return `<div><small>${labels[key]}</small><b>${escapeHtml(value)}</b></div>`}).join('')}</div></section>`;}
function renderCustomGroup(values){if(!values?.length)return '';return `<section class="detail-group"><h3>扩展数据</h3><div class="detail-grid">${values.map(item=>`<div><small>${item.is_active?'':`历史字段 · `}${escapeHtml(item.label)}</small><b>${escapeHtml(item.display_value||item.raw_value||'未填写')}</b>${item.compatible===false?'<span class="history-badge">历史格式</span>':''}</div>`).join('')}</div></section>`;}
function renderImageGroup(images){return `<section class="detail-group"><h3>成品图片</h3><div class="detail-gallery">${ImageViewer.galleryHtml(images)}</div></section>`;}
async function showDetail(id){
try{const {data}=await getJson(`/api/public/records/${id}`);$('#publicModalContent').innerHTML=`<div class="detail-title"><p class="eyebrow">EXPERIMENT DETAIL</p><h2>${escapeHtml(data.experiment_id)}</h2><p>${escapeHtml(data.test_date)} · ${escapeHtml(data.material_category)} · ${escapeHtml(data.material_name)}</p></div><div class="detail-groups">${renderImageGroup(data.images)}${renderGroup('材料物理特性',physical,data)}${renderGroup('激光雕刻参数',laser,data)}${renderGroup('实验结果',resultFields,data)}${renderCustomGroup(data.custom_fields)}</div>`;ImageViewer.wire($('#publicModalContent'),data.images);$('#publicModal').hidden=false;}catch(error){alert(error.message);}
}
document.querySelectorAll('[data-close]').forEach(node=>node.onclick=()=>$('#publicModal').hidden=true);
$('#publicSearchBtn').onclick=()=>loadPublicData(1); $('#publicSearch').addEventListener('keydown',event=>{if(event.key==='Enter')loadPublicData(1)}); $('#publicCategory').onchange=()=>loadPublicData(1);
loadPublicData();