www: 项目分类功能 - 左侧导航分类筛选(全部/未归类/分类)、编辑弹窗放大+分类下拉、右上角新建分类
This commit is contained in:
+90
-23
@@ -16,27 +16,37 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica N
|
||||
.btn-outline{background:transparent;border:1px solid #667eea;color:#667eea}
|
||||
.btn-outline:hover{background:#667eea;color:#fff}
|
||||
.btn-sm{padding:6px 14px;font-size:13px}
|
||||
.main{padding:90px 24px 40px;max-width:1400px;margin:0 auto}
|
||||
.main{padding:90px 24px 40px;max-width:1600px;margin:0 auto}
|
||||
.layout{display:flex;gap:24px;align-items:flex-start}
|
||||
.content{flex:1;min-width:0}
|
||||
.sidebar{width:200px;flex-shrink:0;position:sticky;top:84px;background:rgba(255,255,255,.95);border-radius:16px;padding:12px;box-shadow:0 6px 14px rgba(0,0,0,.12);max-height:calc(100vh - 100px);overflow-y:auto}
|
||||
.sidebar-item{display:flex;align-items:center;gap:8px;padding:9px 12px;border-radius:10px;cursor:pointer;color:#374151;font-size:.92rem;font-weight:500;transition:background .15s,color .15s;margin-bottom:2px}
|
||||
.sidebar-item:hover{background:#eef0ff;color:#667eea}
|
||||
.sidebar-item.active{background:#667eea;color:#fff}
|
||||
.sidebar-icon{font-size:.85rem;width:18px;text-align:center;flex-shrink:0}
|
||||
.sidebar-count{margin-left:auto;font-size:.75rem;background:#e5e7eb;color:#6b7280;border-radius:10px;padding:1px 8px}
|
||||
.sidebar-item.active .sidebar-count{background:rgba(255,255,255,.25);color:#fff}
|
||||
.sidebar-divider{height:1px;background:#e5e7eb;margin:8px 4px}
|
||||
.section-title{font-size:1.5rem;font-weight:600;color:#fff;margin-bottom:24px;text-align:center;text-shadow:0 2px 4px rgba(0,0,0,.2)}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:24px}
|
||||
@media(max-width:1200px){.grid{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:900px){.grid{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:600px){.grid{grid-template-columns:1fr}}
|
||||
.card{background:#fff;border-radius:20px;padding:24px;display:flex;align-items:center;gap:16px;cursor:pointer;position:relative;box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -1px rgba(0,0,0,.06);transition:transform .1s ease,box-shadow .2s ease;transform-style:preserve-3d;will-change:transform}
|
||||
.card:hover{box-shadow:0 20px 25px -5px rgba(0,0,0,.15),0 10px 10px -5px rgba(0,0,0,.04)}
|
||||
.card-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:transform .3s ease}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:20px}
|
||||
.card{background:#fff;border-radius:16px;padding:20px;display:flex;align-items:center;gap:14px;cursor:pointer;position:relative;box-shadow:0 6px 14px rgba(0,0,0,.12),0 2px 4px rgba(0,0,0,.08);transition:transform .1s ease,box-shadow .2s ease;transform-style:preserve-3d;will-change:transform}
|
||||
.card:hover{box-shadow:0 14px 24px rgba(0,0,0,.18),0 4px 8px rgba(0,0,0,.1)}
|
||||
.card-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:transform .3s ease}
|
||||
.card:hover .card-icon{transform:scale(1.1)}
|
||||
.card-icon svg{width:28px;height:28px;stroke:#fff;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
||||
.card-icon svg{width:24px;height:24px;stroke:#fff;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
||||
.card-body{flex:1;min-width:0}
|
||||
.card-title{font-size:1.05rem;font-weight:600;color:#1a1a2e;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card-desc{font-size:.85rem;color:#6b7280;line-height:1.4;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
.card-title{font-size:.95rem;font-weight:600;color:#1a1a2e;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card-desc{font-size:.8rem;color:#6b7280;line-height:1.4;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
.card-close{position:absolute;top:12px;right:12px;width:24px;height:24px;border-radius:50%;background:#ef4444;color:#fff;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;line-height:1;opacity:0;transform:scale(.8);transition:all .2s ease;z-index:10}
|
||||
.card-close:hover{background:#dc2626;transform:scale(1.1)}
|
||||
.card:hover .card-close{opacity:1;transform:scale(1)}
|
||||
.card-edit{position:absolute;top:12px;right:40px;width:24px;height:24px;border-radius:50%;background:#667eea;color:#fff;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:13px;line-height:1;opacity:0;transform:scale(.8);transition:all .2s ease;z-index:10}
|
||||
.card-edit:hover{background:#5a6fd6;transform:scale(1.1)}
|
||||
.card:hover .card-edit{opacity:1;transform:scale(1)}
|
||||
.empty{text-align:center;color:rgba(255,255,255,.7);padding:60px 20px;font-size:1.1rem}
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);backdrop-filter:blur(4px);display:none;align-items:center;justify-content:center;z-index:200}
|
||||
.modal-overlay.show{display:flex}
|
||||
.modal{background:#fff;border-radius:16px;padding:28px;width:100%;max-width:400px;margin:20px;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);animation:modalIn .3s ease}
|
||||
.modal{background:#fff;border-radius:16px;padding:28px;width:100%;max-width:520px;margin:20px;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);animation:modalIn .3s ease}
|
||||
@keyframes modalIn{from{opacity:0;transform:translateY(-20px) scale(.95)}to{opacity:1;transform:translateY(0) scale(1)}}
|
||||
.modal-header{font-size:1.2rem;font-weight:600;margin-bottom:20px;color:#1a1a2e}
|
||||
.modal-body{display:flex;flex-direction:column;gap:14px}
|
||||
@@ -44,6 +54,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica N
|
||||
.input-group label{font-size:.85rem;color:#6b7280;font-weight:500}
|
||||
.input-group input{padding:10px 14px;border:1px solid #e5e7eb;border-radius:10px;font-size:14px;transition:border-color .2s,box-shadow .2s;outline:none}
|
||||
.input-group input:focus{border-color:#667eea;box-shadow:0 0 0 3px rgba(102,126,234,.1)}
|
||||
.input-group select{padding:10px 14px;border:1px solid #e5e7eb;border-radius:10px;font-size:14px;background:#fff;color:#374151;outline:none;transition:border-color .2s,box-shadow .2s}
|
||||
.input-group select:focus{border-color:#667eea;box-shadow:0 0 0 3px rgba(102,126,234,.1)}
|
||||
.card-badge{display:inline-block;margin-top:8px;padding:2px 10px;border-radius:10px;font-size:.72rem;background:#eef0ff;color:#667eea;font-weight:600}
|
||||
.modal-lg{max-width:640px}
|
||||
.sidebar-label{font-size:.75rem;color:#9ca3af;font-weight:600;padding:8px 12px 4px;letter-spacing:.5px}
|
||||
.modal-footer{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
|
||||
.btn-secondary{background:#f3f4f6;color:#374151}
|
||||
.btn-secondary:hover{background:#e5e7eb}
|
||||
@@ -65,9 +80,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica N
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
<div class="section-title">我的项目导航</div>
|
||||
<div class="grid" id="cardGrid"></div>
|
||||
<div class="empty" id="emptyState" style="display:none">暂无项目链接,登录后点击"添加链接"添加</div>
|
||||
<div class="layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
<div class="content">
|
||||
<div class="section-title">我的项目导航</div>
|
||||
<div class="grid" id="cardGrid"></div>
|
||||
<div class="empty" id="emptyState" style="display:none">暂无项目链接,登录后点击"添加链接"添加</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 添加链接模态框 -->
|
||||
@@ -78,6 +98,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica N
|
||||
<div class="modal-body">
|
||||
<div class="input-group"><label>名称</label><input type="text" id="addName" placeholder="例如:个人博客"></div>
|
||||
<div class="input-group"><label>链接</label><input type="text" id="addUrl" placeholder="例如:http://129.211.173.87:8888"></div>
|
||||
<div class="input-group"><label>分类</label><select id="addCategory"></select></div>
|
||||
<div class="input-group"><label>介绍</label><input type="text" id="addDesc" placeholder="简短描述这个项目"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -87,11 +108,46 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica N
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改链接模态框 -->
|
||||
<div class="modal-overlay" id="editModal">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">修改链接</div>
|
||||
<div class="alert alert-error" id="editAlert"></div>
|
||||
<div class="modal-body">
|
||||
<div class="input-group"><label>名称</label><input type="text" id="editName" placeholder="例如:个人博客"></div>
|
||||
<div class="input-group"><label>链接</label><input type="text" id="editUrl" placeholder="例如:http://129.211.173.87:8888"></div>
|
||||
<div class="input-group"><label>分类</label><select id="editCategory"></select></div>
|
||||
<div class="input-group"><label>介绍</label><input type="text" id="editDesc" placeholder="简短描述这个项目"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('editModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="doEditLink()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建分类模态框 -->
|
||||
<div class="modal-overlay" id="catModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">新建分类</div>
|
||||
<div class="alert alert-error" id="catAlert"></div>
|
||||
<div class="modal-body">
|
||||
<div class="input-group"><label>分类名称</label><input type="text" id="catName" placeholder="例如:工具、博客、娱乐"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('catModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="doAddCategory()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE='';
|
||||
const SSO_BASE='http://129.211.173.87:9000';
|
||||
let ssoUser=null;
|
||||
let linksCache=[];
|
||||
let categoriesCache=[];
|
||||
let currentCategory='all';
|
||||
|
||||
const LUCIDE_ICONS={
|
||||
'layout-dashboard':{paths:'<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>'},
|
||||
@@ -124,6 +180,11 @@ function getDefaultLinks(){return[{id:'1',name:'个人博客',url:'http://129.21
|
||||
|
||||
async function fetchLinks(){try{const res=await fetch(`${API_BASE}/api/links`,{credentials:'include'});if(res.ok){linksCache=await res.json();return linksCache}}catch(e){}linksCache=getDefaultLinks();return linksCache}
|
||||
function getLinks(){return linksCache.length?linksCache:getDefaultLinks()}
|
||||
async function fetchCategories(){try{const res=await fetch(`${API_BASE}/api/categories`,{credentials:'include'});if(res.ok){categoriesCache=await res.json();return categoriesCache}}catch(e){}categoriesCache=[];return categoriesCache}
|
||||
function getFilteredLinks(){const links=getLinks();if(currentCategory==='all')return links;if(currentCategory==='uncat')return links.filter(l=>!(l.category||'').trim());const cat=categoriesCache.find(c=>c.id===currentCategory);const name=cat?cat.name:'';if(!name)return [];return links.filter(l=>(l.category||'').trim()===name)}
|
||||
function renderSidebar(){const sidebar=document.getElementById('sidebar');const links=getLinks();const counts={};let uncat=0;links.forEach(l=>{const c=(l.category||'').trim();if(c)counts[c]=(counts[c]||0)+1;else uncat++});const items=[{key:'all',label:'全部',icon:'🗃️',count:links.length},{key:'uncat',label:'未归类',icon:'📂',count:uncat},...categoriesCache.map(c=>({key:c.id,label:c.name,icon:'📁',count:counts[c.name]||0}))];sidebar.innerHTML='<div class="sidebar-label">项目分类</div>'+items.map(it=>`<div class="sidebar-item ${currentCategory===it.key?'active':''}" onclick="setCategory('${it.key}')"><span class="sidebar-icon">${it.icon}</span>${escapeHtml(it.label)}<span class="sidebar-count">${it.count}</span></div>`).join('')}
|
||||
function setCategory(key){currentCategory=key;renderSidebar();renderCards()}
|
||||
function renderCategoryOptions(selectId,selected){const sel=document.getElementById(selectId);sel.innerHTML='<option value="">未归类</option>'+categoriesCache.map(c=>`<option value="${escapeHtml(c.name)}" ${c.name===selected?'selected':''}>${escapeHtml(c.name)}</option>`).join('')}
|
||||
|
||||
function isLoggedIn(){return ssoUser!==null}
|
||||
|
||||
@@ -137,12 +198,13 @@ function genId(){return Date.now().toString(36)+Math.random().toString(36).subst
|
||||
|
||||
function renderIcon(iconKey){const icon=LUCIDE_ICONS[iconKey]||LUCIDE_ICONS['layout-dashboard'];return`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icon.paths}</svg>`}
|
||||
|
||||
function renderCards(){const grid=document.getElementById('cardGrid');const empty=document.getElementById('emptyState');const links=getLinks();if(!links.length){grid.innerHTML='';empty.style.display='block';return}empty.style.display='none';const loggedIn=isLoggedIn();grid.innerHTML=links.map(link=>{const iconKey=link.icon||getIcon(link.name);const grad=getGradient(link.name);return`<div class="card" data-id="${link.id}" onmousemove="tiltCard(event,this)" onmouseleave="resetCard(this)" onclick="openLink(event,'${link.url}')">
|
||||
${loggedIn?`<div class="card-close" onclick="deleteLink(event,'${link.id}')">×</div>`:''}
|
||||
function renderCards(){const grid=document.getElementById('cardGrid');const empty=document.getElementById('emptyState');const links=getFilteredLinks();if(!links.length){grid.innerHTML='';empty.style.display='block';return}empty.style.display='none';const loggedIn=isLoggedIn();grid.innerHTML=links.map(link=>{const iconKey=link.icon||getIcon(link.name);const grad=getGradient(link.name);return`<div class="card" data-id="${link.id}" onmousemove="tiltCard(event,this)" onmouseleave="resetCard(this)" onclick="openLink(event,'${link.url}')">
|
||||
${loggedIn?`<div class="card-edit" title="修改" onclick="editLink(event,'${link.id}')">✎</div><div class="card-close" title="删除" onclick="deleteLink(event,'${link.id}')">×</div>`:''}
|
||||
<div class="card-icon" style="background:${grad}">${renderIcon(iconKey)}</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">${escapeHtml(link.name)}</div>
|
||||
<div class="card-desc">${escapeHtml(link.desc||'')}</div>
|
||||
${link.category?`<div class="card-badge">${escapeHtml(link.category)}</div>`:''}
|
||||
</div>
|
||||
</div>`}).join('')}
|
||||
|
||||
@@ -151,21 +213,26 @@ function escapeHtml(text){if(!text)return'';return text.replace(/&/g,'&').re
|
||||
function tiltCard(e,card){const rect=card.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const cx=rect.width/2;const cy=rect.height/2;const rx=(x-cx)/cx;const ry=(y-cy)/cy;const maxDeg=10;card.style.transform=`perspective(1000px) rotateY(${rx*maxDeg}deg) rotateX(${-ry*maxDeg}deg)`}
|
||||
function resetCard(card){card.style.transform='perspective(1000px) rotateY(0deg) rotateX(0deg)';card.style.transition='transform .3s ease';setTimeout(()=>card.style.transition='transform .1s ease,box-shadow .2s ease',300)}
|
||||
|
||||
function openLink(e,url){if(e.target.closest('.card-close'))return;window.open(url,'_blank')}
|
||||
async function deleteLink(e,id){e.stopPropagation();if(!isLoggedIn())return;try{const res=await fetch(`${API_BASE}/api/links/${id}`,{method:'DELETE',credentials:'include'});if(res.ok){await fetchLinks();renderCards()}}catch(err){}}
|
||||
function openLink(e,url){if(e.target.closest('.card-close')||e.target.closest('.card-edit'))return;window.open(url,'_blank')}
|
||||
async function deleteLink(e,id){e.stopPropagation();if(!isLoggedIn())return;try{const res=await fetch(`${API_BASE}/api/links/${id}`,{method:'DELETE',credentials:'include'});if(res.ok){await fetchLinks();renderCards();renderSidebar()}}catch(err){}}
|
||||
|
||||
function updateNavbar(){const btns=document.getElementById('navbarBtns');if(isLoggedIn()){btns.innerHTML='<div class="user-info"><span class="user-name">'+escapeHtml(ssoUser)+'</span><div class="divider"></div><button class="btn btn-primary btn-sm" onclick="showModal(\'addModal\')">添加链接</button><button class="btn btn-secondary btn-sm" onclick="ssoLogout()">退出</button></div>'}else{btns.innerHTML='<button class="btn btn-primary" onclick="ssoLogin()">🔐 SSO 统一登录</button>'}renderCards()}
|
||||
let editingLinkId=null;
|
||||
function editLink(e,id){e.stopPropagation();if(!isLoggedIn())return;const link=getLinks().find(l=>l.id===id);if(!link)return;editingLinkId=id;document.getElementById('editName').value=link.name;document.getElementById('editUrl').value=link.url;document.getElementById('editDesc').value=link.desc||'';renderCategoryOptions('editCategory',link.category||'');document.getElementById('editModal').classList.add('show')}
|
||||
async function doEditLink(){if(!editingLinkId)return;const name=document.getElementById('editName').value.trim();const url=document.getElementById('editUrl').value.trim();const desc=document.getElementById('editDesc').value.trim();const category=document.getElementById('editCategory').value;if(!name){showAlert('editModal','请输入名称','error');return}if(!url||!/^https?:\/\/.+/.test(url)){showAlert('editModal','请输入有效的链接地址(以http://或https://开头)','error');return}try{const res=await fetch(`${API_BASE}/api/links/${editingLinkId}`,{method:'PUT',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({name,url,desc,category})});if(res.ok){await fetchLinks();renderCards();renderSidebar();closeModal('editModal');editingLinkId=null}else{const data=await res.json();showAlert('editModal',data.detail||'修改失败','error')}}catch(err){showAlert('editModal','网络错误','error')}}
|
||||
|
||||
function showModal(id){document.getElementById(id).classList.add('show');document.querySelectorAll(`#${id} input`).forEach(i=>i.value='');document.querySelectorAll(`#${id} .alert`).forEach(a=>a.classList.remove('show'))}
|
||||
function updateNavbar(){const btns=document.getElementById('navbarBtns');if(isLoggedIn()){btns.innerHTML='<div class="user-info"><span class="user-name">'+escapeHtml(ssoUser)+'</span><div class="divider"></div><button class="btn btn-primary btn-sm" onclick="showModal(\'addModal\')">添加链接</button><button class="btn btn-secondary btn-sm" onclick="showModal(\'catModal\')">新建分类</button><button class="btn btn-secondary btn-sm" onclick="ssoLogout()">退出</button></div>'}else{btns.innerHTML='<button class="btn btn-primary" onclick="ssoLogin()">🔐 SSO 统一登录</button>'}renderCards()}
|
||||
|
||||
function showModal(id){document.getElementById(id).classList.add('show');document.querySelectorAll(`#${id} input`).forEach(i=>i.value='');document.querySelectorAll(`#${id} .alert`).forEach(a=>a.classList.remove('show'));if(id==='addModal')renderCategoryOptions('addCategory','')}
|
||||
function closeModal(id){document.getElementById(id).classList.remove('show')}
|
||||
function showAlert(modalId,msg,type){const alert=document.querySelector(`#${modalId} .alert`);alert.textContent=msg;alert.className=`alert alert-${type} show`;setTimeout(()=>alert.classList.remove('show'),3000)}
|
||||
|
||||
async function doAddLink(){const name=document.getElementById('addName').value.trim();const url=document.getElementById('addUrl').value.trim();const desc=document.getElementById('addDesc').value.trim();if(!name){showAlert('addModal','请输入名称','error');return}if(!url||!/^https?:\/\/.+/.test(url)){showAlert('addModal','请输入有效的链接地址(以http://或https://开头)','error');return}try{const res=await fetch(`${API_BASE}/api/links`,{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({name,url,desc,icon:getIcon(name)})});if(res.ok){await fetchLinks();renderCards();closeModal('addModal')}else{const data=await res.json();showAlert('addModal',data.detail||'添加失败','error')}}catch(err){showAlert('addModal','网络错误','error')}}
|
||||
async function doAddLink(){const name=document.getElementById('addName').value.trim();const url=document.getElementById('addUrl').value.trim();const desc=document.getElementById('addDesc').value.trim();const category=document.getElementById('addCategory').value;if(!name){showAlert('addModal','请输入名称','error');return}if(!url||!/^https?:\/\/.+/.test(url)){showAlert('addModal','请输入有效的链接地址(以http://或https://开头)','error');return}try{const res=await fetch(`${API_BASE}/api/links`,{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({name,url,desc,icon:getIcon(name),category})});if(res.ok){await fetchLinks();renderCards();renderSidebar();closeModal('addModal')}else{const data=await res.json();showAlert('addModal',data.detail||'添加失败','error')}}catch(err){showAlert('addModal','网络错误','error')}}
|
||||
async function doAddCategory(){const name=document.getElementById('catName').value.trim();if(!name){showAlert('catModal','请输入分类名称','error');return}try{const res=await fetch(`${API_BASE}/api/categories`,{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({name})});if(res.ok){await fetchCategories();renderSidebar();closeModal('catModal')}else{const data=await res.json();showAlert('catModal',data.detail||'创建失败','error')}}catch(err){showAlert('catModal','网络错误','error')}}
|
||||
|
||||
document.querySelectorAll('.modal-overlay').forEach(m=>{m.addEventListener('click',e=>{if(e.target===m)closeModal(m.id)})});
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape')document.querySelectorAll('.modal-overlay.show').forEach(m=>closeModal(m.id))});
|
||||
|
||||
fetchLinks().then(()=>renderCards());checkSSOStatus();
|
||||
Promise.all([fetchLinks(),fetchCategories()]).then(()=>{renderSidebar();renderCards()});checkSSOStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+55
-4
@@ -29,6 +29,16 @@ def init_db():
|
||||
url TEXT NOT NULL,
|
||||
desc TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '',
|
||||
category TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)""")
|
||||
# 兼容迁移:旧库补充 category 列
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(links)").fetchall()]
|
||||
if "category" not in cols:
|
||||
conn.execute("ALTER TABLE links ADD COLUMN category TEXT DEFAULT ''")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)""")
|
||||
# 首次启动:如果表为空则写入默认数据
|
||||
@@ -91,23 +101,50 @@ class LinkCreate(BaseModel):
|
||||
url: str
|
||||
desc: str = ""
|
||||
icon: str = ""
|
||||
category: str = ""
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
@app.get("/api/links")
|
||||
def list_links():
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("SELECT id,name,url,desc,icon FROM links ORDER BY sort_order, id").fetchall()
|
||||
rows = conn.execute("SELECT id,name,url,desc,icon,category FROM links ORDER BY sort_order, id").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/categories")
|
||||
def list_categories():
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("SELECT id,name FROM categories ORDER BY sort_order, id").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.post("/api/categories")
|
||||
def add_category(cat: CategoryCreate, username: str = Depends(require_sso)):
|
||||
import time, random
|
||||
name = cat.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "分类名称不能为空")
|
||||
cid = f"c{int(time.time()):x}{random.randint(0,0xffffff):06x}"
|
||||
with get_db() as conn:
|
||||
try:
|
||||
max_sort = conn.execute("SELECT COALESCE(MAX(sort_order),0) FROM categories").fetchone()[0]
|
||||
conn.execute("INSERT INTO categories (id,name,sort_order) VALUES (?,?,?)",
|
||||
(cid, name, max_sort + 1))
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(400, "分类已存在")
|
||||
return {"id": cid, "name": name}
|
||||
|
||||
@app.post("/api/links")
|
||||
def add_link(link: LinkCreate, username: str = Depends(require_sso)):
|
||||
import time, random
|
||||
lid = f"{int(time.time()):x}{random.randint(0,0xffffff):06x}"
|
||||
with get_db() as conn:
|
||||
max_sort = conn.execute("SELECT COALESCE(MAX(sort_order),0) FROM links").fetchone()[0]
|
||||
conn.execute("INSERT INTO links (id,name,url,desc,icon,sort_order) VALUES (?,?,?,?,?,?)",
|
||||
(lid, link.name, link.url, link.desc, link.icon, max_sort + 1))
|
||||
conn.execute("INSERT INTO links (id,name,url,desc,icon,category,sort_order) VALUES (?,?,?,?,?,?,?)",
|
||||
(lid, link.name, link.url, link.desc, link.icon, link.category, max_sort + 1))
|
||||
conn.commit()
|
||||
return {"id": lid, "name": link.name, "url": link.url, "desc": link.desc, "icon": link.icon}
|
||||
return {"id": lid, "name": link.name, "url": link.url, "desc": link.desc, "icon": link.icon, "category": link.category}
|
||||
|
||||
@app.delete("/api/links/{link_id}")
|
||||
def delete_link(link_id: str, username: str = Depends(require_sso)):
|
||||
@@ -118,6 +155,20 @@ def delete_link(link_id: str, username: str = Depends(require_sso)):
|
||||
raise HTTPException(404, "链接不存在")
|
||||
return {"success": True}
|
||||
|
||||
@app.put("/api/links/{link_id}")
|
||||
def update_link(link_id: str, link: LinkCreate, username: str = Depends(require_sso)):
|
||||
with get_db() as conn:
|
||||
row = conn.execute("SELECT * FROM links WHERE id=?", (link_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "链接不存在")
|
||||
# 图标不传则保留原值,避免改名称时图标被重新生成
|
||||
icon = link.icon if link.icon else row["icon"]
|
||||
# 分类始终按提交值更新(空字符串 = 未归类)
|
||||
conn.execute("UPDATE links SET name=?, url=?, desc=?, icon=?, category=? WHERE id=?",
|
||||
(link.name, link.url, link.desc, icon, link.category, link_id))
|
||||
conn.commit()
|
||||
return {"id": link_id, "name": link.name, "url": link.url, "desc": link.desc, "icon": icon, "category": link.category}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8887)
|
||||
|
||||
Reference in New Issue
Block a user