Compare commits
2 Commits
be054e70ea
...
cf57f981c0
| Author | SHA1 | Date | |
|---|---|---|---|
| cf57f981c0 | |||
| 30999e1de4 |
@@ -5,30 +5,25 @@ from .crypto import salt_for_username, derive_password
|
||||
|
||||
def get_user_by_username(username: str):
|
||||
"""
|
||||
从Elasticsearch获取用户数据
|
||||
从Elasticsearch获取用户数据;若不存在则回退到内置admin。
|
||||
期望ES中存储的是明文密码,登录时按用户名盐派生后对nonce做HMAC验证。
|
||||
"""
|
||||
# 首先尝试从ES获取用户数据
|
||||
# es_user = es_get_user_by_username(username)
|
||||
# if es_user:
|
||||
# salt = salt_for_username(username)
|
||||
# derived = derive_password(es_user.get('password', ''), salt)
|
||||
# # 如果ES中有用户数据,使用ES中的密码
|
||||
# return {
|
||||
# 'user_id': es_user.get('user_id', 0),
|
||||
# 'username': es_user.get('username', ''),
|
||||
# 'password': base64.b64encode(derived).decode('ascii'),
|
||||
# 'permission': es_user.get('permission', 1),
|
||||
# }
|
||||
es_user = es_get_user_by_username(username)
|
||||
if es_user:
|
||||
salt = salt_for_username(username)
|
||||
derived = derive_password(es_user.get('password', ''), salt)
|
||||
return {
|
||||
'user_id': es_user.get('user_id', 0),
|
||||
'username': es_user.get('username', ''),
|
||||
'password': base64.b64encode(derived).decode('ascii'),
|
||||
'permission': es_user.get('permission', 1),
|
||||
}
|
||||
|
||||
salt = salt_for_username('admin')
|
||||
derived = derive_password('admin', salt)
|
||||
|
||||
return {
|
||||
'user_id': 0,
|
||||
'username': 'admin',
|
||||
'password': base64.b64encode(derived).decode('ascii'),
|
||||
'permission': 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
return None
|
||||
}
|
||||
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