新增“数据编辑”
This commit is contained in:
3
elastic/indexes.py
Normal file
3
elastic/indexes.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
INDEX_NAME = "wordsearch266666"
|
||||||
|
ACHIEVEMENT_INDEX_NAME = INDEX_NAME
|
||||||
|
USER_INDEX_NAME = INDEX_NAME
|
||||||
176
elastic/templates/elastic/manage.html
Normal file
176
elastic/templates/elastic/manage.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>数据管理</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#fafafa; }
|
||||||
|
.container { max-width: 1100px; margin: 6vh auto; background:#fff; border-radius:10px; box-shadow:0 6px 18px rgba(0,0,0,0.06); padding:20px; }
|
||||||
|
table { width:100%; border-collapse: collapse; }
|
||||||
|
th, td { border-bottom:1px solid #eee; padding:8px; text-align:left; vertical-align: top; }
|
||||||
|
img { max-width: 120px; border:1px solid #eee; border-radius:6px; }
|
||||||
|
.btn { padding:6px 10px; border:none; border-radius:6px; cursor:pointer; }
|
||||||
|
.btn-primary { background:#1677ff; color:#fff; }
|
||||||
|
.btn-danger { background:#ff4d4f; color:#fff; }
|
||||||
|
.btn-secondary { background:#f0f0f0; }
|
||||||
|
.muted { color:#666; font-size:12px; }
|
||||||
|
.modal { position: fixed; inset: 0; display: none; background: rgba(0,0,0,0.4); align-items: center; justify-content: center; }
|
||||||
|
.modal .dialog { width: 720px; max-width: 92vw; background:#fff; border-radius:10px; padding:16px; }
|
||||||
|
textarea { width:100%; min-height: 240px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:14px; }
|
||||||
|
#kvForm { border:1px solid #eee; border-radius:6px; padding:8px; max-height:300px; overflow:auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>数据管理</h2>
|
||||||
|
<p class="muted">仅管理员可见。可查看、编辑、删除所有记录。</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>图片</th>
|
||||||
|
<th>数据</th>
|
||||||
|
<th>作者</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr data-id="{{ it._id }}" data-writer="{{ it.writer_id }}" data-image="{{ it.image }}">
|
||||||
|
<td style="max-width:140px; word-break:break-all;">{{ it._id }}</td>
|
||||||
|
<td>
|
||||||
|
{% if it.image %}
|
||||||
|
<img src="/media/{{ it.image }}" onerror="this.src='';" />
|
||||||
|
<div class="muted">/media/{{ it.image }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<pre style="white-space:pre-wrap; word-wrap:break-word;">{{ it.data|safe }}</pre>
|
||||||
|
</td>
|
||||||
|
<td>{{ it.writer_id }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-primary" onclick="openEdit('{{ it._id }}')">编辑</button>
|
||||||
|
<button class="btn btn-danger" onclick="doDelete('{{ it._id }}')">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div id="modal" class="modal">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>编辑</h3>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px;">
|
||||||
|
<button id="addFieldBtn" class="btn btn-secondary" type="button">添加字段</button>
|
||||||
|
<button id="syncFromTextBtn" class="btn btn-secondary" type="button">从文本区刷新表单</button>
|
||||||
|
<span id="editMsg" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<div id="kvForm"></div>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<textarea id="resultBox" placeholder="JSON"></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px; display:flex; gap:8px;">
|
||||||
|
<button class="btn btn-primary" onclick="saveEdit()">保存</button>
|
||||||
|
<button class="btn" onclick="closeModal()">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
const kvForm = document.getElementById('kvForm');
|
||||||
|
const resultBox = document.getElementById('resultBox');
|
||||||
|
const editMsg = document.getElementById('editMsg');
|
||||||
|
const addFieldBtn = document.getElementById('addFieldBtn');
|
||||||
|
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||||
|
|
||||||
|
let currentId = '';
|
||||||
|
let currentWriter = '';
|
||||||
|
let currentImage = '';
|
||||||
|
|
||||||
|
function createRow(k = '', v = '') {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.display = 'grid';
|
||||||
|
row.style.gridTemplateColumns = '1fr 1fr auto';
|
||||||
|
row.style.gap = '8px';
|
||||||
|
row.style.marginBottom = '6px';
|
||||||
|
const kI = document.createElement('input'); kI.type='text'; kI.placeholder='字段名'; kI.value=k;
|
||||||
|
const vI = document.createElement('input'); vI.type='text'; vI.placeholder='字段值'; vI.value = typeof v==='object'? JSON.stringify(v): (v??'');
|
||||||
|
const del = document.createElement('button'); del.type='button'; del.className='btn'; del.textContent='删除'; del.onclick=()=>{ kvForm.removeChild(row); syncTextarea(); };
|
||||||
|
kI.oninput = syncTextarea; vI.oninput = syncTextarea;
|
||||||
|
row.appendChild(kI); row.appendChild(vI); row.appendChild(del);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm(obj){
|
||||||
|
kvForm.innerHTML='';
|
||||||
|
Object.keys(obj||{}).forEach(k=> kvForm.appendChild(createRow(k, obj[k])));
|
||||||
|
if (!kvForm.children.length) kvForm.appendChild(createRow());
|
||||||
|
syncTextarea();
|
||||||
|
}
|
||||||
|
function formToObject(){
|
||||||
|
const o={};
|
||||||
|
Array.from(kvForm.children).forEach(row=>{
|
||||||
|
const [kI,vI] = row.querySelectorAll('input');
|
||||||
|
const k=(kI.value||'').trim(); if(!k) return;
|
||||||
|
const raw=vI.value; try{ o[k]=JSON.parse(raw);}catch(_){ o[k]=raw; }
|
||||||
|
});
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
function syncTextarea(){ resultBox.value = JSON.stringify(formToObject(), null, 2); }
|
||||||
|
|
||||||
|
addFieldBtn.onclick = ()=>{ kvForm.appendChild(createRow()); syncTextarea(); };
|
||||||
|
syncFromTextBtn.onclick = ()=>{ try{ renderForm(JSON.parse(resultBox.value||'{}')); }catch(e){ editMsg.textContent='JSON无效'; } };
|
||||||
|
|
||||||
|
function openEdit(id){
|
||||||
|
const tr = document.querySelector(`tr[data-id="${id}"]`);
|
||||||
|
currentId = id;
|
||||||
|
currentWriter = tr?.getAttribute('data-writer') || '';
|
||||||
|
currentImage = tr?.getAttribute('data-image') || '';
|
||||||
|
fetch(`/elastic/data/${id}/`, { credentials:'same-origin' })
|
||||||
|
.then(r=>r.json()).then(d=>{
|
||||||
|
if(d.status!=='success') throw new Error('获取失败');
|
||||||
|
const rec=d.data||{};
|
||||||
|
const dataStr = rec.data || '{}';
|
||||||
|
let obj={}; try{ obj = typeof dataStr==='string'? JSON.parse(dataStr): (dataStr||{});}catch(_){ obj={}; }
|
||||||
|
renderForm(obj);
|
||||||
|
modal.style.display='flex';
|
||||||
|
}).catch(e=>{ alert(e.message||'发生错误'); });
|
||||||
|
}
|
||||||
|
function closeModal(){ modal.style.display='none'; currentId=''; }
|
||||||
|
function saveEdit(){
|
||||||
|
const body = {
|
||||||
|
writer_id: currentWriter,
|
||||||
|
data: JSON.stringify(formToObject()),
|
||||||
|
image: currentImage,
|
||||||
|
};
|
||||||
|
fetch(`/elastic/data/${currentId}/update/`, {
|
||||||
|
method:'PUT', credentials:'same-origin',
|
||||||
|
headers:{ 'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken')||'' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then(r=>r.json()).then(d=>{
|
||||||
|
if(d.status!=='success') throw new Error('保存失败');
|
||||||
|
location.reload();
|
||||||
|
}).catch(e=>{ editMsg.textContent = e.message||'发生错误'; });
|
||||||
|
}
|
||||||
|
function doDelete(id){
|
||||||
|
if(!confirm('确认删除该记录?')) return;
|
||||||
|
fetch(`/elastic/data/${id}/delete/`, {
|
||||||
|
method:'DELETE', credentials:'same-origin',
|
||||||
|
headers:{ 'X-CSRFToken': getCookie('csrftoken')||'' }
|
||||||
|
}).then(r=>r.json()).then(d=>{
|
||||||
|
if(d.status!=='success') throw new Error('删除失败');
|
||||||
|
location.reload();
|
||||||
|
}).catch(e=> alert(e.message||'发生错误'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
228
elastic/templates/elastic/upload.html
Normal file
228
elastic/templates/elastic/upload.html
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>图片上传与识别</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #fafafa; }
|
||||||
|
.container { max-width: 900px; margin: 6vh auto; background: #fff; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,0.06); padding: 24px; }
|
||||||
|
.row { display: flex; gap: 16px; }
|
||||||
|
.col { flex: 1; }
|
||||||
|
textarea { width: 100%; min-height: 260px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; }
|
||||||
|
img { max-width: 100%; border: 1px solid #eee; border-radius: 6px; }
|
||||||
|
.btn { padding: 8px 12px; border: none; border-radius: 6px; cursor: pointer; }
|
||||||
|
.btn-primary { background: #1677ff; color: #fff; }
|
||||||
|
.btn-secondary { background: #f0f0f0; }
|
||||||
|
.muted { color: #666; font-size: 12px; }
|
||||||
|
.error { color: #d14343; }
|
||||||
|
.success { color: #179957; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>图片上传与识别</h2>
|
||||||
|
<p class="muted">选择图片后上传,服务端调用大模型解析为可编辑的 JSON,再确认入库。</p>
|
||||||
|
|
||||||
|
<form id="uploadForm" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="file" id="fileInput" name="file" accept="image/*" />
|
||||||
|
<button type="submit" class="btn btn-primary">上传并识别</button>
|
||||||
|
<span id="uploadMsg" class="muted"></span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:16px;">
|
||||||
|
<div class="col">
|
||||||
|
<h4>图片预览</h4>
|
||||||
|
<img id="preview" alt="预览" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<h4>识别结果(可编辑)</h4>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px;">
|
||||||
|
<button id="addFieldBtn" class="btn btn-secondary" type="button">添加字段</button>
|
||||||
|
<button id="syncFromTextBtn" class="btn btn-secondary" type="button">从文本区刷新表单</button>
|
||||||
|
</div>
|
||||||
|
<div id="kvForm" style="border:1px solid #eee; border-radius:6px; padding:8px; max-height:300px; overflow:auto;"></div>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<textarea id="resultBox" placeholder="识别结果JSON将显示在这里"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;">
|
||||||
|
<button id="confirmBtn" class="btn btn-primary" disabled>确认并入库</button>
|
||||||
|
<button id="clearBtn" class="btn btn-secondary" type="button">清空</button>
|
||||||
|
<span id="confirmMsg" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadForm = document.getElementById('uploadForm');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
const resultBox = document.getElementById('resultBox');
|
||||||
|
const uploadMsg = document.getElementById('uploadMsg');
|
||||||
|
const confirmBtn = document.getElementById('confirmBtn');
|
||||||
|
const clearBtn = document.getElementById('clearBtn');
|
||||||
|
const confirmMsg = document.getElementById('confirmMsg');
|
||||||
|
const kvForm = document.getElementById('kvForm');
|
||||||
|
const addFieldBtn = document.getElementById('addFieldBtn');
|
||||||
|
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||||
|
|
||||||
|
let currentImageRel = '';
|
||||||
|
|
||||||
|
function createRow(k = '', v = '') {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.display = 'grid';
|
||||||
|
row.style.gridTemplateColumns = '1fr 1fr auto';
|
||||||
|
row.style.gap = '8px';
|
||||||
|
row.style.marginBottom = '6px';
|
||||||
|
const keyInput = document.createElement('input');
|
||||||
|
keyInput.type = 'text';
|
||||||
|
keyInput.placeholder = '字段名';
|
||||||
|
keyInput.value = k;
|
||||||
|
const valInput = document.createElement('input');
|
||||||
|
valInput.type = 'text';
|
||||||
|
valInput.placeholder = '字段值';
|
||||||
|
valInput.value = typeof v === 'object' ? JSON.stringify(v) : (v ?? '');
|
||||||
|
const delBtn = document.createElement('button');
|
||||||
|
delBtn.type = 'button';
|
||||||
|
delBtn.className = 'btn btn-secondary';
|
||||||
|
delBtn.textContent = '删除';
|
||||||
|
delBtn.onclick = () => { kvForm.removeChild(row); syncTextarea(); };
|
||||||
|
keyInput.oninput = syncTextarea;
|
||||||
|
valInput.oninput = syncTextarea;
|
||||||
|
row.appendChild(keyInput);
|
||||||
|
row.appendChild(valInput);
|
||||||
|
row.appendChild(delBtn);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFormFromObject(obj) {
|
||||||
|
kvForm.innerHTML = '';
|
||||||
|
Object.keys(obj || {}).forEach(k => {
|
||||||
|
kvForm.appendChild(createRow(k, obj[k]));
|
||||||
|
});
|
||||||
|
if (!kvForm.children.length) kvForm.appendChild(createRow());
|
||||||
|
syncTextarea();
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectFromForm() {
|
||||||
|
const obj = {};
|
||||||
|
Array.from(kvForm.children).forEach(row => {
|
||||||
|
const [kInput, vInput] = row.querySelectorAll('input');
|
||||||
|
const k = (kInput.value || '').trim();
|
||||||
|
if (!k) return;
|
||||||
|
const raw = vInput.value;
|
||||||
|
try {
|
||||||
|
obj[k] = JSON.parse(raw);
|
||||||
|
} catch (_) {
|
||||||
|
obj[k] = raw;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTextarea() {
|
||||||
|
const obj = objectFromForm();
|
||||||
|
resultBox.value = JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
addFieldBtn.addEventListener('click', () => {
|
||||||
|
kvForm.appendChild(createRow());
|
||||||
|
syncTextarea();
|
||||||
|
});
|
||||||
|
|
||||||
|
syncFromTextBtn.addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(resultBox.value || '{}');
|
||||||
|
renderFormFromObject(obj);
|
||||||
|
} catch (e) {
|
||||||
|
uploadMsg.textContent = '文本区不是有效JSON';
|
||||||
|
uploadMsg.className = 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadMsg.textContent = '';
|
||||||
|
confirmMsg.textContent = '';
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
resultBox.value = '';
|
||||||
|
currentImageRel = '';
|
||||||
|
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
uploadMsg.textContent = '请选择图片文件';
|
||||||
|
uploadMsg.className = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/elastic/upload/', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': getCookie('csrftoken') || '' },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.status !== 'success') {
|
||||||
|
throw new Error(data.message || '上传识别失败');
|
||||||
|
}
|
||||||
|
uploadMsg.textContent = data.message || '识别成功';
|
||||||
|
uploadMsg.className = 'success';
|
||||||
|
preview.src = data.image_url;
|
||||||
|
renderFormFromObject(data.data || {});
|
||||||
|
currentImageRel = data.image;
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
} catch (e) {
|
||||||
|
uploadMsg.textContent = e.message || '发生错误';
|
||||||
|
uploadMsg.className = 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', async () => {
|
||||||
|
confirmMsg.textContent = '';
|
||||||
|
try {
|
||||||
|
const edited = objectFromForm();
|
||||||
|
const resp = await fetch('/elastic/confirm/', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken') || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ data: edited, image: currentImageRel })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.status !== 'success') {
|
||||||
|
throw new Error(data.message || '录入失败');
|
||||||
|
}
|
||||||
|
confirmMsg.textContent = data.message || '录入成功';
|
||||||
|
confirmMsg.className = 'success';
|
||||||
|
} catch (e) {
|
||||||
|
confirmMsg.textContent = e.message || '发生错误';
|
||||||
|
confirmMsg.className = 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
fileInput.value = '';
|
||||||
|
preview.src = '';
|
||||||
|
resultBox.value = '';
|
||||||
|
kvForm.innerHTML = '';
|
||||||
|
uploadMsg.textContent = '';
|
||||||
|
confirmMsg.textContent = '';
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user