@@ -4,27 +4,191 @@
< meta charset = "UTF-8" / >
< title > 数据管理< / title >
< style >
body { font-family : system-ui , - apple-system , Segoe UI , Roboto , sans-serif ; background : #fafafa ; }
. container { max-width : 1100 px ; margin : 6 vh auto ; background : #fff ; border-radius : 10 px ; box-shadow : 0 6 px 18 px rgba ( 0 , 0 , 0 , 0.06 ) ; padding : 20 px ; }
table { width : 100 % ; border-collapse : collapse ; }
th , td { border-bottom : 1 px solid #eee ; padding : 8 px ; text-align : left ; vertical-align : top ; }
img { max-width : 1 20px ; border : 1 px solid #eee ; border-radius : 6 px ; }
. btn { padding : 6 px 10 px ; border : none ; border-radius : 6 px ; cursor : pointer ; }
. btn-primary { background : #1677ff ; color : #fff ; }
. btn-danger { background : #ff4d4f ; color : #fff ; }
. btn-secondary { background : #f0f0f0 ; }
. muted { color : #666 ; font-size : 12 px ; }
. modal { position : fixed ; inset : 0 ; display : none ; background : rgba ( 0 , 0 , 0 , 0.4 ) ; align-items : center ; justify-content : center ; }
. modal . dialog { width : 720 px ; max-width : 92 vw ; background : #fff ; border-radius : 1 0px ; padding : 16 px ; }
textarea { width : 100 % ; min-height : 24 0 px ; font-family : ui-monospace , SFMono-Regular , Menlo , monospace ; font-size : 14 px ; }
# kvForm { border : 1 px solid #eee ; border-radius : 6 px ; padding : 8 px ; max-height : 300 px ; overflow : auto ; }
body {
font-family : system-ui , - apple-system , Segoe UI , Roboto , sans-serif ;
background : #fafafa ;
margin : 0 ;
padding : 20 px ;
}
. container {
max-width : 1200 px ;
margin : 0 auto ;
background : #fff ;
border-radius : 10 px ;
box-shadow : 0 6 px 18 px rgba ( 0 , 0 , 0 , 0.06 ) ;
padding : 20 px ;
}
table {
width : 100 % ;
border-collapse : collapse ;
margin-top : 20 px ;
}
th , td {
border-bottom : 1 px solid #eee ;
padding : 12 px 8 px ;
text-align : left ;
vertical-align : top ;
}
th {
background-color : #f8f9fa ;
font-weight : 600 ;
}
img {
max-width : 120 px ;
border : 1 px solid #eee ;
border-radius : 6 px ;
}
. btn {
padding : 6 px 10 px ;
border : none ;
border-radius : 6 px ;
cursor : pointer ;
font-size : 14 px ;
margin : 2 px ;
}
. btn-primary {
background : #1677ff ;
color : #fff ;
}
. btn-danger {
background : #ff4d4f ;
color : #fff ;
}
. btn-secondary {
background : #f0f0f0 ;
color : #333 ;
}
. muted {
color : #666 ;
font-size : 12 px ;
}
. modal {
position : fixed ;
inset : 0 ;
display : none ;
background : rgba ( 0 , 0 , 0 , 0.4 ) ;
align-items : center ;
justify-content : center ;
z-index : 1000 ;
}
. modal . dialog {
width : 720 px ;
max-width : 92 vw ;
background : #fff ;
border-radius : 10 px ;
padding : 20 px ;
max-height : 80 vh ;
overflow-y : auto ;
}
textarea {
width : 100 % ;
min-height : 240 px ;
font-family : ui-monospace , SFMono-Regular , Menlo , monospace ;
font-size : 14 px ;
padding : 10 px ;
border : 1 px solid #ddd ;
border-radius : 4 px ;
resize : vertical ;
}
# kvForm {
border : 1 px solid #eee ;
border-radius : 6 px ;
padding : 8 px ;
max-height : 300 px ;
overflow : auto ;
}
/* 搜索区域样式 */
. search-container {
background : #f8f9fa ;
padding : 15 px ;
border-radius : 8 px ;
margin-bottom : 20 px ;
}
. search-controls {
display : flex ;
flex-wrap : wrap ;
gap : 10 px ;
align-items : center ;
margin-bottom : 10 px ;
}
. search-input {
flex : 1 ;
min-width : 200 px ;
padding : 8 px 12 px ;
border : 1 px solid #ddd ;
border-radius : 4 px ;
font-size : 14 px ;
}
. search-result {
margin-top : 10 px ;
padding : 10 px ;
background : #e8f4ff ;
border-radius : 4 px ;
font-size : 14 px ;
}
. search-result . empty {
background : #fff8e8 ;
}
. search-result . error {
background : #ffe8e8 ;
}
/* 加载动画 */
. loading {
display : inline-block ;
width : 20 px ;
height : 20 px ;
border : 3 px solid #f3f3f3 ;
border-top : 3 px solid #1677ff ;
border-radius : 50 % ;
animation : spin 1 s linear infinite ;
}
@ keyframes spin {
0 % { transform : rotate ( 0 deg ) ; }
100 % { transform : rotate ( 360 deg ) ; }
}
/* 响应式调整 */
@ media ( max-width : 768px ) {
. search-controls {
flex-direction : column ;
align-items : stretch ;
}
. search-input {
min-width : auto ;
}
. btn {
width : 100 % ;
margin : 2 px 0 ;
}
}
< / style >
< / head >
< body >
< div class = "container" >
< h2 > 数据管理< / h2 >
< p class = "muted" > 仅管理员可见。可查看、编辑、删除所有记录。< / p >
< table >
<!-- 搜索功能区域 -->
< div class = "search-container" >
< div class = "search-controls" >
< input type = "text" id = "searchQuery" class = "search-input" placeholder = "请输入搜索关键词..." >
< button class = "btn btn-primary" onclick = "performSearch('exact')" > 关键词搜索< / button >
< button class = "btn btn-secondary" onclick = "performSearch('fuzzy')" > 模糊搜索< / button >
< button class = "btn" onclick = "loadAllData()" > 显示全部< / button >
< button class = "btn" onclick = "clearSearch()" > 清空结果< / button >
< / div >
< div id = "searchResult" class = "search-result" style = "display: none;" >
< div id = "searchStatus" > 正在搜索...< / div >
< div id = "searchCount" style = "margin-top: 5px; font-weight: bold;" > < / div >
< / div >
< / div >
<!-- 数据表格 -->
< table id = "dataTable" >
< thead >
< tr >
< th > ID< / th >
@@ -34,32 +198,15 @@
< 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 id = "tableBody" >
<!-- 数据将通过JavaScript动态加载 -->
< / tbody >
< / table >
< div id = "modal" class = "modal" >
<!-- 编辑模态框 -- >
< div id = "editModal" class = "modal" >
< div class = "dialog" >
< h3 > 编辑< / h3 >
< 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 >
@@ -67,7 +214,7 @@
< / div >
< div id = "kvForm" > < / div >
< div style = "margin-top:8px;" >
< textarea id = "resultBox" placeholder = "JSON" > < / textarea >
< textarea id = "resultBox" placeholder = "JSON数据 " > < / textarea >
< / div >
< div style = "margin-top:12px; display:flex; gap:8px;" >
< button class = "btn btn-primary" onclick = "saveEdit()" > 保存< / button >
@@ -79,34 +226,239 @@
< / div >
< script >
// 获取CSRF token的函数
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' ) ;
// DOM元素引用
const searchQueryInput = document . getElementById ( 'searchQuery' ) ;
const searchResultDiv = document . getElementById ( 'searchResult' ) ;
const searchStatus = document . getElementById ( 'searchStatus' ) ;
const searchCount = document . getElementById ( 'searchCount' ) ;
const tableBody = document . getElementById ( 'tableBody' ) ;
const editModal = document . getElementById ( 'editModal' ) ;
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 = '' ;
let allDataCache = [ ] ; // 缓存所有数据,避免重复请求
// 搜索功能
async function performSearch ( type ) {
const query = searchQueryInput . value . trim ( ) ;
if ( ! query ) {
showSearchMessage ( '请输入搜索关键词' , 'error' ) ;
return ;
}
showSearchLoading ( ) ;
try {
let url ;
if ( type === 'exact' ) {
url = ` /elastic/search/?q= ${ encodeURIComponent ( query ) } ` ;
} else if ( type === 'fuzzy' ) {
url = ` /elastic/fuzzy-search/?keyword= ${ encodeURIComponent ( query ) } ` ;
}
const response = await fetch ( url ) ;
const data = await response . json ( ) ;
if ( data . status === 'success' ) {
displaySearchResults ( data . data || [ ] ) ;
} else {
showSearchMessage ( ` 搜索失败: ${ data . message || '未知错误' } ` , 'error' ) ;
}
} catch ( error ) {
console . error ( '搜索请求失败:' , error ) ;
showSearchMessage ( '搜索请求失败,请检查网络连接' , 'error' ) ;
}
}
// 显示加载状态
function showSearchLoading ( ) {
searchResultDiv . style . display = 'block' ;
searchResultDiv . className = 'search-result' ;
searchStatus . innerHTML = '<span class="loading"></span> 正在搜索...' ;
searchCount . textContent = '' ;
}
// 显示搜索结果
function displaySearchResults ( results ) {
if ( results . length === 0 ) {
showSearchMessage ( '未找到匹配的结果' , 'empty' ) ;
renderTable ( [ ] ) ;
return ;
}
searchResultDiv . style . display = 'block' ;
searchResultDiv . className = 'search-result' ;
searchStatus . textContent = ` 找到 ${ results . length } 条匹配结果 ` ;
searchCount . textContent = ` 显示 ${ results . length } 条记录 ` ;
renderTable ( results ) ;
}
// 显示搜索消息
function showSearchMessage ( message , type = '' ) {
searchResultDiv . style . display = 'block' ;
searchResultDiv . className = ` search-result ${ type } ` ;
searchStatus . textContent = message ;
searchCount . textContent = '' ;
}
// 加载所有数据
async function loadAllData ( ) {
showSearchLoading ( ) ;
try {
// 如果已有缓存,直接使用
if ( allDataCache . length > 0 ) {
displayAllData ( allDataCache ) ;
return ;
}
const response = await fetch ( '/elastic/all-data/' ) ;
const data = await response . json ( ) ;
if ( data . status === 'success' ) {
allDataCache = data . data || [ ] ;
displayAllData ( allDataCache ) ;
} else {
showSearchMessage ( ` 加载数据失败: ${ data . message || '未知错误' } ` , 'error' ) ;
}
} catch ( error ) {
console . error ( '加载数据失败:' , error ) ;
showSearchMessage ( '加载数据失败,请检查网络连接' , 'error' ) ;
}
}
// 显示所有数据
function displayAllData ( data ) {
searchResultDiv . style . display = 'block' ;
searchResultDiv . className = 'search-result' ;
searchStatus . textContent = '显示全部数据' ;
searchCount . textContent = ` 共 ${ data . length } 条记录 ` ;
renderTable ( data ) ;
}
// 清空搜索结果
function clearSearch ( ) {
searchQueryInput . value = '' ;
searchResultDiv . style . display = 'none' ;
// 如果有缓存数据,显示全部
if ( allDataCache . length > 0 ) {
renderTable ( allDataCache ) ;
} else {
// 否则重新加载
loadAllData ( ) ;
}
}
// 渲染表格
function renderTable ( data ) {
tableBody . innerHTML = '' ;
if ( ! data || data . length === 0 ) {
const row = document . createElement ( 'tr' ) ;
row . innerHTML = '<td colspan="5" style="text-align: center; color: #999;">暂无数据</td>' ;
tableBody . appendChild ( row ) ;
return ;
}
data . forEach ( item => {
const row = document . createElement ( 'tr' ) ;
row . setAttribute ( 'data-id' , item . _id || item . id ) ;
row . setAttribute ( 'data-writer' , item . writer _id ) ;
row . setAttribute ( 'data-image' , item . image ) ;
// 解析data字段, 如果是JSON字符串则格式化显示
let displayData = item . data || '' ;
try {
const parsed = JSON . parse ( item . data ) ;
displayData = JSON . stringify ( parsed , null , 2 ) ;
} catch ( e ) {
// 如果不是JSON, 直接显示原字符串
}
row . innerHTML = `
<td style="max-width:140px; word-break:break-all; font-size: 12px;"> ${ item . _id || item . id || '' } </td>
<td>
${ item . image ? ` <img src="/media/ ${ item . image } " onerror="this.src=''; this.alt='图片加载失败'" /> ` : '无图片' }
</td>
<td>
<pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;"> ${ escapeHtml ( displayData ) } </pre>
</td>
<td style="font-size: 12px;"> ${ item . writer _id || '' } </td>
<td>
<button class="btn btn-primary" onclick="openEdit(' ${ item . _id || item . id } ')">编辑</button>
<button class="btn btn-danger" onclick="doDelete(' ${ item . _id || item . id } ')">删除</button>
</td>
` ;
tableBody . appendChild ( row ) ;
} ) ;
}
// 转义HTML以防止XSS
function escapeHtml ( unsafe ) {
return unsafe
. replace ( /&/g , "&" )
. replace ( /</g , "<" )
. replace ( />/g , ">" )
. replace ( /"/g , """ )
. replace ( /'/g , "'" ) ;
}
// 编辑功能相关
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 v I = 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 ) ;
const k I = 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 = ( ) => {
if ( kvForm . children . length > 1 ) { // 至少保留一行
kvForm . removeChild ( row ) ;
syncTextarea ( ) ;
} else {
kI . value = '' ;
vI . value = '' ;
syncTextarea ( ) ;
}
} ;
kI . oninput = syncTextarea ;
vI . oninput = syncTextarea ;
row . appendChild ( kI ) ;
row . appendChild ( vI ) ;
row . appendChild ( del ) ;
return row ;
}
@@ -116,61 +468,155 @@ function renderForm(obj){
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 ; }
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 syncTextarea ( ) {
try {
resultBox . value = JSON . stringify ( formToObject ( ) , null , 2 ) ;
} catch ( e ) {
resultBox . value = '{}' ;
}
}
// 事件绑定
addFieldBtn . onclick = ( ) => {
kvForm . appendChild ( createRow ( ) ) ;
syncTextarea ( ) ;
} ;
syncFromTextBtn . onclick = ( ) => {
try {
const obj = JSON . parse ( resultBox . value || '{}' ) ;
renderForm ( obj ) ;
editMsg . textContent = '已从文本区刷新表单' ;
setTimeout ( ( ) => editMsg . textContent = '' , 2000 ) ;
} 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 ( '获取失败' ) ;
if ( d . status !== 'success' ) throw new Error ( d . message || '获取失败' ) ;
const rec = d . data || { } ;
const dataStr = rec . data || '{}' ;
let obj = { } ; try { obj = typeof dataStr === 'string' ? JSON . parse ( dataStr ) : ( dataStr || { } ) ; } catch ( _ ) { obj = { } ; }
let obj = { } ;
try {
obj = typeof dataStr === 'string' ? JSON . parse ( dataStr ) : ( dataStr || { } ) ;
} catch ( _ ) {
obj = { } ;
}
renderForm ( obj ) ;
m odal. style . display = 'flex' ;
} ) . catch ( e => { alert ( e . message || '发生错误' ) ; } ) ;
editM odal. style . display = 'flex' ;
} ) . catch ( e => {
alert ( e . message || '获取数据失败' ) ;
} ) ;
}
function closeModal ( ) { modal . style . display = 'none' ; currentId = '' ; }
function saveEdit ( ) {
function closeModal ( ) {
editModal . style . display = 'none' ;
currentId = '' ;
}
async function saveEdit ( ) {
const body = {
writer _id : currentWriter ,
data : JSON . stringify ( formToObject ( ) ) ,
data : resultBox . value , // 直接使用textarea中的值
image : currentImage ,
} ;
fetch ( ` /elastic/data/ ${ currentId } /update/ ` , {
method : 'PUT' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' , 'X-CSRFToken' : getCookie ( 'csrftoken' ) || '' } ,
b ody : JSON . stringify ( body ) ,
} ) . then ( r => r . json ( ) ) . then ( d => {
if ( d . status !== 'success' ) throw new Error ( '保存失败' ) ;
location . reload ( ) ;
} ) . catch ( e => { editMsg . textContent = e . message || '发生错误' ; } ) ;
try {
const response = await fetch ( ` /elastic/data/ ${ currentId } /update/ ` , {
meth od : 'PUT' ,
credentials : 'same-origin' ,
headers : {
'Content-Type' : 'application/json' ,
'X-CSRFToken' : getCookie ( 'csrftoken' ) || ''
} ,
body : JSON . stringify ( body ) ,
} ) ;
const data = await response . json ( ) ;
if ( data . status !== 'success' ) throw new Error ( data . message || '保存失败' ) ;
alert ( '保存成功' ) ;
closeModal ( ) ;
// 重新加载数据以显示更新
if ( searchResultDiv . style . display !== 'none' ) {
// 如果当前显示的是搜索结果,重新执行搜索
const query = searchQueryInput . value . trim ( ) ;
if ( query ) {
const isFuzzy = document . querySelector ( '.search-result' ) . textContent . includes ( '模糊' ) ;
performSearch ( isFuzzy ? 'fuzzy' : 'exact' ) ;
} else {
loadAllData ( ) ;
}
} else {
loadAllData ( ) ;
}
} 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 || '发生错误' ) ) ;
async function doDelete ( id ) {
if ( ! confirm ( '确认删除该记录?此操作不可撤销' ) ) return ;
try {
const response = await fetch ( ` /elastic/data/ ${ id } /delete/ ` , {
method : 'DELETE' ,
credentials : 'same-origin' ,
headers : { 'X-CSRFToken' : getCookie ( 'csrftoken' ) || '' }
} ) ;
const data = await response . json ( ) ;
if ( data . status !== 'success' ) throw new Error ( data . message || '删除失败' ) ;
alert ( '删除成功' ) ;
// 重新加载数据
if ( searchResultDiv . style . display !== 'none' ) {
const query = searchQueryInput . value . trim ( ) ;
if ( query ) {
const isFuzzy = document . querySelector ( '.search-result' ) . textContent . includes ( '模糊' ) ;
performSearch ( isFuzzy ? 'fuzzy' : 'exact' ) ;
} else {
loadAllData ( ) ;
}
} else {
loadAllData ( ) ;
}
} catch ( e ) {
alert ( e . message || '删除失败' ) ;
}
}
// 页面加载时自动加载所有数据
document . addEventListener ( 'DOMContentLoaded' , function ( ) {
loadAllData ( ) ;
} ) ;
< / script >
< / body >
< / html >