328 lines
13 KiB
JavaScript
328 lines
13 KiB
JavaScript
/**
|
|
* TH1 Dashboard - Suggestion Tracker Module
|
|
*
|
|
* Data source: DOC/suggestions.json (project-level, version controlled)
|
|
* Server API:
|
|
* GET /api/suggestions/list
|
|
* POST /api/suggestions/create
|
|
* POST /api/suggestions/update
|
|
* POST /api/suggestions/delete
|
|
*/
|
|
|
|
let suggestionsList = [];
|
|
let suggestionsLoaded = false;
|
|
|
|
const SUGGESTION_STATUSES = {
|
|
open: { label: '未处理', color: '#d97706', bg: '#fffbeb' },
|
|
processed: { label: '已处理', color: '#16a34a', bg: '#f0fdf4' },
|
|
};
|
|
|
|
const SUGGESTION_MODULES = [
|
|
'战斗系统', '技能系统', 'AI系统', 'UI界面', '地图系统',
|
|
'科技树', '存档系统', '多语言', '美术资源', '配置数据',
|
|
'网络/联机', '音频', '性能', '其他'
|
|
];
|
|
|
|
async function suggestionsLoadAll() {
|
|
if (suggestionsLoaded) return;
|
|
try {
|
|
const r = await fetch('/api/suggestions/list?t=' + Date.now());
|
|
if (!r.ok) throw new Error(r.status);
|
|
const data = await r.json();
|
|
suggestionsList = data.suggestions || [];
|
|
} catch (e) {
|
|
console.warn('suggestionsLoad fail:', e);
|
|
suggestionsList = [];
|
|
}
|
|
suggestionsLoaded = true;
|
|
suggestionsRenderList();
|
|
}
|
|
|
|
function suggestionsRenderList() {
|
|
const container = document.getElementById('suggestions-list');
|
|
const countEl = document.getElementById('suggestions-count');
|
|
if (!container) return;
|
|
|
|
const searchQ = (document.getElementById('suggestions-search')?.value || '').trim().toLowerCase();
|
|
const statusF = document.getElementById('suggestions-status-filter')?.value || '';
|
|
const moduleF = document.getElementById('suggestions-module-filter')?.value || '';
|
|
|
|
let filtered = [...suggestionsList];
|
|
|
|
if (searchQ) {
|
|
filtered = filtered.filter(s =>
|
|
String(s.id).includes(searchQ) ||
|
|
(s.title || '').toLowerCase().includes(searchQ) ||
|
|
(s.description || '').toLowerCase().includes(searchQ)
|
|
);
|
|
}
|
|
if (statusF) filtered = filtered.filter(s => s.status === statusF);
|
|
if (moduleF) filtered = filtered.filter(s => s.module === moduleF);
|
|
|
|
filtered.sort((a, b) => b.id - a.id);
|
|
|
|
if (countEl) {
|
|
const total = suggestionsList.length;
|
|
const openCount = suggestionsList.filter(s => s.status !== 'processed').length;
|
|
countEl.textContent = `共 ${total} 条建议,${openCount} 条未处理`;
|
|
}
|
|
|
|
if (filtered.length === 0) {
|
|
container.innerHTML = '<div class="loading-inline">暂无匹配的建议记录</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = filtered.map(s => {
|
|
const st = SUGGESTION_STATUSES[s.status] || SUGGESTION_STATUSES.open;
|
|
const dateStr = s.createdAt ? new Date(s.createdAt).toLocaleDateString('zh-CN') : '';
|
|
const showDoneBtn = s.status !== 'processed';
|
|
const doneBtn = showDoneBtn ? `<button class="bug-btn bug-btn-sm bug-btn-primary" onclick="suggestionsQuickProcess(${s.id}, event)" title="标记为已处理" style="margin-right:4px">处理</button>` : '';
|
|
|
|
return `<div class="bug-card" onclick="suggestionsShowDetail(${s.id})">
|
|
<div class="bug-card-left">
|
|
<span class="bug-card-id">#${String(s.id).padStart(3, '0')}</span>
|
|
<span class="bug-card-title">${suggestionsEsc(s.title)}</span>
|
|
${s.module ? `<span class="bug-card-module">${suggestionsEsc(s.module)}</span>` : ''}
|
|
</div>
|
|
<div class="bug-card-right">
|
|
${doneBtn}
|
|
<span class="bug-card-status" style="color:${st.color};background:${st.bg}">${st.label}</span>
|
|
<span class="bug-card-date">${dateStr}</span>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function suggestionsEsc(s) {
|
|
if (!s) return '';
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function suggestionsShowDetail(id) {
|
|
const suggestion = suggestionsList.find(s => s.id === id);
|
|
if (!suggestion) return;
|
|
|
|
const st = SUGGESTION_STATUSES[suggestion.status] || SUGGESTION_STATUSES.open;
|
|
const dateStr = suggestion.createdAt ? new Date(suggestion.createdAt).toLocaleString('zh-CN') : '';
|
|
const updStr = suggestion.updatedAt ? new Date(suggestion.updatedAt).toLocaleString('zh-CN') : '';
|
|
const statusOpts = Object.entries(SUGGESTION_STATUSES).map(([k, v]) =>
|
|
`<option value="${k}" ${suggestion.status === k ? 'selected' : ''}>${v.label}</option>`
|
|
).join('');
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'bug-detail-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="bug-detail-modal">
|
|
<div class="bug-detail-header">
|
|
<div>
|
|
<div class="bug-detail-id">#${String(suggestion.id).padStart(3, '0')}</div>
|
|
<div class="bug-detail-title">${suggestionsEsc(suggestion.title)}</div>
|
|
<div class="bug-detail-meta">
|
|
<span class="bug-card-status" style="color:${st.color};background:${st.bg}">${st.label}</span>
|
|
${suggestion.module ? `<span class="bug-card-module">${suggestionsEsc(suggestion.module)}</span>` : ''}
|
|
<span>创建: ${dateStr}</span>
|
|
${updStr ? `<span>更新: ${updStr}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<button class="bug-detail-close" onclick="this.closest('.bug-detail-overlay').remove()">×</button>
|
|
</div>
|
|
<div class="bug-detail-body">
|
|
${suggestion.description ? `<div class="bug-detail-desc">${suggestionsEsc(suggestion.description).replace(/\n/g, '<br>')}</div>` : '<div class="bug-detail-desc" style="color:#94a3b8">无详细描述</div>'}
|
|
</div>
|
|
<div class="bug-detail-footer">
|
|
<div class="bug-detail-status-change">
|
|
<label>状态</label>
|
|
<select id="suggestion-status-select" class="filter-select" onchange="suggestionsUpdateStatus(${suggestion.id}, this.value)">${statusOpts}</select>
|
|
</div>
|
|
<div>
|
|
<button class="bug-btn bug-btn-danger" onclick="suggestionsDelete(${suggestion.id})">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
|
|
const handler = (e) => {
|
|
if (e.key === 'Escape') {
|
|
overlay.remove();
|
|
document.removeEventListener('keydown', handler);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
}
|
|
|
|
function suggestionsShowAddModal() {
|
|
const moduleOpts = SUGGESTION_MODULES.map(m => `<option value="${m}">${m}</option>`).join('');
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'bug-detail-overlay';
|
|
overlay.id = 'suggestion-add-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="bug-add-modal">
|
|
<div class="bug-detail-header">
|
|
<div class="bug-detail-title">新增建议</div>
|
|
<button class="bug-detail-close" onclick="this.closest('.bug-detail-overlay').remove()">×</button>
|
|
</div>
|
|
<div class="bug-add-body">
|
|
<div class="bug-form-group">
|
|
<label class="bug-form-label">标题 *</label>
|
|
<input type="text" id="suggestion-add-title" class="bug-form-input" placeholder="简要描述建议内容">
|
|
</div>
|
|
<div class="bug-form-group">
|
|
<label class="bug-form-label">模块</label>
|
|
<select id="suggestion-add-module" class="bug-form-select">
|
|
<option value="">未分类</option>
|
|
${moduleOpts}
|
|
</select>
|
|
</div>
|
|
<div class="bug-form-group">
|
|
<label class="bug-form-label">详细描述</label>
|
|
<textarea id="suggestion-add-desc" class="bug-form-textarea" rows="6" placeholder="记录建议背景、期望调整、玩家反馈来源等..."></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="bug-detail-footer">
|
|
<button class="bug-btn" onclick="document.getElementById('suggestion-add-overlay').remove()">取消</button>
|
|
<button class="bug-btn bug-btn-primary" onclick="suggestionsSubmitAdd()">提交建议</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
|
|
setTimeout(() => document.getElementById('suggestion-add-title')?.focus(), 100);
|
|
|
|
const handler = (e) => {
|
|
if (e.key === 'Escape') {
|
|
overlay.remove();
|
|
document.removeEventListener('keydown', handler);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
}
|
|
|
|
async function suggestionsSubmitAdd() {
|
|
const title = document.getElementById('suggestion-add-title').value.trim();
|
|
if (!title) { alert('请输入建议标题'); return; }
|
|
|
|
const module = document.getElementById('suggestion-add-module').value;
|
|
const description = document.getElementById('suggestion-add-desc').value.trim();
|
|
|
|
const btn = document.querySelector('#suggestion-add-overlay .bug-btn-primary');
|
|
if (btn) { btn.disabled = true; btn.textContent = '提交中...'; }
|
|
|
|
try {
|
|
const r = await fetch('/api/suggestions/create', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title, module, description })
|
|
});
|
|
const result = await r.json();
|
|
if (result.success) {
|
|
document.getElementById('suggestion-add-overlay')?.remove();
|
|
suggestionsLoaded = false;
|
|
await suggestionsLoadAll();
|
|
suggestionsShowToast(`建议 #${String(result.id).padStart(3, '0')} 已创建`, 'success');
|
|
} else {
|
|
alert('创建失败: ' + (result.error || '未知错误'));
|
|
}
|
|
} catch (e) {
|
|
alert('创建失败: ' + e.message);
|
|
} finally {
|
|
if (btn) { btn.disabled = false; btn.textContent = '提交建议'; }
|
|
}
|
|
}
|
|
|
|
async function suggestionsQuickProcess(id, event) {
|
|
if (event) event.stopPropagation();
|
|
await suggestionsUpdateStatus(id, 'processed');
|
|
}
|
|
|
|
async function suggestionsUpdateStatus(id, newStatus) {
|
|
try {
|
|
const r = await fetch('/api/suggestions/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, status: newStatus })
|
|
});
|
|
const result = await r.json();
|
|
if (result.success) {
|
|
const suggestion = suggestionsList.find(s => s.id === id);
|
|
if (suggestion) {
|
|
suggestion.status = newStatus;
|
|
suggestion.updatedAt = Date.now();
|
|
}
|
|
suggestionsRenderList();
|
|
suggestionsShowToast('状态已更新', 'success');
|
|
} else {
|
|
alert('更新失败: ' + (result.error || '未知错误'));
|
|
}
|
|
} catch (e) {
|
|
alert('更新失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function suggestionsDelete(id) {
|
|
if (!confirm(`确定要删除建议 #${String(id).padStart(3, '0')} 吗?`)) return;
|
|
|
|
try {
|
|
const r = await fetch('/api/suggestions/delete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id })
|
|
});
|
|
const result = await r.json();
|
|
if (result.success) {
|
|
document.querySelector('.bug-detail-overlay')?.remove();
|
|
suggestionsLoaded = false;
|
|
await suggestionsLoadAll();
|
|
suggestionsShowToast('建议已删除', 'success');
|
|
} else {
|
|
alert('删除失败: ' + (result.error || '未知错误'));
|
|
}
|
|
} catch (e) {
|
|
alert('删除失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function suggestionsShowToast(msg, type) {
|
|
const toast = document.getElementById('toast');
|
|
if (!toast) return;
|
|
toast.textContent = msg;
|
|
toast.className = 'toast ' + (type || 'success');
|
|
toast.style.display = 'block';
|
|
setTimeout(() => { toast.style.display = 'none'; }, 3000);
|
|
}
|
|
|
|
function suggestionsBindFilters() {
|
|
const ids = ['suggestions-search', 'suggestions-status-filter', 'suggestions-module-filter'];
|
|
ids.forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', () => suggestionsRenderList());
|
|
});
|
|
|
|
const moduleFilter = document.getElementById('suggestions-module-filter');
|
|
if (moduleFilter && moduleFilter.options.length <= 1) {
|
|
SUGGESTION_MODULES.forEach(m => {
|
|
const opt = document.createElement('option');
|
|
opt.value = m;
|
|
opt.textContent = m;
|
|
moduleFilter.appendChild(opt);
|
|
});
|
|
}
|
|
}
|
|
|
|
(function () {
|
|
const observer = new MutationObserver(() => {
|
|
const panel = document.getElementById('panel-suggestions');
|
|
if (panel && panel.classList.contains('active') && !suggestionsLoaded) {
|
|
suggestionsLoadAll();
|
|
suggestionsBindFilters();
|
|
}
|
|
});
|
|
const panel = document.getElementById('panel-suggestions');
|
|
if (panel) {
|
|
observer.observe(panel, { attributes: true, attributeFilter: ['class'] });
|
|
}
|
|
})();
|