公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
85 lines
20 KiB
JavaScript
85 lines
20 KiB
JavaScript
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 解析器会先把 ' 解回单引号),那种地方一律改用 data-* + 监听器。
|
||
function escapeHtml(value) { return String(value??'').replace(/&/g,'&').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=>`<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');
|