Compare commits

...

2 Commits

Author SHA1 Message Date
cf57f981c0 新增“数据编辑” 2025-11-13 17:06:10 +08:00
30999e1de4 新增“数据编辑” 2025-11-13 17:06:01 +08:00
4 changed files with 421 additions and 19 deletions

View File

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

3
elastic/indexes.py Normal file
View File

@@ -0,0 +1,3 @@
INDEX_NAME = "wordsearch266666"
ACHIEVEMENT_INDEX_NAME = INDEX_NAME
USER_INDEX_NAME = INDEX_NAME

View 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>

View 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>