Files
nanocode/www/index.html
T
2026-08-07 21:29:19 +08:00

239 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nancode Portal</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);min-height:100vh;color:#333}
.navbar{height:60px;background:rgba(255,255,255,.95);backdrop-filter:blur(10px);display:flex;align-items:center;justify-content:space-between;padding:0 24px;position:fixed;top:0;left:0;right:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,.1)}
.navbar-brand{font-size:1.3rem;font-weight:700;color:#667eea;letter-spacing:-.5px}
.navbar-btns{display:flex;gap:12px;align-items:center}
.btn{padding:8px 18px;border:none;border-radius:8px;cursor:pointer;font-size:14px;transition:all .2s}
.btn-primary{background:#667eea;color:#fff}
.btn-primary:hover{background:#5a6fd6;transform:translateY(-1px)}
.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 0 40px;max-width:1600px;margin:0 auto}
.layout{display:flex;gap:24px;align-items:flex-start}
.content{flex:1;min-width:0;padding-right:24px}
.sidebar{width:200px;flex-shrink:0;position:sticky;top:84px;background:rgba(255,255,255,.95);border-radius:0 16px 16px 0;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(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: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:.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: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}
.input-group{display:flex;flex-direction:column;gap:5px}
.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}
.alert{padding:10px 14px;border-radius:8px;font-size:.85rem;margin-bottom:10px;display:none}
.alert.show{display:block}
.alert-error{background:#fef2f2;color:#dc2626;border:1px solid #fecaca}
.alert-success{background:#f0fdf4;color:#16a34a;border:1px solid #bbf7d0}
.user-info{display:flex;align-items:center;gap:10px}
.user-name{font-size:.9rem;color:#374151;font-weight:500}
.divider{height:20px;width:1px;background:#e5e7eb}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">🚀 Nancode Portal</div>
<div class="navbar-btns" id="navbarBtns">
<button class="btn btn-primary" onclick="ssoLogin()">🔐 SSO 统一登录</button>
</div>
</nav>
<main class="main">
<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>
<!-- 添加链接模态框 -->
<div class="modal-overlay" id="addModal">
<div class="modal">
<div class="modal-header">添加链接</div>
<div class="alert alert-error" id="addAlert"></div>
<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">
<button class="btn btn-secondary" onclick="closeModal('addModal')">取消</button>
<button class="btn btn-primary" onclick="doAddLink()">添加</button>
</div>
</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"/>'},
server:{paths:'<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>'},
briefcase:{paths:'<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>'},
database:{paths:'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>'},
'book-open':{paths:'<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>'},
code:{paths:'<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>'},
monitor:{paths:'<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>'},
smartphone:{paths:'<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>'},
bot:{paths:'<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>'},
image:{paths:'<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>'},
mic:{paths:'<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>'},
headphones:{paths:'<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>'},
'gamepad-2':{paths:'<line x1="6" x2="10" y1="11" y2="11"/><line x1="8" x2="8" y1="9" y2="13"/><line x1="15" x2="15.01" y1="12" y2="12"/><line x1="18" x2="18.01" y1="10" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>'},
globe:{paths:'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'},
sparkles:{paths:'<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>'},
'message-circle':{paths:'<path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>'},
hexagon:{paths:'<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>'},
puzzle:{paths:'<path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a2.414 2.414 0 0 1-3.414 0l-1.682-1.683a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L8.39 4.39a2.414 2.414 0 0 1 3.414 0l1.682 1.683Z"/>'}
};
const ICON_KEYS=Object.keys(LUCIDE_ICONS);
const GRADIENTS=['linear-gradient(135deg,#667eea,#764ba2)','linear-gradient(135deg,#f093fb,#f5576c)','linear-gradient(135deg,#4facfe,#00f2fe)','linear-gradient(135deg,#43e97b,#38f9d7)','linear-gradient(135deg,#fa709a,#fee140)','linear-gradient(135deg,#a8edea,#fed6e3)','linear-gradient(135deg,#ff9a9e,#fecfef)','linear-gradient(135deg,#ffecd2,#fcb69f)','linear-gradient(135deg,#667eea,#764ba2)','linear-gradient(135deg,#11998e,#38ef7d)','linear-gradient(135deg,#fc5c7d,#6a82fb)','linear-gradient(135deg,#f6d365,#fda085)'];
function getIcon(name){let h=0;for(let i=0;i<name.length;i++)h=(h*31+name.charCodeAt(i))>>>0;return ICON_KEYS[h%ICON_KEYS.length]}
function getGradient(name){let h=0;for(let i=0;i<name.length;i++)h=(h*31+name.charCodeAt(i))>>>0;return GRADIENTS[h%GRADIENTS.length]}
function getDefaultLinks(){return[{id:'1',name:'个人博客',url:'http://129.211.173.87:8888',desc:'Next.js技术博客,个人技术文章与生活记录',icon:'book-open'},{id:'2',name:'相册查看器',url:'http://129.211.173.87:8892',desc:'图片相册浏览与管理工具',icon:'image'},{id:'3',name:'快乐游戏',url:'http://129.211.173.87:8895',desc:'Flask小游戏集合,休闲益智',icon:'gamepad-2'},{id:'4',name:'网游引擎',url:'http://129.211.173.87:8900',desc:'网络游戏引擎服务平台',icon:'server'},{id:'5',name:'有声书播放器',url:'http://129.211.173.87:8899',desc:'在线有声书播放与TTS服务',icon:'headphones'},{id:'6',name:'WanAndroid',url:'http://129.211.173.87:8893',desc:'Material Design风格的Web应用',icon:'smartphone'},{id:'7',name:'FumaMX CRM',url:'http://129.211.173.87:10000',desc:'客户关系管理系统',icon:'briefcase'},{id:'8',name:'AI聊天服务',url:'http://129.211.173.87:8890',desc:'AI对话机器人服务',icon:'bot'},{id:'9',name:'新媒体平台',url:'http://129.211.173.87:18888',desc:'CMS内容管理系统与网站后台',icon:'layout-dashboard'},{id:'10',name:'微信小程序桥接',url:'http://129.211.173.87:5000',desc:'微信-Nanobot消息桥接服务',icon:'message-circle'},{id:'11',name:'音频录制工具',url:'http://129.211.173.87:8901',desc:'在线音频录制与处理工具',icon:'mic'},{id:'12',name:'粒子实验室',url:'http://129.211.173.87:8902',desc:'粒子特效与图形渲染实验',icon:'sparkles'},{id:'13',name:'WebGPU演示',url:'http://129.211.173.87:8902',desc:'WebGPU与PlayCanvas 3D演示',icon:'globe'},{id:'14',name:'Hermes网关',url:'http://129.211.173.87:8894',desc:'AI网关与代理服务',icon:'hexagon'}]}
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}
async function checkSSOStatus(){try{const res=await fetch(`${SSO_BASE}/api/userinfo`,{credentials:'include'});if(res.ok){const data=await res.json();ssoUser=data.username}else{ssoUser=null}}catch(e){ssoUser=null}updateNavbar()}
function ssoLogin(){const redirect=window.location.origin+window.location.pathname;window.location.href=`${SSO_BASE}/?redirect=${encodeURIComponent(redirect)}`}
async function ssoLogout(){try{await fetch(`${SSO_BASE}/api/logout`,{method:'POST',credentials:'include'})}catch(e){}ssoUser=null;updateNavbar()}
function genId(){return Date.now().toString(36)+Math.random().toString(36).substr(2,5)}
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=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}')">&#9998;</div><div class="card-close" title="删除" onclick="deleteLink(event,'${link.id}')">&times;</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('')}
function escapeHtml(text){if(!text)return'';return text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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')||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){}}
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 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();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))});
Promise.all([fetchLinks(),fetchCategories()]).then(()=>{renderSidebar();renderCards()});checkSSOStatus();
</script>
</body>
</html>