feat: 统一文件上传接口 + 模板图片展示 + 导航页 + 侧边栏调整

This commit is contained in:
admin
2026-07-10 20:43:02 +08:00
parent cbd0430452
commit ce6823e282
25 changed files with 933 additions and 166 deletions

View File

@@ -139,11 +139,61 @@ tbody tr:last-child td{border-bottom:none}
.spinner{display:inline-block;width:32px;height:32px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Content nav tabs */
.content-tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap}
.content-tab{padding:8px 16px;border-radius:var(--radius);background:#fff;border:1px solid var(--border);cursor:pointer;font-size:13px;font-weight:600;transition:all .2s;color:var(--text-muted)}
.content-tab:hover{background:#f8fafc}
.content-tab.active{background:var(--primary);color:#fff;border-color:var(--primary)}
/* Content layout: sidebar + main */
.content-layout{display:flex;gap:16px;align-items:flex-start}
.content-sidebar{width:220px;flex-shrink:0;background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;position:sticky;top:80px}
.content-group{border-bottom:1px solid var(--border)}
.content-group:last-child{border-bottom:none}
.content-group-header{display:flex;align-items:center;gap:8px;padding:12px 16px;cursor:pointer;font-size:13px;font-weight:700;color:var(--text);transition:background .2s;user-select:none}
.content-group-header:hover{background:#f8fafc}
.content-group-header .arrow{margin-left:auto;transition:transform .25s;font-size:12px;color:var(--text-muted)}
.content-group.expanded .content-group-header .arrow{transform:rotate(90deg)}
.content-group-items{max-height:0;overflow:hidden;transition:max-height .3s ease}
.content-group.expanded .content-group-items{max-height:400px}
.content-item{padding:8px 16px 8px 40px;cursor:pointer;font-size:13px;color:var(--text-muted);transition:all .2s;border-left:3px solid transparent}
.content-item:hover{background:var(--primary-light);color:var(--primary)}
.content-item.active{background:var(--primary);color:#fff;border-left-color:#818cf8;font-weight:600}
.content-item .count{float:right;font-size:11px;opacity:.7}
.content-main{flex:1;min-width:0}
/* Upload page */
.upload-area{border:2px dashed var(--border);border-radius:12px;padding:40px;text-align:center;cursor:pointer;transition:all .2s;background:#fafbfc}
.upload-area:hover{border-color:var(--primary);background:var(--primary-light)}
.upload-area.dragover{border-color:var(--primary);background:var(--primary-light);transform:scale(1.01)}
.upload-area .upload-icon{font-size:48px;margin-bottom:12px}
.upload-area .upload-text{font-size:15px;color:var(--text-muted)}
.upload-area .upload-hint{font-size:12px;color:var(--text-muted);margin-top:8px}
.upload-formats{display:flex;gap:8px;justify-content:center;margin-top:12px}
.fmt-tag{display:inline-block;padding:3px 12px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.5px}
.fmt-tag:nth-child(1){background:#fef3c7;color:#92400e}
.fmt-tag:nth-child(2){background:#dbeafe;color:#1e40af}
.fmt-tag:nth-child(3){background:#fce7f3;color:#9d174d}
.fmt-tag:nth-child(4){background:#d1fae5;color:#065f46}
.fmt-tag:nth-child(5){background:#ede9fe;color:#5b21b6}
.fmt-tag:nth-child(6){background:#ffedd5;color:#9a3412}
.fmt-tag:nth-child(7){background:#fee2e2;color:#991b1b}
.module-tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap}
.module-tab{padding:8px 16px;border-radius:var(--radius);background:#fff;border:1px solid var(--border);cursor:pointer;font-size:13px;font-weight:600;transition:all .2s;color:var(--text-muted)}
.module-tab:hover{background:#f8fafc}
.module-tab.active{background:var(--primary);color:#fff;border-color:var(--primary)}
.file-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-top:16px}
.file-card{background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;transition:transform .2s,box-shadow .2s;position:relative}
.file-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg)}
.file-card .file-preview{width:100%;height:120px;background:#f8fafc;display:flex;align-items:center;justify-content:center;overflow:hidden}
.file-card .file-preview img{width:100%;height:100%;object-fit:cover}
.file-card .file-preview .file-icon{font-size:36px}
.file-card .file-info{padding:8px 12px}
.file-card .file-name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-card .file-meta{font-size:11px;color:var(--text-muted);margin-top:2px}
.file-card .file-actions{position:absolute;top:4px;right:4px;display:flex;gap:4px;opacity:0;transition:opacity .2s}
.file-card:hover .file-actions{opacity:1}
.file-card .file-actions button{background:rgba(255,255,255,.9);border:none;border-radius:4px;width:28px;height:28px;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center}
.file-card .file-actions button:hover{background:#fff}
.file-card .copy-url{position:absolute;bottom:4px;right:4px;opacity:0;transition:opacity .2s}
.file-card:hover .copy-url{opacity:1}
.upload-progress{margin-top:12px}
.upload-progress-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden}
.upload-progress-fill{height:100%;background:var(--primary);transition:width .3s}
/* Image cell */
.cell-img{width:40px;height:40px;border-radius:6px;object-fit:cover}
@@ -190,10 +240,6 @@ tbody tr:last-child td{border-bottom:none}
<h2><span class="logo">G</span> Gotest</h2>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">概览</div>
<a class="sidebar-link" onclick="navigate('dashboard')" data-page="dashboard">
<span class="icon">📊</span> 仪表盘
</a>
<div class="sidebar-section">管理</div>
<a class="sidebar-link" onclick="navigate('users')" data-page="users">
<span class="icon">👥</span> 用户管理
@@ -219,7 +265,7 @@ tbody tr:last-child td{border-bottom:none}
<header class="topbar">
<div class="topbar-left">
<button class="sidebar-toggle" onclick="toggleSidebar()">☰</button>
<h3 id="pageTitle">仪表盘</h3>
<h3 id="pageTitle">内容管理</h3>
</div>
<div class="topbar-right">
<div class="topbar-user">
@@ -336,7 +382,7 @@ function showApp() {
document.getElementById('app').style.display = 'flex';
document.getElementById('topbarUsername').textContent = currentUser.username;
document.getElementById('topbarAvatar').textContent = currentUser.username[0].toUpperCase();
navigate('dashboard');
navigate('content');
}
// ===== 导航 =====
@@ -345,10 +391,9 @@ function navigate(page, params = {}) {
document.querySelectorAll('.sidebar-link').forEach(el => el.classList.remove('active'));
const link = document.querySelector(`[data-page="${page}"]`);
if (link) link.classList.add('active');
const titles = { dashboard: '仪表盘', users: '用户管理', content: '内容管理', settings: '系统设置', sites: '站点导航' };
const titles = { users: '用户管理', content: '内容管理', settings: '系统设置', sites: '站点导航' };
document.getElementById('pageTitle').textContent = titles[page] || page;
if (page === 'dashboard') renderDashboard();
else if (page === 'users') renderUsers();
if (page === 'users') renderUsers();
else if (page === 'content') renderContent(params.table);
else if (page === 'settings') renderSettings();
else if (page === 'sites') renderSites();
@@ -357,69 +402,6 @@ function navigate(page, params = {}) {
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); }
// ===== 仪表盘 =====
async function renderDashboard() {
const el = document.getElementById('content');
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const data = await api('/admin/dashboard/stats');
const g = data.data.grouped;
const iconMap = { enterprise: '🏢', shop: '🛒', food: '🍜', game: '🎮', app: '📱', music: '🎵' };
const colorMap = { enterprise: 'bg-indigo', shop: 'bg-green', food: 'bg-orange', game: 'bg-purple', app: 'bg-blue', music: 'bg-pink' };
let statsHtml = `
<div class="stat-card">
<div class="stat-icon bg-cyan">👥</div>
<div class="stat-value">${data.data.total_counts.users || 0}</div>
<div class="stat-label">总用户数</div>
</div>`;
let groupedHtml = '';
for (const [key, val] of Object.entries(g)) {
const items = val.items;
let total = 0;
for (const v of Object.values(items)) total += (v || 0);
statsHtml += `
<div class="stat-card" onclick="navigate('content',{table:'${Object.keys(items)[0]}'})" style="cursor:pointer">
<div class="stat-icon ${colorMap[key]}">${iconMap[key]}</div>
<div class="stat-value">${total}</div>
<div class="stat-label">${val.label}</div>
</div>`;
groupedHtml += `
<div class="table-card" style="margin-bottom:16px">
<div class="section-header" style="padding:16px">
<h4>${iconMap[key]} ${val.label}</h4>
</div>
<div class="table-wrap">
<table>
<tbody>`;
const itemLabels = {
company: '企业信息', news: '新闻', services: '服务', team: '团队',
products: '商品', categories: '分类', restaurants: '餐厅',
menu_items: '菜品', menu_categories: '菜品分类',
games: '游戏', apps: '应用', songs: '歌曲'
};
for (const [itemKey, itemVal] of Object.entries(items)) {
const tableKey = key + '_' + itemKey;
groupedHtml += `
<tr style="cursor:pointer" onclick="navigate('content',{table:'${tableKey}'})">
<td style="font-weight:600">${itemLabels[itemKey] || itemKey}</td>
<td style="text-align:right"><span class="badge-status badge-active">${itemVal || 0} 条</span></td>
<td style="text-align:right;width:60px">→</td>
</tr>`;
}
groupedHtml += `</tbody></table></div></div>`;
}
el.innerHTML = `
<div class="stats-grid">${statsHtml}</div>
<div class="section-header"><h4>📈 各模块数据概览</h4></div>
${groupedHtml}
`;
} catch (e) { el.innerHTML = '<div class="loading">加载失败,请刷新重试</div>'; }
}
// ===== 用户管理 =====
let userPage = 1, userPageSize = 20, userKeyword = '';
async function renderUsers(page) {
@@ -582,15 +564,27 @@ function renderContent(table) {
const el = document.getElementById('content');
// 渲染 tabs
let tabsHtml = '';
// 构建左侧可下拉分组导航
let sidebarHtml = '';
for (const mod of contentModules) {
// 判断当前选中表是否属于该模块,属于则默认展开
const isActive = mod.tables.some(t => t.key === currentTable);
sidebarHtml += `<div class="content-group ${isActive?'expanded':''}">
<div class="content-group-header" onclick="this.parentElement.classList.toggle('expanded')">
<span>${mod.icon}</span> <span>${mod.group}</span>
<span class="arrow">▶</span>
</div>
<div class="content-group-items">`;
for (const t of mod.tables) {
tabsHtml += `<div class="content-tab ${t.key===currentTable?'active':''}" onclick="navigate('content',{table:'${t.key}'})">${mod.icon} ${t.label}</div>`;
sidebarHtml += `<div class="content-item ${t.key===currentTable?'active':''}" onclick="navigate('content',{table:'${t.key}'})">${t.label}</div>`;
}
sidebarHtml += `</div></div>`;
}
el.innerHTML = `<div class="content-tabs">${tabsHtml}</div><div id="contentTable"><div class="loading"><div class="spinner"></div></div></div>`;
el.innerHTML = `<div class="content-layout">
<div class="content-sidebar">${sidebarHtml}</div>
<div class="content-main"><div id="contentTable"><div class="loading"><div class="spinner"></div></div></div></div>
</div>`;
loadContentTable();
}
@@ -661,15 +655,26 @@ function openContentModal(id) {
const fields = allKeys.length > 0 ? allKeys : ['name'];
let formHtml = '';
const imageFields = ['image','cover','logo','avatar','icon','images','screenshots'];
for (const key of fields) {
const val = item ? item[key] : '';
const isLongText = ['description','content','bio','vision','mission','notice','lyrics'].includes(key);
const isNumber = ['price','original_price','stock','sales','rating','sort','status','category_id','restaurant_id','plays','downloads','reviews','play_count','like_count','duration','delivery_time','min_order','delivery_fee','founded_year'].includes(key);
const isBool = ['is_hot','is_new','is_featured'].includes(key);
const isImage = imageFields.includes(key);
if (isBool) {
formHtml += `<div class="form-group"><label>${formatKey(key)}</label>
<select id="f_${key}"><option value="false" ${val!==true?'selected':''}>否</option><option value="true" ${val===true?'selected':''}>是</option></select></div>`;
} else if (isImage) {
const mod = currentTable.split('_')[0];
formHtml += `<div class="form-group"><label>${formatKey(key)}</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="f_${key}" value="${val??''}" placeholder="输入URL或点击上传" style="flex:1">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadToField('${key}','${mod}')" title="支持 JPG/PNG/GIF/WebP/SVG/MP4/PDF,最大 10MB">📤 上传</button>
</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">支持格式: JPG · PNG · GIF · WebP · SVG · MP4 · PDF,最大 10MB</div>
${val ? `<img id="preview_${key}" src="${val}" style="margin-top:8px;max-width:120px;max-height:80px;border-radius:6px;border:1px solid var(--border)" onerror="this.style.display='none'">` : ''}</div>`;
} else if (isLongText) {
formHtml += `<div class="form-group"><label>${formatKey(key)}</label><textarea id="f_${key}">${val||''}</textarea></div>`;
} else if (isNumber) {
@@ -724,6 +729,229 @@ async function deleteContent(id) {
} catch (e) {}
}
// ===== 文件上传 =====
let uploadModule = 'common';
const moduleList = [
{ key: 'enterprise', label: '🏢 企业官网' },
{ key: 'shop', label: '🛒 跨境电商' },
{ key: 'food', label: '🍜 外卖平台' },
{ key: 'game', label: '🎮 游戏平台' },
{ key: 'app', label: '📱 应用商店' },
{ key: 'music', label: '🎵 音乐平台' },
{ key: 'common', label: '📂 公共资源' },
];
async function renderUploads() {
const el = document.getElementById('content');
let tabsHtml = '';
for (const m of moduleList) {
tabsHtml += `<div class="module-tab ${m.key===uploadModule?'active':''}" onclick="switchUploadModule('${m.key}')">${m.label}</div>`;
}
el.innerHTML = `
<div class="module-tabs">${tabsHtml}</div>
<div class="table-card">
<div style="padding:24px">
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📤</div>
<div class="upload-text">点击或拖拽文件到此处上传</div>
<div class="upload-formats">
<span class="fmt-tag">JPG</span>
<span class="fmt-tag">PNG</span>
<span class="fmt-tag">GIF</span>
<span class="fmt-tag">WebP</span>
<span class="fmt-tag">SVG</span>
<span class="fmt-tag">MP4</span>
<span class="fmt-tag">PDF</span>
</div>
<div class="upload-hint">单个文件最大 10MB · 可多选</div>
</div>
<input type="file" id="fileInput" multiple style="display:none" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/x-icon,image/bmp,video/mp4,video/webm,application/pdf">
<div class="upload-progress" id="uploadProgress" style="display:none">
<div class="upload-progress-bar"><div class="upload-progress-fill" id="progressFill" style="width:0%"></div></div>
<div style="text-align:center;margin-top:6px;font-size:12px;color:var(--text-muted)" id="progressText">上传中...</div>
</div>
</div>
</div>
<div class="section-header" style="margin-top:24px"><h4>📋 已上传文件</h4></div>
<div id="fileGrid"><div class="loading"><div class="spinner"></div></div></div>
`;
// 绑定文件选择和拖拽事件
const fileInput = document.getElementById('fileInput');
const uploadArea = document.getElementById('uploadArea');
fileInput.addEventListener('change', function() { handleFileUpload(this.files); });
uploadArea.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', function(e) { e.preventDefault(); this.classList.remove('dragover'); });
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('dragover');
handleFileUpload(e.dataTransfer.files);
});
loadFileList();
}
function switchUploadModule(mod) {
uploadModule = mod;
renderUploads();
}
async function handleFileUpload(files) {
if (!files || files.length === 0) return;
const progressEl = document.getElementById('uploadProgress');
const fillEl = document.getElementById('progressFill');
const textEl = document.getElementById('progressText');
progressEl.style.display = 'block';
for (let i = 0; i < files.length; i++) {
const file = files[i];
textEl.textContent = `上传中 (${i+1}/${files.length}): ${file.name}`;
fillEl.style.width = ((i / files.length) * 100) + '%';
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(API_BASE + '/upload?module=' + uploadModule, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const data = await resp.json();
if (data.code !== 0) {
toast('上传失败: ' + data.message, 'error');
} else {
toast('上传成功: ' + file.name, 'success');
}
} catch (e) {
toast('网络错误: ' + file.name, 'error');
}
}
fillEl.style.width = '100%';
textEl.textContent = '上传完成';
setTimeout(() => { progressEl.style.display = 'none'; fillEl.style.width = '0%'; }, 1500);
document.getElementById('fileInput').value = '';
loadFileList();
}
async function loadFileList() {
const el = document.getElementById('fileGrid');
if (!el) return;
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const data = await api('/upload/list?module=' + uploadModule);
const files = data.data.list || [];
if (files.length === 0) {
el.innerHTML = '<div class="table-card"><div class="empty-row" style="padding:40px">暂无文件,请上传</div></div>';
return;
}
let html = '<div class="file-grid">';
for (const f of files) {
const isImage = f.url.match(/\.(jpg|jpeg|png|gif|webp)$/i);
const isVideo = f.url.match(/\.(mp4|webm)$/i);
let preview = '';
if (isImage) {
preview = `<img src="${f.url}" onerror="this.parentElement.innerHTML='<div class=\\'file-icon\\'>📄</div>'">`;
} else if (isVideo) {
preview = `<div class="file-icon">🎬</div>`;
} else {
preview = `<div class="file-icon">📄</div>`;
}
html += `
<div class="file-card">
<div class="file-preview">${preview}</div>
<div class="file-actions">
<button title="复制链接" onclick="copyFileUrl('${f.url}')">🔗</button>
<button title="删除" onclick="deleteFile('${f.url}')">🗑</button>
</div>
<div class="file-info">
<div class="file-name" title="${f.name}">${f.name}</div>
<div class="file-meta">${formatFileSize(f.size)} · ${f.mod_time}</div>
</div>
</div>`;
}
html += '</div>';
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="loading">加载失败</div>';
}
}
function formatFileSize(bytes) {
if (!bytes) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
function copyFileUrl(url) {
const fullUrl = window.location.origin + url;
navigator.clipboard.writeText(fullUrl).then(() => {
toast('链接已复制: ' + fullUrl, 'success');
}).catch(() => {
// 降级方案
const input = document.createElement('input');
input.value = fullUrl;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
toast('链接已复制: ' + fullUrl, 'success');
});
}
async function deleteFile(fileUrl) {
if (!confirm('确定删除该文件?')) return;
// 从 URL 中提取模块相对路径
const path = fileUrl.replace('/uploads/', '');
try {
await api('/upload?module=' + uploadModule + '&filename=' + encodeURIComponent(path), { method: 'DELETE' });
toast('删除成功', 'success');
loadFileList();
} catch (e) {}
}
// 内容编辑弹窗中的字段上传
function uploadToField(fieldKey, mod) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/x-icon,image/bmp,video/mp4,video/webm,application/pdf';
input.onchange = async function() {
const file = this.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(API_BASE + '/upload?module=' + mod, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const data = await resp.json();
if (data.code !== 0) {
toast('上传失败: ' + data.message, 'error');
return;
}
const urlInput = document.getElementById('f_' + fieldKey);
urlInput.value = data.data.url;
// 更新预览
let preview = document.getElementById('preview_' + fieldKey);
if (!preview) {
preview = document.createElement('img');
preview.id = 'preview_' + fieldKey;
preview.style.cssText = 'margin-top:8px;max-width:120px;max-height:80px;border-radius:6px;border:1px solid var(--border)';
urlInput.parentElement.parentElement.appendChild(preview);
}
preview.src = data.data.url;
preview.style.display = '';
toast('上传成功', 'success');
} catch (e) {
toast('网络错误', 'error');
}
};
input.click();
}
// ===== 系统设置 =====
async function renderSettings() {
const el = document.getElementById('content');